checkpoint: marketplace and operational features complete, ready for Week 4 data migration
Summary of completed work: - Complete buyer portal (browse, cart, checkout, orders, invoices) - Complete seller portal (orders, manifests, fleet, picking) - Business onboarding wizards (buyer 4-step, seller 5-step) - Email verification and registration flows - Notification system for buyers and sellers - Payment term surcharges and pickup/delivery workflows - Filament admin resources (Business, Brand, Product, Order, Invoice, User) - 51 migrations executed successfully Renamed Company -> Business throughout codebase for consistency. Unified authentication flows with password reset. Added audit trail and telescope for debugging. Next: Week 4 Data Migration (Days 22-28) - Product migration (883 products from cannabrands_crm) - Company migration (81 buyer companies) - User migration (preserve password hashes) - Order history migration
This commit is contained in:
71
app/Console/Commands/BackfillBusinessOwners.php
Normal file
71
app/Console/Commands/BackfillBusinessOwners.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class BackfillBusinessOwners extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'businesses:backfill-owners';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Backfill owner_user_id for existing businesses based on primary or first user';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Backfilling business owners...');
|
||||
|
||||
// Get all businesses without an owner set
|
||||
$businesses = Business::whereNull('owner_user_id')->get();
|
||||
|
||||
if ($businesses->isEmpty()) {
|
||||
$this->info('No businesses found without an owner.');
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$businesses->count()} businesses without an owner.");
|
||||
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($businesses as $business) {
|
||||
// Try to find primary user first
|
||||
$primaryUser = $business->users()->wherePivot('is_primary', true)->first();
|
||||
|
||||
// If no primary user, get the first user
|
||||
$owner = $primaryUser ?? $business->users()->first();
|
||||
|
||||
if ($owner) {
|
||||
$business->update(['owner_user_id' => $owner->id]);
|
||||
$this->line("✓ Business #{$business->id} ({$business->name}): Set owner to {$owner->first_name} {$owner->last_name} ({$owner->email})");
|
||||
$updated++;
|
||||
} else {
|
||||
$this->warn("✗ Business #{$business->id} ({$business->name}): No users found, skipping");
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("Backfill complete!");
|
||||
$this->info("Updated: {$updated}");
|
||||
|
||||
if ($skipped > 0) {
|
||||
$this->warn("Skipped: {$skipped} (no users associated)");
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
// Simulate order workflow to ready_for_invoice status
|
||||
$this->progressOrderToInvoice($order);
|
||||
$this->info("✓ Progressed order through workflow to 'invoiced' status");
|
||||
$this->info("✓ Progressed order through workflow to 'awaiting_invoice_approval' status");
|
||||
|
||||
// Generate invoice
|
||||
$invoiceService = app(InvoiceService::class);
|
||||
@@ -204,7 +204,7 @@ class CreateTestInvoiceForApproval extends Command
|
||||
|
||||
// Invoice generated
|
||||
$order->update([
|
||||
'status' => 'invoiced',
|
||||
'status' => 'awaiting_invoice_approval',
|
||||
'invoiced_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -17,19 +17,19 @@ class FixManifestOrderStatus extends Command
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = 'Update orders that have manifests but are still at manifest_created status';
|
||||
protected $description = 'Update orders that have manifests but are still at ready_for_manifest status';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Finding orders with manifests stuck at manifest_created status...');
|
||||
$this->info('Finding orders with manifests stuck at ready_for_manifest status...');
|
||||
|
||||
// Find all orders that:
|
||||
// 1. Have status = 'manifest_created'
|
||||
// 1. Have status = 'ready_for_manifest'
|
||||
// 2. Have an associated manifest
|
||||
$orders = Order::where('status', 'manifest_created')
|
||||
$orders = Order::where('status', 'ready_for_manifest')
|
||||
->has('manifest')
|
||||
->get();
|
||||
|
||||
|
||||
103
app/Enums/ContactType.php
Normal file
103
app/Enums/ContactType.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ContactType: string
|
||||
{
|
||||
case OWNER = 'owner';
|
||||
case BUYER = 'buyer';
|
||||
case BUDTENDER = 'budtender';
|
||||
case ACCOUNTS_PAYABLE = 'accounts_payable';
|
||||
case ACCOUNTS_RECEIVABLE = 'accounts_receivable';
|
||||
case RECEIVING = 'receiving';
|
||||
case COMPLIANCE = 'compliance';
|
||||
case OPERATIONS = 'operations';
|
||||
case SALES = 'sales';
|
||||
case MARKETING = 'marketing';
|
||||
case IT = 'it';
|
||||
case LEGAL = 'legal';
|
||||
case STAFF = 'staff';
|
||||
case EMERGENCY = 'emergency';
|
||||
|
||||
/**
|
||||
* Get human-readable label for contact type
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match($this) {
|
||||
self::OWNER => 'Owner',
|
||||
self::BUYER => 'Buyer/Purchasing Manager',
|
||||
self::BUDTENDER => 'Budtender',
|
||||
self::ACCOUNTS_PAYABLE => 'Accounts Payable',
|
||||
self::ACCOUNTS_RECEIVABLE => 'Accounts Receivable',
|
||||
self::RECEIVING => 'Receiving/Warehouse',
|
||||
self::COMPLIANCE => 'Compliance Officer',
|
||||
self::OPERATIONS => 'Operations Manager',
|
||||
self::SALES => 'Sales Representative',
|
||||
self::MARKETING => 'Marketing',
|
||||
self::IT => 'IT Administrator',
|
||||
self::LEGAL => 'Legal Counsel',
|
||||
self::STAFF => 'General Staff',
|
||||
self::EMERGENCY => 'Emergency Contact',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get suggested RBAC role for this contact type
|
||||
*/
|
||||
public function suggestedRole(string $userType): string
|
||||
{
|
||||
$prefix = $userType === 'seller' ? 'company' : 'buyer';
|
||||
|
||||
return match($this) {
|
||||
self::OWNER => "{$prefix}-owner",
|
||||
self::COMPLIANCE, self::OPERATIONS => "{$prefix}-manager",
|
||||
default => "{$prefix}-user",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all contact types as array for select options
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return collect(self::cases())->mapWithKeys(function (self $type) {
|
||||
return [$type->value => $type->label()];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get buyer-specific contact types
|
||||
*/
|
||||
public static function buyerTypes(): array
|
||||
{
|
||||
return [
|
||||
self::OWNER,
|
||||
self::BUYER,
|
||||
self::BUDTENDER,
|
||||
self::ACCOUNTS_PAYABLE,
|
||||
self::RECEIVING,
|
||||
self::COMPLIANCE,
|
||||
self::OPERATIONS,
|
||||
self::STAFF,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get seller-specific contact types
|
||||
*/
|
||||
public static function sellerTypes(): array
|
||||
{
|
||||
return [
|
||||
self::OWNER,
|
||||
self::SALES,
|
||||
self::ACCOUNTS_RECEIVABLE,
|
||||
self::MARKETING,
|
||||
self::COMPLIANCE,
|
||||
self::OPERATIONS,
|
||||
self::IT,
|
||||
self::LEGAL,
|
||||
self::STAFF,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,15 @@ class BrandForm
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Select::make('company_id')
|
||||
->relationship('company', 'name')
|
||||
->required(),
|
||||
Select::make('business_id')
|
||||
->relationship('business', 'name', function ($query) {
|
||||
return $query->where('status', 'approved')
|
||||
->where('onboarding_completed', true);
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->helperText('Only approved and onboarded businesses'),
|
||||
TextInput::make('name')
|
||||
->required(),
|
||||
TextInput::make('slug')
|
||||
|
||||
@@ -12,7 +12,7 @@ class BrandInfolist
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
TextEntry::make('company.name'),
|
||||
TextEntry::make('business.name'),
|
||||
TextEntry::make('name'),
|
||||
TextEntry::make('slug'),
|
||||
TextEntry::make('tagline'),
|
||||
|
||||
@@ -19,7 +19,7 @@ class BrandsTable
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('company.name')
|
||||
TextColumn::make('business.name')
|
||||
->searchable(),
|
||||
TextColumn::make('name')
|
||||
->searchable(),
|
||||
|
||||
753
app/Filament/Resources/BusinessResource.php
Normal file
753
app/Filament/Resources/BusinessResource.php
Normal file
@@ -0,0 +1,753 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use App\Models\Contact;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Infolists\Infolist;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\Section as InfoSection;
|
||||
use Filament\Infolists\Components\RepeatableEntry;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use App\Filament\Resources\BusinessResource\Pages\ListBusinesses;
|
||||
use App\Filament\Resources\BusinessResource\Pages\CreateBusiness;
|
||||
use App\Filament\Resources\BusinessResource\Pages\EditBusiness;
|
||||
use App\Filament\Resources\BusinessResource\Pages;
|
||||
use App\Filament\Resources\BusinessResource\RelationManagers;
|
||||
use App\Models\Business;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class BusinessResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Business::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static ?string $navigationLabel = 'Businesses';
|
||||
|
||||
protected static ?string $modelLabel = 'Business';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Businesses';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Business Details')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Business Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('dba_name')
|
||||
->label('DBA Name')
|
||||
->maxLength(255),
|
||||
Select::make('business_type')
|
||||
->label('Business Type')
|
||||
->required()
|
||||
->options([
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'both' => 'Both Retailer & Brand',
|
||||
])
|
||||
->default('retailer'),
|
||||
TextInput::make('business_group')
|
||||
->label('Business Group')
|
||||
->helperText('For multi-location operators (e.g., "Jars Cannabis Co.")')
|
||||
->maxLength(255),
|
||||
TextInput::make('business_email')
|
||||
->label('Business Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('business_phone')
|
||||
->label('Business Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
]),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('License & Legal')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('license_number')
|
||||
->label('Cannabis License Number')
|
||||
->maxLength(255),
|
||||
TextInput::make('license_type')
|
||||
->label('License Type')
|
||||
->maxLength(255),
|
||||
DatePicker::make('license_issue_date')
|
||||
->label('License Issue Date'),
|
||||
DatePicker::make('license_expiration_date')
|
||||
->label('License Expiration Date'),
|
||||
TextInput::make('tin_ein')
|
||||
->label('TIN/EIN')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Addresses')
|
||||
->schema([
|
||||
Section::make('Physical Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('physical_address')
|
||||
->label('Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('city')
|
||||
->maxLength(255),
|
||||
TextInput::make('state')
|
||||
->maxLength(255),
|
||||
TextInput::make('zipcode')
|
||||
->label('ZIP Code')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Billing Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('billing_address')
|
||||
->label('Billing Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('billing_city')
|
||||
->label('Billing City')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_state')
|
||||
->label('Billing State')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_zipcode')
|
||||
->label('Billing ZIP Code')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
|
||||
Tab::make('Users & Access')
|
||||
->schema([
|
||||
Repeater::make('users')
|
||||
->relationship('users')
|
||||
->helperText('Users with login credentials and access to manage this business')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Role/Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary')
|
||||
->default(function ($record, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
return $business && $business->users()->count() <= 1;
|
||||
})
|
||||
->disabled(function ($record, $get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if ($business && $business->users()->count() === 1) {
|
||||
return true;
|
||||
}
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->users()->wherePivot('is_primary', true)->count();
|
||||
return $primaryCount <= 1;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
->helperText(function ($record, $get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if ($business && $business->users()->count() === 1) {
|
||||
return 'Locked - there must always be one primary user';
|
||||
}
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->users()->wherePivot('is_primary', true)->count();
|
||||
if ($primaryCount <= 1) {
|
||||
return 'Cannot disable - there must always be one primary user';
|
||||
}
|
||||
}
|
||||
return 'Mark as the main user for this business';
|
||||
})
|
||||
->inline(false),
|
||||
]),
|
||||
])
|
||||
->saveRelationshipsUsing(function ($component, $state, $record) {
|
||||
if (!$record) return;
|
||||
$syncData = [];
|
||||
foreach ($state as $item) {
|
||||
if (isset($item['id'])) {
|
||||
$user = \App\Models\User::find($item['id']);
|
||||
if ($user) {
|
||||
$user->update([
|
||||
'first_name' => $item['first_name'] ?? null,
|
||||
'last_name' => $item['last_name'] ?? null,
|
||||
'email' => $item['email'] ?? null,
|
||||
'phone' => $item['phone'] ?? null,
|
||||
]);
|
||||
}
|
||||
$syncData[$item['id']] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
$record->users()->sync($syncData);
|
||||
})
|
||||
->itemLabel(fn (array $state): ?string =>
|
||||
trim(($state['first_name'] ?? '') . ' ' . ($state['last_name'] ?? '')) ?:
|
||||
(isset($state['email']) ? $state['email'] : 'New User')
|
||||
)
|
||||
->defaultItems(0)
|
||||
->addActionLabel('Add Platform User')
|
||||
->deleteAction(
|
||||
fn (Action $action) => $action
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Remove User Access')
|
||||
->modalDescription('This will remove platform access for this user. They will no longer be able to log in or manage this business.')
|
||||
->modalSubmitActionLabel('Yes, Remove Access')
|
||||
)
|
||||
->reorderable(false)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('Business Contacts')
|
||||
->schema([
|
||||
Repeater::make('contacts')
|
||||
->relationship('contacts')
|
||||
->helperText('Business contacts for operations (billing, receiving, etc.) - may not have platform login')
|
||||
->schema([
|
||||
Grid::make(3)
|
||||
->schema([
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->label('Phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Contact Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary')
|
||||
->default(function ($record, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
return $business && $business->contacts()->count() <= 1;
|
||||
})
|
||||
->disabled(function ($record, $get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if ($business && $business->contacts()->count() === 1) {
|
||||
return true;
|
||||
}
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->contacts()->where('is_primary', true)->count();
|
||||
return $primaryCount <= 1;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
->helperText(function ($record, $get, $livewire) {
|
||||
$business = $livewire->getRecord();
|
||||
if ($business && $business->contacts()->count() === 1) {
|
||||
return 'Locked - there must always be one primary contact';
|
||||
}
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->contacts()->where('is_primary', true)->count();
|
||||
if ($primaryCount <= 1) {
|
||||
return 'Cannot disable - there must always be one primary contact';
|
||||
}
|
||||
}
|
||||
return 'Mark as the main contact for operations';
|
||||
})
|
||||
->inline(false),
|
||||
]),
|
||||
])
|
||||
->itemLabel(fn (array $state): ?string =>
|
||||
trim(($state['first_name'] ?? '') . ' ' . ($state['last_name'] ?? '')) ?:
|
||||
(isset($state['email']) ? $state['email'] : 'New Contact')
|
||||
)
|
||||
->defaultItems(0)
|
||||
->addActionLabel('Add Business Contact')
|
||||
->deleteAction(
|
||||
fn (Action $action) => $action
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Remove Contact')
|
||||
->modalDescription('Are you sure you want to remove this contact from the business directory?')
|
||||
->modalSubmitActionLabel('Yes, Remove')
|
||||
)
|
||||
->reorderable(false)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('Status & Settings')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
Toggle::make('onboarding_completed')
|
||||
->label('Onboarding Complete')
|
||||
->default(false),
|
||||
Select::make('status')
|
||||
->label('Status')
|
||||
->options([
|
||||
'not_started' => 'Not Started',
|
||||
'in_progress' => 'In Progress',
|
||||
'submitted' => 'Submitted',
|
||||
'approved' => 'Approved',
|
||||
'rejected' => 'Rejected',
|
||||
])
|
||||
->default('not_started')
|
||||
->required(),
|
||||
DateTimePicker::make('approved_at')
|
||||
->label('Approved At'),
|
||||
TextInput::make('commission_rate')
|
||||
->label('Commission Rate (%)')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->default(0),
|
||||
TextInput::make('credit_limit')
|
||||
->label('Credit Limit')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->prefix('$'),
|
||||
]),
|
||||
Textarea::make('notes')
|
||||
->label('Admin Notes')
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Danger Zone')
|
||||
->description('Irreversible and destructive actions.')
|
||||
->schema([
|
||||
Actions::make([
|
||||
Action::make('delete_business')
|
||||
->label('Delete Business')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Delete Business')
|
||||
->modalDescription('Are you sure you want to delete this business? This will permanently remove the business profile, all associated data, contacts, and cannot be undone.')
|
||||
->modalSubmitActionLabel('Yes, Delete Business')
|
||||
->action(function ($record) {
|
||||
$businessName = $record->name;
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Business Deleted')
|
||||
->body("'{$businessName}' has been permanently deleted.")
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return redirect()->route('filament.admin.resources.businesses.index');
|
||||
})
|
||||
->visible(fn($record) => $record && $record->exists),
|
||||
])
|
||||
->alignCenter(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('business_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'retailer' => 'success',
|
||||
'brand' => 'warning',
|
||||
'both' => 'info',
|
||||
default => 'gray',
|
||||
})
|
||||
->searchable()
|
||||
->sortable(),
|
||||
BadgeColumn::make('status')
|
||||
->label('Status')
|
||||
->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'not_started' => 'gray',
|
||||
'in_progress' => 'info',
|
||||
'submitted' => 'warning',
|
||||
'approved' => 'success',
|
||||
'rejected' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('owner.full_name')
|
||||
->label('Account Owner')
|
||||
->getStateUsing(function (Business $record): ?string {
|
||||
$owner = $record->owner;
|
||||
if ($owner) {
|
||||
$name = trim($owner->first_name . ' ' . $owner->last_name);
|
||||
return $name . ' (' . $owner->email . ')';
|
||||
}
|
||||
return 'N/A';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('owner', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('primary_contact')
|
||||
->label('Primary Contact')
|
||||
->getStateUsing(function (Business $record): ?string {
|
||||
$primaryUser = $record->users()->wherePivot('is_primary', true)->first();
|
||||
if ($primaryUser) {
|
||||
$name = trim($primaryUser->first_name . ' ' . $primaryUser->last_name);
|
||||
$contactType = $primaryUser->pivot->contact_type ?? null;
|
||||
|
||||
if ($contactType) {
|
||||
try {
|
||||
$role = \App\Enums\ContactType::from($contactType)->label();
|
||||
return $name . ' (' . $role . ')';
|
||||
} catch (\ValueError $e) {
|
||||
return $name . ' (' . ucfirst(str_replace('_', ' ', $contactType)) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
return 'N/A';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('users', function ($q) use ($search) {
|
||||
$q->where('first_name', 'like', "%{$search}%")
|
||||
->orWhere('last_name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('users_count')
|
||||
->label('Users')
|
||||
->counts('users')
|
||||
->sortable()
|
||||
->action(
|
||||
Action::make('view_users')
|
||||
->modalHeading(fn (Business $record) => $record->name . ' - Platform Users')
|
||||
->modalContent(function (Business $record) {
|
||||
$users = $record->users()->get();
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<div class="text-center py-8 text-gray-500">No platform users found for this business.</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$html = '<div class="space-y-4">';
|
||||
foreach ($users as $user) {
|
||||
$isPrimary = $user->pivot->is_primary ?? false;
|
||||
$contactType = $user->pivot->contact_type ?? 'N/A';
|
||||
$contactTypeLabel = Contact::CONTACT_TYPES[$contactType] ?? ucfirst($contactType);
|
||||
|
||||
$html .= '<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">';
|
||||
$html .= '<div class="flex items-start justify-between mb-2">';
|
||||
$html .= '<h3 class="font-semibold text-lg">' . e($user->first_name . ' ' . $user->last_name) . '</h3>';
|
||||
if ($isPrimary) {
|
||||
$html .= '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Primary</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="space-y-1 text-sm">';
|
||||
$html .= '<div><span class="font-medium">Email:</span> ' . e($user->email) . '</div>';
|
||||
if ($user->phone) {
|
||||
$html .= '<div><span class="font-medium">Phone:</span> ' . e($user->phone) . '</div>';
|
||||
}
|
||||
$html .= '<div><span class="font-medium">Role:</span> ' . e($contactTypeLabel) . '</div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->modalWidth('2xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
)
|
||||
->color('primary')
|
||||
->weight('medium')
|
||||
->tooltip('Click to view platform users'),
|
||||
TextColumn::make('contacts_count')
|
||||
->label('Contacts')
|
||||
->counts('contacts')
|
||||
->sortable()
|
||||
->action(
|
||||
Action::make('view_contacts')
|
||||
->modalHeading(fn (Business $record) => $record->name . ' - Business Contacts')
|
||||
->modalContent(function (Business $record) {
|
||||
$contacts = $record->contacts()->get();
|
||||
|
||||
if ($contacts->isEmpty()) {
|
||||
return new \Illuminate\Support\HtmlString(
|
||||
'<div class="text-center py-8 text-gray-500">No business contacts found for this business.</div>'
|
||||
);
|
||||
}
|
||||
|
||||
$html = '<div class="space-y-4">';
|
||||
foreach ($contacts as $contact) {
|
||||
$isPrimary = $contact->is_primary ?? false;
|
||||
$contactTypeLabel = Contact::CONTACT_TYPES[$contact->contact_type] ?? ucfirst($contact->contact_type ?? 'N/A');
|
||||
|
||||
$html .= '<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">';
|
||||
$html .= '<div class="flex items-start justify-between mb-2">';
|
||||
$html .= '<h3 class="font-semibold text-lg">' . e($contact->first_name . ' ' . $contact->last_name) . '</h3>';
|
||||
if ($isPrimary) {
|
||||
$html .= '<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">Primary</span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<div class="space-y-1 text-sm">';
|
||||
if ($contact->email) {
|
||||
$html .= '<div><span class="font-medium">Email:</span> ' . e($contact->email) . '</div>';
|
||||
}
|
||||
if ($contact->phone) {
|
||||
$html .= '<div><span class="font-medium">Phone:</span> ' . e($contact->phone) . '</div>';
|
||||
}
|
||||
$html .= '<div><span class="font-medium">Type:</span> ' . e($contactTypeLabel) . '</div>';
|
||||
$html .= '</div>';
|
||||
$html .= '</div>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
|
||||
return new \Illuminate\Support\HtmlString($html);
|
||||
})
|
||||
->modalWidth('2xl')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
)
|
||||
->color('primary')
|
||||
->weight('medium')
|
||||
->tooltip('Click to view business contacts'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('business_type')
|
||||
->label('Business Type')
|
||||
->options([
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'both' => 'Both',
|
||||
]),
|
||||
TernaryFilter::make('is_active')
|
||||
->label('Active'),
|
||||
TernaryFilter::make('onboarding_completed')
|
||||
->label('Onboarding Complete'),
|
||||
SelectFilter::make('status')
|
||||
->label('Status')
|
||||
->options([
|
||||
'not_started' => 'Not Started',
|
||||
'in_progress' => 'In Progress',
|
||||
'submitted' => 'Submitted',
|
||||
'approved' => 'Approved',
|
||||
'rejected' => 'Rejected',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
ActionGroup::make([
|
||||
EditAction::make()
|
||||
->label('View/Edit')
|
||||
->icon('heroicon-o-pencil'),
|
||||
Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Business $record) => 'Approve ' . $record->name . '?')
|
||||
->modalDescription(fn (Business $record) =>
|
||||
'This will approve the business application and grant access to the platform.' . "\n\n" .
|
||||
'Business Type: ' . ($record->business_type ? (Business::BUSINESS_TYPES[$record->business_type] ?? $record->business_type) : 'N/A') . "\n" .
|
||||
'License: ' . ($record->license_number ?? 'N/A') . "\n" .
|
||||
'Submitted: ' . ($record->application_submitted_at ? $record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Approve Application')
|
||||
->modalIcon('heroicon-o-check-circle')
|
||||
->action(function (Business $record) {
|
||||
$record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Send approval notification to business owner
|
||||
if ($record->owner) {
|
||||
$record->owner->notify(new \App\Notifications\UserApproved());
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Approved')
|
||||
->body('Business "' . $record->name . '" has been approved successfully.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Business $record) => $record->status === 'submitted'),
|
||||
Action::make('reject_application')
|
||||
->label('Reject Application')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn (Business $record) => 'Reject ' . $record->name . '?')
|
||||
->modalDescription(fn (Business $record) =>
|
||||
'This will reject the business application and notify the applicant.' . "\n\n" .
|
||||
'Business Type: ' . ($record->business_type ? (Business::BUSINESS_TYPES[$record->business_type] ?? $record->business_type) : 'N/A') . "\n" .
|
||||
'License: ' . ($record->license_number ?? 'N/A') . "\n" .
|
||||
'Submitted: ' . ($record->application_submitted_at ? $record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Reject Application')
|
||||
->modalIcon('heroicon-o-x-circle')
|
||||
->form([
|
||||
Forms\Components\Textarea::make('rejection_reason')
|
||||
->label('Rejection Reason')
|
||||
->required()
|
||||
->rows(4)
|
||||
->maxLength(1000)
|
||||
->helperText('Provide a clear reason for rejection. This will be sent to the applicant.')
|
||||
])
|
||||
->action(function (Business $record, array $data) {
|
||||
$record->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => $data['rejection_reason'],
|
||||
]);
|
||||
|
||||
// Send rejection notification to business owner
|
||||
if ($record->owner) {
|
||||
$record->owner->notify(new \App\Notifications\ApplicationRejectedNotification($record, $data['rejection_reason']));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Rejected')
|
||||
->body('Business "' . $record->name . '" has been rejected.')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Business $record) => $record->status === 'submitted'),
|
||||
])
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('white')
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('approve_applications')
|
||||
->label('Approve Selected')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records) {
|
||||
$approved = 0;
|
||||
$records->each(function (Business $record) use (&$approved) {
|
||||
if ($record->status === 'submitted') {
|
||||
$record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
$approved++;
|
||||
}
|
||||
});
|
||||
|
||||
Notification::make()
|
||||
->title('Applications Approved')
|
||||
->body("Successfully approved {$approved} business applications.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
\Tapp\FilamentAuditing\RelationManagers\AuditsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListBusinesses::route('/'),
|
||||
'create' => CreateBusiness::route('/create'),
|
||||
'edit' => EditBusiness::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateBusiness extends CreateRecord
|
||||
{
|
||||
protected static string $resource = BusinessResource::class;
|
||||
}
|
||||
130
app/Filament/Resources/BusinessResource/Pages/EditBusiness.php
Normal file
130
app/Filament/Resources/BusinessResource/Pages/EditBusiness.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BusinessResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BusinessResource;
|
||||
use App\Models\Business;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditBusiness extends EditRecord
|
||||
{
|
||||
protected static string $resource = BusinessResource::class;
|
||||
|
||||
/**
|
||||
* Livewire listeners for audit trail integration.
|
||||
*/
|
||||
protected $listeners = [
|
||||
'auditRestored' => 'onAuditRestored',
|
||||
];
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn () => 'Approve ' . $this->record->name . '?')
|
||||
->modalDescription(fn () =>
|
||||
'This will approve the business application and grant access to the platform.' . "\n\n" .
|
||||
'Business Type: ' . ($this->record->business_type ? (Business::BUSINESS_TYPES[$this->record->business_type] ?? $this->record->business_type) : 'N/A') . "\n" .
|
||||
'License: ' . ($this->record->license_number ?? 'N/A') . "\n" .
|
||||
'Submitted: ' . ($this->record->application_submitted_at ? $this->record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Approve Application')
|
||||
->modalIcon('heroicon-o-check-circle')
|
||||
->action(function () {
|
||||
$this->record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
// Send approval notification to business owner
|
||||
if ($this->record->owner) {
|
||||
$this->record->owner->notify(new \App\Notifications\UserApproved());
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Approved')
|
||||
->body('Business "' . $this->record->name . '" has been approved successfully.')
|
||||
->success()
|
||||
->send();
|
||||
|
||||
// Redirect to list
|
||||
return redirect()->route('filament.admin.resources.businesses.index');
|
||||
})
|
||||
->visible(fn () => $this->record->status === 'submitted'),
|
||||
|
||||
Actions\Action::make('reject_application')
|
||||
->label('Reject Application')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading(fn () => 'Reject ' . $this->record->name . '?')
|
||||
->modalDescription(fn () =>
|
||||
'This will reject the business application and notify the applicant.' . "\n\n" .
|
||||
'Business Type: ' . ($this->record->business_type ? (Business::BUSINESS_TYPES[$this->record->business_type] ?? $this->record->business_type) : 'N/A') . "\n" .
|
||||
'License: ' . ($this->record->license_number ?? 'N/A') . "\n" .
|
||||
'Submitted: ' . ($this->record->application_submitted_at ? $this->record->application_submitted_at->diffForHumans() : 'N/A')
|
||||
)
|
||||
->modalSubmitActionLabel('Yes, Reject Application')
|
||||
->modalIcon('heroicon-o-x-circle')
|
||||
->form([
|
||||
Forms\Components\Textarea::make('rejection_reason')
|
||||
->label('Rejection Reason')
|
||||
->required()
|
||||
->rows(4)
|
||||
->maxLength(1000)
|
||||
->helperText('Provide a clear reason for rejection. This will be sent to the applicant.')
|
||||
])
|
||||
->action(function (array $data) {
|
||||
$this->record->update([
|
||||
'status' => 'rejected',
|
||||
'rejected_at' => now(),
|
||||
'rejection_reason' => $data['rejection_reason'],
|
||||
]);
|
||||
|
||||
// Send rejection notification to business owner
|
||||
if ($this->record->owner) {
|
||||
$this->record->owner->notify(new \App\Notifications\ApplicationRejectedNotification($this->record, $data['rejection_reason']));
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title('Application Rejected')
|
||||
->body('Business "' . $this->record->name . '" has been rejected.')
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
// Redirect to list
|
||||
return redirect()->route('filament.admin.resources.businesses.index');
|
||||
})
|
||||
->visible(fn () => $this->record->status === 'submitted'),
|
||||
|
||||
// Delete action moved to Danger Zone in Status & Settings tab for safety
|
||||
];
|
||||
}
|
||||
|
||||
protected function afterSave(): void
|
||||
{
|
||||
// Dispatch Livewire event to refresh the Audits relation manager
|
||||
$this->dispatch('updateAuditsRelationManager');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the audit restoration event.
|
||||
*
|
||||
* Refreshes the form data after an audit entry is restored from the Audits tab.
|
||||
* This ensures the form displays the restored values without requiring a page refresh.
|
||||
*/
|
||||
public function onAuditRestored(): void
|
||||
{
|
||||
$this->record->refresh();
|
||||
$this->fillForm();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CompanyResource\Pages;
|
||||
namespace App\Filament\Resources\BusinessResource\Pages;
|
||||
|
||||
use Filament\Actions\CreateAction;
|
||||
use App\Filament\Resources\CompanyResource;
|
||||
use App\Filament\Resources\BusinessResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListCompanies extends ListRecords
|
||||
class ListBusinesses extends ListRecords
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
protected static string $resource = BusinessResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CompanyResource\RelationManagers;
|
||||
namespace App\Filament\Resources\BusinessResource\RelationManagers;
|
||||
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Section;
|
||||
@@ -17,7 +17,7 @@ use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DetachAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DetachBulkAction;
|
||||
use App\Models\Company;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Models\User;
|
||||
use Filament\Forms;
|
||||
@@ -1,543 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use App\Models\Contact;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Schemas\Components\Actions;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Filters\Filter;
|
||||
use Filament\Actions\ViewAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use App\Filament\Resources\CompanyResource\Pages\ListCompanies;
|
||||
use App\Filament\Resources\CompanyResource\Pages\CreateCompany;
|
||||
use App\Filament\Resources\CompanyResource\Pages\EditCompany;
|
||||
use App\Filament\Resources\CompanyResource\Pages;
|
||||
use App\Filament\Resources\CompanyResource\RelationManagers;
|
||||
use App\Models\Company;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class CompanyResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Company::class;
|
||||
|
||||
protected static string | \BackedEnum | null $navigationIcon = 'heroicon-o-building-office-2';
|
||||
|
||||
protected static ?string $navigationLabel = 'Companies';
|
||||
|
||||
protected static ?string $modelLabel = 'Company';
|
||||
|
||||
protected static ?string $pluralModelLabel = 'Companies';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->components([
|
||||
Tabs::make('Business Details')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Company Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('dba_name')
|
||||
->label('DBA Name')
|
||||
->maxLength(255),
|
||||
Select::make('business_type')
|
||||
->label('Business Type')
|
||||
->required()
|
||||
->options([
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'both' => 'Both Retailer & Brand',
|
||||
])
|
||||
->default('retailer'),
|
||||
TextInput::make('business_group')
|
||||
->label('Business Group')
|
||||
->helperText('For multi-location operators (e.g., "Jars Cannabis Co.")')
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->email()
|
||||
->maxLength(255),
|
||||
TextInput::make('phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
]),
|
||||
Textarea::make('description')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('License & Legal')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('license_number')
|
||||
->label('Cannabis License Number')
|
||||
->maxLength(255),
|
||||
TextInput::make('license_type')
|
||||
->label('License Type')
|
||||
->maxLength(255),
|
||||
DatePicker::make('license_issue_date')
|
||||
->label('License Issue Date'),
|
||||
DatePicker::make('license_expiration_date')
|
||||
->label('License Expiration Date'),
|
||||
TextInput::make('tin_ein')
|
||||
->label('TIN/EIN')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Tab::make('Addresses')
|
||||
->schema([
|
||||
Section::make('Physical Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('physical_address')
|
||||
->label('Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('city')
|
||||
->maxLength(255),
|
||||
TextInput::make('state')
|
||||
->maxLength(255),
|
||||
TextInput::make('zipcode')
|
||||
->label('ZIP Code')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
|
||||
Section::make('Billing Address')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('billing_address')
|
||||
->label('Billing Street Address')
|
||||
->maxLength(255)
|
||||
->columnSpan(2),
|
||||
TextInput::make('billing_city')
|
||||
->label('Billing City')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_state')
|
||||
->label('Billing State')
|
||||
->maxLength(255),
|
||||
TextInput::make('billing_zipcode')
|
||||
->label('Billing ZIP Code')
|
||||
->maxLength(255),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
|
||||
Tab::make('Business Contacts')
|
||||
->schema([
|
||||
Repeater::make('users')
|
||||
->relationship('users')
|
||||
->schema([
|
||||
Grid::make(4)
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
->label('Contact Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('contact_type')
|
||||
->label('Contact Type')
|
||||
->required()
|
||||
->options(Contact::CONTACT_TYPES)
|
||||
->default('staff')
|
||||
->searchable(),
|
||||
Toggle::make('is_primary')
|
||||
->label('Primary')
|
||||
->default(function ($record, $livewire) {
|
||||
// Get the business from the Livewire component
|
||||
$business = $livewire->getRecord();
|
||||
// If this is the only contact, default to primary
|
||||
return $business && $business->users()->count() <= 1;
|
||||
})
|
||||
->disabled(function ($record, $get, $livewire) {
|
||||
// Get the business from the Livewire component
|
||||
$business = $livewire->getRecord();
|
||||
// If this is the only contact, lock as primary
|
||||
if ($business && $business->users()->count() === 1) {
|
||||
return true;
|
||||
}
|
||||
// Also prevent turning off primary if there would be no primary contacts left
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->users()->wherePivot('is_primary', true)->count();
|
||||
return $primaryCount <= 1; // Don't allow turning off if this is the last primary
|
||||
}
|
||||
return false;
|
||||
})
|
||||
->helperText(function ($record, $get, $livewire) {
|
||||
// Get the business from the Livewire component
|
||||
$business = $livewire->getRecord();
|
||||
if ($business && $business->users()->count() === 1) {
|
||||
return 'Locked - there must always be one primary contact';
|
||||
}
|
||||
$isPrimary = $get('is_primary');
|
||||
if ($isPrimary && $business) {
|
||||
$primaryCount = $business->users()->wherePivot('is_primary', true)->count();
|
||||
if ($primaryCount <= 1) {
|
||||
return 'Cannot disable - there must always be one primary contact';
|
||||
}
|
||||
}
|
||||
return 'Mark as the main contact for this business';
|
||||
})
|
||||
->inline(false),
|
||||
]),
|
||||
])
|
||||
->saveRelationshipsUsing(function ($component, $state, $record) {
|
||||
// Custom relationship saving logic
|
||||
if (!$record) return;
|
||||
|
||||
$syncData = [];
|
||||
|
||||
foreach ($state as $item) {
|
||||
if (isset($item['id'])) {
|
||||
$syncData[$item['id']] = [
|
||||
'contact_type' => $item['contact_type'] ?? 'staff',
|
||||
'is_primary' => $item['is_primary'] ?? false,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$record->users()->sync($syncData);
|
||||
})
|
||||
->itemLabel(fn (array $state): ?string =>
|
||||
($state['name'] ?? 'New Contact') .
|
||||
(isset($state['contact_type']) ? ' (' . (Contact::CONTACT_TYPES[$state['contact_type']] ?? 'Unknown') . ')' : '')
|
||||
)
|
||||
->collapsible()
|
||||
->defaultItems(0)
|
||||
->addActionLabel('Add Contact')
|
||||
->deleteAction(
|
||||
fn (Action $action) => $action
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Remove Contact')
|
||||
->modalDescription('Are you sure you want to remove this contact from the business?')
|
||||
->modalSubmitActionLabel('Yes, Remove')
|
||||
)
|
||||
->reorderable(false)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
Tab::make('Status & Settings')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
Toggle::make('is_approved')
|
||||
->label('Approved')
|
||||
->default(false),
|
||||
Toggle::make('onboarding_completed')
|
||||
->label('Onboarding Complete')
|
||||
->default(false),
|
||||
Select::make('setup_status')
|
||||
->label('Setup Status')
|
||||
->options(Company::SETUP_STATUSES)
|
||||
->default('incomplete'),
|
||||
DateTimePicker::make('approved_at')
|
||||
->label('Approved At'),
|
||||
TextInput::make('commission_rate')
|
||||
->label('Commission Rate (%)')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->default(0),
|
||||
TextInput::make('credit_limit')
|
||||
->label('Credit Limit')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->prefix('$'),
|
||||
]),
|
||||
Textarea::make('notes')
|
||||
->label('Admin Notes')
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Danger Zone')
|
||||
->description('Irreversible and destructive actions.')
|
||||
->schema([
|
||||
Actions::make([
|
||||
Action::make('delete_business')
|
||||
->label('Delete Business')
|
||||
->color('danger')
|
||||
->icon('heroicon-o-trash')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Delete Business')
|
||||
->modalDescription('Are you sure you want to delete this business? This will permanently remove the business profile, all associated data, contacts, and cannot be undone.')
|
||||
->modalSubmitActionLabel('Yes, Delete Business')
|
||||
->action(function ($record) {
|
||||
$businessName = $record->name;
|
||||
$record->delete();
|
||||
|
||||
Notification::make()
|
||||
->title('Business Deleted')
|
||||
->body("'{$businessName}' has been permanently deleted.")
|
||||
->success()
|
||||
->send();
|
||||
|
||||
return redirect()->route('filament.admin.resources.businesses.index');
|
||||
})
|
||||
->visible(fn($record) => $record && $record->exists),
|
||||
])
|
||||
->alignCenter(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Company Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('business_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'retailer' => 'success',
|
||||
'brand' => 'warning',
|
||||
'both' => 'info',
|
||||
default => 'gray',
|
||||
})
|
||||
->searchable(),
|
||||
BadgeColumn::make('setup_status')
|
||||
->label('Setup Status')
|
||||
->colors([
|
||||
'secondary' => 'incomplete',
|
||||
'warning' => 'in_progress',
|
||||
'info' => 'pending_review',
|
||||
'success' => 'complete',
|
||||
])
|
||||
->formatStateUsing(fn (string $state): string => Company::SETUP_STATUSES[$state] ?? 'Unknown')
|
||||
->sortable(),
|
||||
TextColumn::make('setup_progress')
|
||||
->label('Setup Progress')
|
||||
->getStateUsing(function ($record) {
|
||||
$progress = $record->getSetupProgress();
|
||||
return round($progress['percentage']) . '%';
|
||||
})
|
||||
->sortable()
|
||||
->toggleable(),
|
||||
TextColumn::make('business_group')
|
||||
->label('Group')
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('license_number')
|
||||
->label('License #')
|
||||
->searchable(),
|
||||
TextColumn::make('state')
|
||||
->searchable()
|
||||
->toggleable(),
|
||||
IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean(),
|
||||
IconColumn::make('is_approved')
|
||||
->label('Approved')
|
||||
->boolean(),
|
||||
IconColumn::make('onboarding_completed')
|
||||
->label('Onboarded')
|
||||
->boolean(),
|
||||
TextColumn::make('users_count')
|
||||
->label('Contacts')
|
||||
->counts('users')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Created')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('email')
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('phone')
|
||||
->searchable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('business_type')
|
||||
->label('Business Type')
|
||||
->options([
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'both' => 'Both',
|
||||
]),
|
||||
TernaryFilter::make('is_active')
|
||||
->label('Active'),
|
||||
TernaryFilter::make('is_approved')
|
||||
->label('Approved'),
|
||||
TernaryFilter::make('onboarding_completed')
|
||||
->label('Onboarding Complete'),
|
||||
SelectFilter::make('setup_status')
|
||||
->label('Setup Status')
|
||||
->options(Company::SETUP_STATUSES),
|
||||
Filter::make('needs_approval')
|
||||
->label('Needs Approval')
|
||||
->query(fn (Builder $query): Builder => $query->where('is_approved', false)),
|
||||
Filter::make('pending_review')
|
||||
->label('Pending Setup Review')
|
||||
->query(fn (Builder $query): Builder => $query->where('setup_status', 'pending_review')),
|
||||
])
|
||||
->recordActions([
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
Action::make('approve_application')
|
||||
->label('Approve Application')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Approve Company Application')
|
||||
->modalDescription('This will approve the company and transform application data into CRM records (locations, contacts).')
|
||||
->action(function (Company $record) {
|
||||
$record->update([
|
||||
'application_status' => 'approved',
|
||||
'is_approved' => true,
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Application Approved')
|
||||
->body('Company "' . $record->name . '" has been approved successfully.')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Company $record) => $record->application_status === 'pending' || !$record->is_approved),
|
||||
Action::make('reject_application')
|
||||
->label('Reject Application')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->modalHeading('Reject Company Application')
|
||||
->form([
|
||||
Forms\Components\Textarea::make('rejection_reason')
|
||||
->label('Rejection Reason')
|
||||
->required()
|
||||
->maxLength(1000)
|
||||
->helperText('Provide a clear reason for rejection. This will be sent to the applicant.')
|
||||
])
|
||||
->action(function (Company $record, array $data) {
|
||||
$record->update([
|
||||
'application_status' => 'rejected',
|
||||
'application_rejected_at' => now(),
|
||||
'application_rejection_reason' => $data['rejection_reason'],
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->title('Application Rejected')
|
||||
->body('Company "' . $record->name . '" has been rejected.')
|
||||
->warning()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Company $record) => $record->application_status === 'pending' || $record->application_status === null),
|
||||
Action::make('approve_setup')
|
||||
->label('Approve Setup')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function (Company $record) {
|
||||
$record->update([
|
||||
'setup_status' => 'complete',
|
||||
'is_approved' => true,
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
$record->markSetupComplete();
|
||||
|
||||
Notification::make()
|
||||
->title('Setup Approved')
|
||||
->success()
|
||||
->send();
|
||||
})
|
||||
->visible(fn (Company $record) => $record->setup_status === 'pending_review'),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
BulkAction::make('approve_applications')
|
||||
->label('Approve Applications')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->deselectRecordsAfterCompletion()
|
||||
->action(function (Collection $records) {
|
||||
$approved = 0;
|
||||
$records->each(function (Company $record) use (&$approved) {
|
||||
if (!$record->is_approved) {
|
||||
$record->update([
|
||||
'application_status' => 'approved',
|
||||
'is_approved' => true,
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
$approved++;
|
||||
}
|
||||
});
|
||||
|
||||
Notification::make()
|
||||
->title('Applications Approved')
|
||||
->body("Successfully approved {$approved} company applications.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListCompanies::route('/'),
|
||||
'create' => CreateCompany::route('/create'),
|
||||
'edit' => EditCompany::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CompanyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\CompanyResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateCompany extends CreateRecord
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\CompanyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\CompanyResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditCompany extends EditRecord
|
||||
{
|
||||
protected static string $resource = CompanyResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
// Delete action moved to Danger Zone in Status & Settings tab for safety
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,15 @@ class InvoiceForm
|
||||
Select::make('order_id')
|
||||
->relationship('order', 'id')
|
||||
->required(),
|
||||
Select::make('company_id')
|
||||
->relationship('company', 'name')
|
||||
->required(),
|
||||
Select::make('business_id')
|
||||
->relationship('business', 'name', function ($query) {
|
||||
return $query->where('status', 'approved')
|
||||
->where('onboarding_completed', true);
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->helperText('Only approved and onboarded businesses'),
|
||||
TextInput::make('subtotal')
|
||||
->required()
|
||||
->numeric()
|
||||
|
||||
@@ -21,7 +21,7 @@ class InvoicesTable
|
||||
->searchable(),
|
||||
TextColumn::make('order.id')
|
||||
->searchable(),
|
||||
TextColumn::make('company.name')
|
||||
TextColumn::make('business.name')
|
||||
->searchable(),
|
||||
TextColumn::make('subtotal')
|
||||
->numeric()
|
||||
|
||||
@@ -169,11 +169,11 @@ class EditOrder extends EditRecord
|
||||
->label('Approve Invoice & Create Manifest')
|
||||
->icon(Heroicon::OutlinedDocumentCheck)
|
||||
->color('success')
|
||||
->visible(fn ($record) => $record->status === 'invoiced')
|
||||
->visible(fn ($record) => $record->status === 'awaiting_invoice_approval')
|
||||
->requiresConfirmation()
|
||||
->modalDescription('This will approve the invoice and create a manifest for delivery preparation.')
|
||||
->action(function ($record) {
|
||||
$record->createManifest();
|
||||
$record->markReadyForManifest();
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Invoice Approved')
|
||||
@@ -185,7 +185,7 @@ class EditOrder extends EditRecord
|
||||
->label('Mark Ready for Delivery')
|
||||
->icon(Heroicon::OutlinedTruck)
|
||||
->color('warning')
|
||||
->visible(fn ($record) => $record->status === 'manifest_created')
|
||||
->visible(fn ($record) => $record->status === 'ready_for_manifest')
|
||||
->requiresConfirmation()
|
||||
->action(function ($record) {
|
||||
$record->markReadyForDelivery();
|
||||
|
||||
@@ -30,9 +30,9 @@ class OrderForm
|
||||
->required()
|
||||
->default('new'),
|
||||
|
||||
Select::make('company_id')
|
||||
->label('Company')
|
||||
->relationship('company', 'name')
|
||||
Select::make('business_id')
|
||||
->label('Business')
|
||||
->relationship('business', 'name')
|
||||
->disabled()
|
||||
->required(),
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ class OrderInfolist
|
||||
->label('Ordered')
|
||||
->dateTime('M j, Y g:i A'),
|
||||
|
||||
TextEntry::make('company.name')
|
||||
->label('Company'),
|
||||
TextEntry::make('business.name')
|
||||
->label('Business'),
|
||||
|
||||
TextEntry::make('user.name')
|
||||
->label('Ordered By')
|
||||
|
||||
@@ -23,7 +23,7 @@ class OrdersTable
|
||||
->copyable()
|
||||
->weight('medium'),
|
||||
|
||||
TextColumn::make('company.name')
|
||||
TextColumn::make('business.name')
|
||||
->label('Customer')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
@@ -111,8 +111,8 @@ class OrdersTable
|
||||
])
|
||||
->multiple(),
|
||||
|
||||
SelectFilter::make('company_id')
|
||||
->relationship('company', 'name')
|
||||
SelectFilter::make('business_id')
|
||||
->relationship('business', 'name')
|
||||
->label('Customer')
|
||||
->searchable()
|
||||
->preload(),
|
||||
|
||||
@@ -9,6 +9,9 @@ use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Components\Tabs;
|
||||
use Filament\Schemas\Components\Tabs\Tab;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class ProductForm
|
||||
@@ -16,243 +19,268 @@ class ProductForm
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
// Product Identity Section
|
||||
Section::make('Product Information')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Select::make('brand_id')
|
||||
->relationship('brand', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Tabs::make('Product Details')
|
||||
->tabs([
|
||||
Tab::make('Basic Information')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Select::make('brand_id')
|
||||
->relationship('brand', 'name', function ($query) {
|
||||
return $query->whereHas('business', function ($q) {
|
||||
$q->where('status', 'approved')
|
||||
->where('onboarding_completed', true);
|
||||
});
|
||||
})
|
||||
->searchable()
|
||||
->preload()
|
||||
->required()
|
||||
->helperText('Only brands from approved businesses'),
|
||||
|
||||
Select::make('strain_id')
|
||||
->relationship('strain', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Select the cannabis strain for this product'),
|
||||
Select::make('strain_id')
|
||||
->relationship('strain', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('Cannabis strain for this product'),
|
||||
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('sku')
|
||||
->label('SKU')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Auto-generated if left blank'),
|
||||
TextInput::make('sku')
|
||||
->label('SKU')
|
||||
->required()
|
||||
->unique(ignoreRecord: true)
|
||||
->helperText('Auto-generated if left blank'),
|
||||
|
||||
TextInput::make('slug')
|
||||
->helperText('Auto-generated from name if left blank'),
|
||||
TextInput::make('slug')
|
||||
->helperText('Auto-generated from name if left blank'),
|
||||
|
||||
Select::make('type')
|
||||
->options([
|
||||
'flower' => 'Flower',
|
||||
'pre_roll' => 'Pre-Roll',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'vape' => 'Vape',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'accessory' => 'Accessory',
|
||||
'raw_material' => 'Raw Material',
|
||||
'assembly' => 'Assembly',
|
||||
])
|
||||
->required()
|
||||
->default('flower')
|
||||
->reactive(),
|
||||
Select::make('type')
|
||||
->options([
|
||||
'flower' => 'Flower',
|
||||
'pre_roll' => 'Pre-Roll',
|
||||
'concentrate' => 'Concentrate',
|
||||
'edible' => 'Edible',
|
||||
'vape' => 'Vape',
|
||||
'topical' => 'Topical',
|
||||
'tincture' => 'Tincture',
|
||||
'accessory' => 'Accessory',
|
||||
'raw_material' => 'Raw Material',
|
||||
'assembly' => 'Assembly',
|
||||
])
|
||||
->required()
|
||||
->default('flower')
|
||||
->reactive(),
|
||||
|
||||
TextInput::make('category')
|
||||
->helperText('E.g., Indica, Sativa, Hybrid'),
|
||||
TextInput::make('category')
|
||||
->helperText('E.g., Indica, Sativa, Hybrid'),
|
||||
|
||||
Textarea::make('description')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Textarea::make('description')
|
||||
->rows(3)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
|
||||
// Cannabis-Specific Section
|
||||
Section::make('Cannabis Properties')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('thc_percentage')
|
||||
->label('THC %')
|
||||
->numeric()
|
||||
->suffix('%')
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
Section::make('Cannabis Properties')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('thc_percentage')
|
||||
->label('THC %')
|
||||
->numeric()
|
||||
->suffix('%')
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
|
||||
TextInput::make('cbd_percentage')
|
||||
->label('CBD %')
|
||||
->numeric()
|
||||
->suffix('%')
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
TextInput::make('cbd_percentage')
|
||||
->label('CBD %')
|
||||
->numeric()
|
||||
->suffix('%')
|
||||
->minValue(0)
|
||||
->maxValue(100),
|
||||
|
||||
TextInput::make('thc_content_mg')
|
||||
->label('THC Content (mg)')
|
||||
->numeric()
|
||||
->suffix('mg')
|
||||
->helperText('Total THC in milligrams'),
|
||||
TextInput::make('thc_content_mg')
|
||||
->label('THC Content (mg)')
|
||||
->numeric()
|
||||
->suffix('mg')
|
||||
->helperText('Total THC in milligrams'),
|
||||
|
||||
TextInput::make('cbd_content_mg')
|
||||
->label('CBD Content (mg)')
|
||||
->numeric()
|
||||
->suffix('mg')
|
||||
->helperText('Total CBD in milligrams'),
|
||||
]),
|
||||
TextInput::make('cbd_content_mg')
|
||||
->label('CBD Content (mg)')
|
||||
->numeric()
|
||||
->suffix('mg')
|
||||
->helperText('Total CBD in milligrams'),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Pricing & Packaging Section
|
||||
Section::make('Pricing & Packaging')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('wholesale_price')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->minValue(0),
|
||||
Tab::make('Pricing & Inventory')
|
||||
->schema([
|
||||
Section::make('Pricing')
|
||||
->columns(2)
|
||||
->schema([
|
||||
TextInput::make('wholesale_price')
|
||||
->label('Wholesale Price')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->minValue(0),
|
||||
|
||||
TextInput::make('cost_per_unit')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->minValue(0)
|
||||
->helperText('Cost to produce/acquire'),
|
||||
TextInput::make('cost_per_unit')
|
||||
->label('Cost Per Unit')
|
||||
->numeric()
|
||||
->prefix('$')
|
||||
->minValue(0)
|
||||
->helperText('Cost to produce/acquire'),
|
||||
|
||||
Select::make('price_unit')
|
||||
->options([
|
||||
'each' => 'Each',
|
||||
'gram' => 'Gram',
|
||||
'oz' => 'Ounce',
|
||||
'lb' => 'Pound',
|
||||
])
|
||||
->default('each'),
|
||||
Select::make('price_unit')
|
||||
->label('Price Unit')
|
||||
->options([
|
||||
'each' => 'Each',
|
||||
'gram' => 'Gram',
|
||||
'oz' => 'Ounce',
|
||||
'lb' => 'Pound',
|
||||
])
|
||||
->default('each'),
|
||||
]),
|
||||
|
||||
TextInput::make('net_weight')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.001),
|
||||
Section::make('Packaging')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('net_weight')
|
||||
->label('Net Weight')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->step(0.001),
|
||||
|
||||
Select::make('weight_unit')
|
||||
->options([
|
||||
'g' => 'Grams',
|
||||
'oz' => 'Ounces',
|
||||
'lb' => 'Pounds',
|
||||
])
|
||||
->default('g'),
|
||||
Select::make('weight_unit')
|
||||
->label('Weight Unit')
|
||||
->options([
|
||||
'g' => 'Grams',
|
||||
'oz' => 'Ounces',
|
||||
'lb' => 'Pounds',
|
||||
])
|
||||
->default('g'),
|
||||
|
||||
TextInput::make('units_per_case')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->helperText('How many units in a case'),
|
||||
]),
|
||||
TextInput::make('units_per_case')
|
||||
->label('Units Per Case')
|
||||
->numeric()
|
||||
->minValue(1)
|
||||
->helperText('Units in a case'),
|
||||
]),
|
||||
|
||||
// Inventory Section
|
||||
Section::make('Inventory')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('quantity_on_hand')
|
||||
->label('On Hand')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0)
|
||||
->required(),
|
||||
Section::make('Inventory Levels')
|
||||
->columns(3)
|
||||
->schema([
|
||||
TextInput::make('quantity_on_hand')
|
||||
->label('On Hand')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0)
|
||||
->required(),
|
||||
|
||||
TextInput::make('quantity_allocated')
|
||||
->label('Allocated')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0)
|
||||
->required()
|
||||
->helperText('Reserved for orders'),
|
||||
TextInput::make('quantity_allocated')
|
||||
->label('Allocated')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0)
|
||||
->required()
|
||||
->helperText('Reserved for orders'),
|
||||
|
||||
TextInput::make('reorder_point')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Trigger reorder when below this'),
|
||||
]),
|
||||
TextInput::make('reorder_point')
|
||||
->label('Reorder Point')
|
||||
->numeric()
|
||||
->minValue(0)
|
||||
->helperText('Reorder trigger'),
|
||||
]),
|
||||
]),
|
||||
|
||||
// BOM Section
|
||||
Section::make('Bill of Materials')
|
||||
->columns(2)
|
||||
->collapsed()
|
||||
->schema([
|
||||
Toggle::make('is_assembly')
|
||||
->label('Is Assembly?')
|
||||
->helperText('Made from components'),
|
||||
Tab::make('Compliance & Tracking')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
TextInput::make('metrc_id')
|
||||
->label('METRC ID')
|
||||
->helperText('State tracking system ID')
|
||||
->unique(ignoreRecord: true),
|
||||
|
||||
Toggle::make('is_raw_material')
|
||||
->label('Is Raw Material?')
|
||||
->helperText('Can be used as a component'),
|
||||
]),
|
||||
TextInput::make('license_number')
|
||||
->label('License Number'),
|
||||
|
||||
// Compliance Section
|
||||
Section::make('Compliance & Tracking')
|
||||
->columns(2)
|
||||
->collapsed()
|
||||
->schema([
|
||||
TextInput::make('metrc_id')
|
||||
->label('METRC ID')
|
||||
->helperText('State tracking system ID')
|
||||
->unique(ignoreRecord: true),
|
||||
DatePicker::make('harvest_date')
|
||||
->label('Harvest Date'),
|
||||
|
||||
TextInput::make('license_number')
|
||||
->label('License Number'),
|
||||
DatePicker::make('package_date')
|
||||
->label('Package Date'),
|
||||
|
||||
DatePicker::make('harvest_date'),
|
||||
DatePicker::make('test_date')
|
||||
->label('Lab Test Date')
|
||||
->helperText('Date of lab testing'),
|
||||
]),
|
||||
]),
|
||||
|
||||
DatePicker::make('package_date'),
|
||||
Tab::make('Media & Display')
|
||||
->schema([
|
||||
Grid::make(2)
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
|
||||
DatePicker::make('test_date')
|
||||
->helperText('Lab test date'),
|
||||
]),
|
||||
Toggle::make('is_featured')
|
||||
->label('Featured')
|
||||
->default(false),
|
||||
|
||||
// Display & SEO Section
|
||||
Section::make('Display & SEO')
|
||||
->columns(2)
|
||||
->collapsed()
|
||||
->schema([
|
||||
Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true),
|
||||
TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0),
|
||||
|
||||
Toggle::make('is_featured')
|
||||
->label('Featured')
|
||||
->default(false),
|
||||
FileUpload::make('image_path')
|
||||
->label('Primary Image')
|
||||
->image()
|
||||
->directory('products')
|
||||
->columnSpanFull(),
|
||||
|
||||
TextInput::make('sort_order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->minValue(0),
|
||||
TextInput::make('meta_title')
|
||||
->label('SEO Title')
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
|
||||
FileUpload::make('image_path')
|
||||
->label('Primary Image')
|
||||
->image()
|
||||
->directory('products')
|
||||
->columnSpanFull(),
|
||||
Textarea::make('meta_description')
|
||||
->label('SEO Description')
|
||||
->rows(2)
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
]),
|
||||
|
||||
TextInput::make('meta_title')
|
||||
->maxLength(255)
|
||||
->columnSpanFull(),
|
||||
Tab::make('Advanced')
|
||||
->schema([
|
||||
Section::make('Bill of Materials')
|
||||
->columns(2)
|
||||
->schema([
|
||||
Toggle::make('is_assembly')
|
||||
->label('Is Assembly?')
|
||||
->helperText('Made from components'),
|
||||
|
||||
Textarea::make('meta_description')
|
||||
->rows(2)
|
||||
->maxLength(500)
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Toggle::make('is_raw_material')
|
||||
->label('Is Raw Material?')
|
||||
->helperText('Can be used as a component'),
|
||||
]),
|
||||
|
||||
// Parent Product (for varieties)
|
||||
Section::make('Product Variants')
|
||||
->columns(1)
|
||||
->collapsed()
|
||||
->schema([
|
||||
Select::make('parent_product_id')
|
||||
->label('Parent Product')
|
||||
->relationship('parent', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('If this is a variant, select the parent product'),
|
||||
]),
|
||||
Section::make('Product Variants')
|
||||
->schema([
|
||||
Select::make('parent_product_id')
|
||||
->label('Parent Product')
|
||||
->relationship('parent', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->helperText('If this is a variant, select the parent product'),
|
||||
]),
|
||||
]),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ class UserResource extends Resource
|
||||
->components([
|
||||
Section::make('Personal Information')
|
||||
->schema([
|
||||
TextInput::make('name')
|
||||
TextInput::make('first_name')
|
||||
->label('First Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('last_name')
|
||||
->label('Last Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('email')
|
||||
@@ -55,42 +60,89 @@ class UserResource extends Resource
|
||||
TextInput::make('phone')
|
||||
->tel()
|
||||
->maxLength(255),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Account Settings')
|
||||
->schema([
|
||||
Select::make('user_type')
|
||||
->label('User Type')
|
||||
->required()
|
||||
->options([
|
||||
'admin' => 'Admin',
|
||||
'business' => 'Business',
|
||||
'buyer' => 'Buyer',
|
||||
'seller' => 'Seller',
|
||||
])
|
||||
->default('business'),
|
||||
->default('buyer'),
|
||||
Select::make('status')
|
||||
->label('Account Status')
|
||||
->required()
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'suspended' => 'Suspended',
|
||||
])
|
||||
->default('active'),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Business Information')
|
||||
Section::make('Business Association')
|
||||
->description('Business account this user belongs to')
|
||||
->schema([
|
||||
TextInput::make('business_name')
|
||||
->label('Business Name')
|
||||
->disabled()
|
||||
->formatStateUsing(fn ($record) => $record?->businesses()->first()?->name ?? 'N/A'),
|
||||
TextInput::make('business_role')
|
||||
->label('Permission Level')
|
||||
->disabled()
|
||||
->formatStateUsing(fn ($record) => ucfirst($record?->businesses()->first()?->pivot->role ?? 'N/A')),
|
||||
TextInput::make('contact_type')
|
||||
->label('Contact Type')
|
||||
->disabled()
|
||||
->formatStateUsing(function ($record) {
|
||||
if (!$record) return 'N/A';
|
||||
$contactType = $record->businesses()->first()?->pivot->contact_type;
|
||||
if ($contactType) {
|
||||
try {
|
||||
return \App\Enums\ContactType::from($contactType)->label();
|
||||
} catch (\ValueError $e) {
|
||||
return ucfirst(str_replace('_', ' ', $contactType));
|
||||
}
|
||||
}
|
||||
return 'N/A';
|
||||
}),
|
||||
TextInput::make('is_account_owner')
|
||||
->label('Account Owner')
|
||||
->disabled()
|
||||
->formatStateUsing(function ($record) {
|
||||
if (!$record) return 'No';
|
||||
$business = $record->businesses()->first();
|
||||
if ($business && $business->owner_user_id === $record->id) {
|
||||
return 'Yes';
|
||||
}
|
||||
return 'No';
|
||||
}),
|
||||
TextInput::make('is_primary_contact')
|
||||
->label('Primary Contact')
|
||||
->disabled()
|
||||
->formatStateUsing(fn ($record) => $record?->businesses()->first()?->pivot->is_primary ? 'Yes' : 'No'),
|
||||
])->columns(2)
|
||||
->visible(fn ($record) => $record && $record->exists && $record->businesses()->exists()),
|
||||
|
||||
Section::make('Temporary Registration Data')
|
||||
->description('Data collected during registration before business setup is completed')
|
||||
->schema([
|
||||
TextInput::make('temp_business_name')
|
||||
->label('Business Name')
|
||||
->label('Business Name (Temp)')
|
||||
->disabled()
|
||||
->maxLength(255),
|
||||
TextInput::make('temp_market')
|
||||
->label('Market')
|
||||
->label('Market (Temp)')
|
||||
->disabled()
|
||||
->maxLength(255),
|
||||
TextInput::make('temp_contact_type')
|
||||
->label('Contact Type (Temp)')
|
||||
->disabled()
|
||||
->maxLength(255),
|
||||
])->columns(2),
|
||||
|
||||
Section::make('Approval Status')
|
||||
->schema([
|
||||
Select::make('approval_status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'approved' => 'Approved',
|
||||
'rejected' => 'Rejected',
|
||||
])
|
||||
->required()
|
||||
->default('pending'),
|
||||
DateTimePicker::make('approved_at')
|
||||
->label('Approved At'),
|
||||
Select::make('approved_by')
|
||||
->label('Approved By')
|
||||
->relationship('approver', 'name')
|
||||
->searchable(),
|
||||
])->columns(2),
|
||||
]);
|
||||
}
|
||||
@@ -107,33 +159,66 @@ class UserResource extends Resource
|
||||
->label('Email')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('approval_status')
|
||||
TextColumn::make('user_type')
|
||||
->label('User Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'buyer' => 'info',
|
||||
'seller' => 'warning',
|
||||
'admin' => 'success',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => match ($state) {
|
||||
'buyer' => 'Buyer',
|
||||
'seller' => 'Seller',
|
||||
'admin' => 'Admin',
|
||||
default => ucfirst($state),
|
||||
})
|
||||
->sortable(),
|
||||
TextColumn::make('business')
|
||||
->label('Business')
|
||||
->getStateUsing(function ($record): ?string {
|
||||
$business = $record->businesses()->first();
|
||||
if ($business) {
|
||||
$role = $business->pivot->role ?? 'Member';
|
||||
return $business->name . ' (' . ucfirst($role) . ')';
|
||||
}
|
||||
return 'N/A';
|
||||
})
|
||||
->searchable(query: function ($query, $search) {
|
||||
return $query->whereHas('businesses', function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%");
|
||||
});
|
||||
}),
|
||||
TextColumn::make('status')
|
||||
->label('Status')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'pending' => 'warning',
|
||||
'approved' => 'success',
|
||||
'rejected' => 'danger',
|
||||
'active' => 'success',
|
||||
'inactive' => 'gray',
|
||||
'suspended' => 'danger',
|
||||
default => 'gray',
|
||||
})
|
||||
->formatStateUsing(fn (string $state): string => ucfirst($state))
|
||||
->sortable(),
|
||||
TextColumn::make('approved_at')
|
||||
->label('Approved At')
|
||||
TextColumn::make('created_at')
|
||||
->label('Registered At')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
TextColumn::make('approver.name')
|
||||
->label('Approved By'),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('approval_status')
|
||||
->options([
|
||||
'pending' => 'Pending',
|
||||
'approved' => 'Approved',
|
||||
'rejected' => 'Rejected',
|
||||
]),
|
||||
SelectFilter::make('user_type')
|
||||
->label('User Type')
|
||||
->options([
|
||||
'admin' => 'Admin',
|
||||
'business' => 'Business',
|
||||
'buyer' => 'Buyer',
|
||||
'seller' => 'Seller',
|
||||
]),
|
||||
SelectFilter::make('status')
|
||||
->options([
|
||||
'active' => 'Active',
|
||||
'inactive' => 'Inactive',
|
||||
'suspended' => 'Suspended',
|
||||
]),
|
||||
])
|
||||
->recordActions([
|
||||
@@ -141,59 +226,28 @@ class UserResource extends Resource
|
||||
EditAction::make()
|
||||
->label('View/Modify')
|
||||
->icon('heroicon-o-pencil'),
|
||||
Action::make('approve')
|
||||
->label('Approve')
|
||||
Action::make('suspend')
|
||||
->label('Suspend')
|
||||
->icon('heroicon-o-no-symbol')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (User $record) => $record->isActive())
|
||||
->action(function (User $record) {
|
||||
$record->update(['status' => 'suspended']);
|
||||
}),
|
||||
Action::make('activate')
|
||||
->label('Activate')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (User $record) => $record->isPending())
|
||||
->visible(fn (User $record) => !$record->isActive())
|
||||
->action(function (User $record) {
|
||||
$record->update([
|
||||
'approval_status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// Send approval email
|
||||
$record->notify(new UserApproved());
|
||||
}),
|
||||
Action::make('reject')
|
||||
->label('Deny')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->visible(fn (User $record) => $record->isPending())
|
||||
->action(function (User $record) {
|
||||
$record->update([
|
||||
'approval_status' => 'rejected',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
$record->update(['status' => 'active']);
|
||||
}),
|
||||
])
|
||||
->icon('heroicon-o-ellipsis-vertical')
|
||||
->color('white')
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkAction::make('approve_selected')
|
||||
->label('Approve Selected')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->isPending()) {
|
||||
$record->update([
|
||||
'approval_status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
]);
|
||||
// Send approval email
|
||||
$record->notify(new UserApproved());
|
||||
}
|
||||
}
|
||||
}),
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
|
||||
43
app/Filament/Widgets/BusinessOverview.php
Normal file
43
app/Filament/Widgets/BusinessOverview.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Business;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class BusinessOverview extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 100;
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
$totalBusinesses = Business::count();
|
||||
$buyers = Business::where('business_type', 'retailer')->count();
|
||||
$sellers = Business::where('business_type', 'brand')->count();
|
||||
$pendingApplications = Business::where('status', 'submitted')->count();
|
||||
$activeBusinesses = Business::where('is_active', true)->count();
|
||||
$suspendedBusinesses = Business::where('is_active', false)->count();
|
||||
|
||||
return [
|
||||
Stat::make('Total Businesses', $totalBusinesses)
|
||||
->description("Buyers: {$buyers} | Sellers: {$sellers}")
|
||||
->descriptionIcon('heroicon-o-building-office-2')
|
||||
->color('primary')
|
||||
->url(route('filament.admin.resources.businesses.index')),
|
||||
|
||||
Stat::make('Pending Applications', $pendingApplications)
|
||||
->description('Awaiting approval')
|
||||
->descriptionIcon('heroicon-o-clock')
|
||||
->color($pendingApplications > 0 ? 'warning' : 'success')
|
||||
->url(route('filament.admin.resources.businesses.index', ['tableFilters' => ['status' => ['values' => ['submitted']]]])),
|
||||
|
||||
Stat::make('Active Businesses', $activeBusinesses)
|
||||
->description("Suspended: {$suspendedBusinesses}")
|
||||
->descriptionIcon('heroicon-o-check-circle')
|
||||
->color('success'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ class LatestNotifications extends TableWidget
|
||||
|
||||
protected int | string | array $columnSpan = 'full';
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function getTableQuery(): Builder
|
||||
{
|
||||
return Notification::query()
|
||||
|
||||
45
app/Filament/Widgets/OrderMetrics.php
Normal file
45
app/Filament/Widgets/OrderMetrics.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class OrderMetrics extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 101;
|
||||
|
||||
protected ?string $pollingInterval = '30s';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
$totalOrders = Order::count();
|
||||
$thisMonth = Order::whereMonth('created_at', now()->month)
|
||||
->whereYear('created_at', now()->year)
|
||||
->count();
|
||||
|
||||
$newOrders = Order::where('status', 'new')->count();
|
||||
$inProgress = Order::whereIn('status', ['accepted', 'in_progress', 'ready_for_invoice'])->count();
|
||||
$delivered = Order::where('status', 'delivered')->count();
|
||||
|
||||
return [
|
||||
Stat::make('Orders This Month', $thisMonth)
|
||||
->description("Total: {$totalOrders}")
|
||||
->descriptionIcon('heroicon-o-shopping-cart')
|
||||
->color('primary')
|
||||
->url(route('filament.admin.resources.orders.index')),
|
||||
|
||||
Stat::make('New Orders', $newOrders)
|
||||
->description('Awaiting acceptance')
|
||||
->descriptionIcon('heroicon-o-bell-alert')
|
||||
->color($newOrders > 0 ? 'warning' : 'gray')
|
||||
->url(route('filament.admin.resources.orders.index', ['tableFilters' => ['status' => ['values' => ['new']]]])),
|
||||
|
||||
Stat::make('In Progress', $inProgress)
|
||||
->description("Delivered: {$delivered}")
|
||||
->descriptionIcon('heroicon-o-truck')
|
||||
->color('info'),
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Filament/Widgets/PendingActionsCard.php
Normal file
36
app/Filament/Widgets/PendingActionsCard.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Business;
|
||||
use App\Models\User;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class PendingActionsCard extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 103;
|
||||
|
||||
protected ?string $pollingInterval = '30s';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
$pendingBusinesses = Business::where('status', 'submitted')->count();
|
||||
$incompleteRegistrations = User::whereDoesntHave('businesses')
|
||||
->where('created_at', '<', now()->subDays(3))
|
||||
->count();
|
||||
|
||||
return [
|
||||
Stat::make('Pending Business Applications', $pendingBusinesses)
|
||||
->description('Awaiting approval')
|
||||
->descriptionIcon('heroicon-o-clock')
|
||||
->color($pendingBusinesses > 0 ? 'warning' : 'success')
|
||||
->url(route('filament.admin.resources.businesses.index', ['tableFilters' => ['status' => ['values' => ['submitted']]]])),
|
||||
|
||||
Stat::make('Incomplete Registrations', $incompleteRegistrations)
|
||||
->description('Users registered 3+ days ago without business')
|
||||
->descriptionIcon('heroicon-o-exclamation-triangle')
|
||||
->color($incompleteRegistrations > 0 ? 'danger' : 'success'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,28 +2,86 @@
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Business;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use App\Models\User;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Filament\Tables;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Filament\Notifications\Notification;
|
||||
|
||||
class PendingUsers extends TableWidget
|
||||
{
|
||||
protected static ?string $heading = 'Pending User Approvals';
|
||||
protected static ?int $sort = 104;
|
||||
|
||||
protected static ?string $heading = 'Recent Business Applications';
|
||||
|
||||
protected int | string | array $columnSpan = 'full';
|
||||
|
||||
public function getTableQuery(): Builder|Relation|null
|
||||
{
|
||||
return User::query()->where('approval_status', 'pending');
|
||||
return Business::query()
|
||||
->where('status', 'submitted')
|
||||
->latest()
|
||||
->limit(5);
|
||||
}
|
||||
|
||||
protected function getTableColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('name')->searchable(),
|
||||
TextColumn::make('email')->searchable(),
|
||||
TextColumn::make('created_at')->dateTime()->sortable(),
|
||||
TextColumn::make('name')
|
||||
->label('Business Name')
|
||||
->searchable()
|
||||
->url(fn ($record) => route('filament.admin.resources.businesses.edit', $record)),
|
||||
TextColumn::make('business_type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->formatStateUsing(fn (string $state): string => ucfirst($state)),
|
||||
TextColumn::make('owner.name')
|
||||
->label('Account Owner')
|
||||
->getStateUsing(fn ($record) => $record->owner ? $record->owner->first_name . ' ' . $record->owner->last_name : 'N/A'),
|
||||
BadgeColumn::make('status')
|
||||
->label('Status')
|
||||
->formatStateUsing(fn (string $state): string => ucfirst(str_replace('_', ' ', $state)))
|
||||
->colors([
|
||||
'warning' => 'submitted',
|
||||
'success' => 'approved',
|
||||
'danger' => 'rejected',
|
||||
'gray' => 'not_started',
|
||||
'info' => 'in_progress',
|
||||
]),
|
||||
TextColumn::make('application_submitted_at')
|
||||
->label('Submitted')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableRecordActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('approve')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->requiresConfirmation()
|
||||
->action(function (Business $record) {
|
||||
$record->update([
|
||||
'status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
'approved_by' => auth()->id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Business Approved')
|
||||
->body("'{$record->name}' has been approved.")
|
||||
->send();
|
||||
}),
|
||||
Action::make('view')
|
||||
->icon('heroicon-o-eye')
|
||||
->url(fn ($record) => route('filament.admin.resources.businesses.edit', $record)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
65
app/Filament/Widgets/RecentOrders.php
Normal file
65
app/Filament/Widgets/RecentOrders.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use Filament\Tables\Columns\BadgeColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Widgets\TableWidget;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class RecentOrders extends TableWidget
|
||||
{
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected static ?string $heading = 'Recent Orders';
|
||||
|
||||
protected int | string | array $columnSpan = 'full';
|
||||
|
||||
public function getTableQuery(): Builder|Relation|null
|
||||
{
|
||||
return Order::query()
|
||||
->with(['business', 'user'])
|
||||
->latest()
|
||||
->limit(10);
|
||||
}
|
||||
|
||||
protected function getTableColumns(): array
|
||||
{
|
||||
return [
|
||||
TextColumn::make('order_number')
|
||||
->label('Order #')
|
||||
->searchable()
|
||||
->weight('medium')
|
||||
->url(fn ($record) => route('filament.admin.resources.orders.view', $record)),
|
||||
TextColumn::make('business.name')
|
||||
->label('Customer')
|
||||
->searchable()
|
||||
->limit(30),
|
||||
BadgeColumn::make('status')
|
||||
->colors([
|
||||
'info' => 'new',
|
||||
'warning' => 'accepted',
|
||||
'primary' => 'in_progress',
|
||||
'success' => 'delivered',
|
||||
'danger' => 'cancelled',
|
||||
]),
|
||||
TextColumn::make('total')
|
||||
->money('USD')
|
||||
->sortable(),
|
||||
TextColumn::make('created_at')
|
||||
->label('Ordered')
|
||||
->dateTime('M j, g:i A')
|
||||
->sortable(),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getTableRecordActions(): array
|
||||
{
|
||||
return [
|
||||
\Filament\Tables\Actions\ViewAction::make()
|
||||
->url(fn ($record) => route('filament.admin.resources.orders.view', $record)),
|
||||
];
|
||||
}
|
||||
}
|
||||
72
app/Filament/Widgets/RevenueChart.php
Normal file
72
app/Filament/Widgets/RevenueChart.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class RevenueChart extends ChartWidget
|
||||
{
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected ?string $heading = 'Revenue Trend (Last 30 Days)';
|
||||
|
||||
protected ?string $maxHeight = '300px';
|
||||
|
||||
protected int | string | array $columnSpan = 2;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
// Get last 30 days of revenue
|
||||
$data = [];
|
||||
$labels = [];
|
||||
|
||||
for ($i = 29; $i >= 0; $i--) {
|
||||
$date = now()->subDays($i);
|
||||
$labels[] = $date->format('M j');
|
||||
|
||||
$revenue = Order::where('status', 'delivered')
|
||||
->whereDate('created_at', $date)
|
||||
->sum('total');
|
||||
|
||||
$data[] = $revenue;
|
||||
}
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => 'Revenue',
|
||||
'data' => $data,
|
||||
'fill' => 'start',
|
||||
'backgroundColor' => 'rgba(59, 130, 246, 0.1)',
|
||||
'borderColor' => 'rgb(59, 130, 246)',
|
||||
],
|
||||
],
|
||||
'labels' => $labels,
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
|
||||
protected function getOptions(): array
|
||||
{
|
||||
return [
|
||||
'plugins' => [
|
||||
'legend' => [
|
||||
'display' => false,
|
||||
],
|
||||
],
|
||||
'scales' => [
|
||||
'y' => [
|
||||
'ticks' => [
|
||||
'callback' => 'function(value) { return "$" + value.toLocaleString(); }',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ class RolesOverview extends TableWidget
|
||||
{
|
||||
protected static ?string $heading = 'Roles & Access';
|
||||
|
||||
public static function canView(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function getTableQuery(): Builder|Relation|null
|
||||
{
|
||||
return Role::query();
|
||||
|
||||
69
app/Filament/Widgets/SalesMetrics.php
Normal file
69
app/Filament/Widgets/SalesMetrics.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\Order;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
|
||||
class SalesMetrics extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected ?string $pollingInterval = '60s';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
// Total Revenue (all delivered orders)
|
||||
$totalRevenue = Order::where('status', 'delivered')->sum('total');
|
||||
|
||||
// This Month Revenue
|
||||
$thisMonthRevenue = Order::where('status', 'delivered')
|
||||
->whereMonth('created_at', now()->month)
|
||||
->whereYear('created_at', now()->year)
|
||||
->sum('total');
|
||||
|
||||
// Last Month Revenue (for comparison)
|
||||
$lastMonthRevenue = Order::where('status', 'delivered')
|
||||
->whereMonth('created_at', now()->subMonth()->month)
|
||||
->whereYear('created_at', now()->subMonth()->year)
|
||||
->sum('total');
|
||||
|
||||
// Calculate percentage change
|
||||
$percentageChange = 0;
|
||||
if ($lastMonthRevenue > 0) {
|
||||
$percentageChange = (($thisMonthRevenue - $lastMonthRevenue) / $lastMonthRevenue) * 100;
|
||||
}
|
||||
|
||||
// Average Order Value
|
||||
$deliveredOrders = Order::where('status', 'delivered')->count();
|
||||
$avgOrderValue = $deliveredOrders > 0 ? $totalRevenue / $deliveredOrders : 0;
|
||||
|
||||
// This Week Revenue
|
||||
$thisWeekRevenue = Order::where('status', 'delivered')
|
||||
->whereBetween('created_at', [now()->startOfWeek(), now()->endOfWeek()])
|
||||
->sum('total');
|
||||
|
||||
return [
|
||||
Stat::make('Total Revenue', '$' . number_format($totalRevenue, 2))
|
||||
->description("From {$deliveredOrders} delivered orders")
|
||||
->descriptionIcon('heroicon-o-banknotes')
|
||||
->color('success')
|
||||
->chart([7, 3, 4, 5, 6, 3, 5, 3]),
|
||||
|
||||
Stat::make('Revenue This Month', '$' . number_format($thisMonthRevenue, 2))
|
||||
->description(
|
||||
$percentageChange > 0
|
||||
? number_format($percentageChange, 1) . '% increase from last month'
|
||||
: number_format(abs($percentageChange), 1) . '% decrease from last month'
|
||||
)
|
||||
->descriptionIcon($percentageChange >= 0 ? 'heroicon-o-arrow-trending-up' : 'heroicon-o-arrow-trending-down')
|
||||
->color($percentageChange >= 0 ? 'success' : 'danger'),
|
||||
|
||||
Stat::make('This Week', '$' . number_format($thisWeekRevenue, 2))
|
||||
->description('Average Order: $' . number_format($avgOrderValue, 2))
|
||||
->descriptionIcon('heroicon-o-shopping-cart')
|
||||
->color('info'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -8,15 +8,17 @@ use Filament\Widgets\StatsOverviewWidget;
|
||||
|
||||
class UserStats extends StatsOverviewWidget
|
||||
{
|
||||
protected static ?int $sort = 102;
|
||||
|
||||
protected ?string $heading = 'User Overview';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
return [
|
||||
Stat::make('Total Users', User::count()),
|
||||
Stat::make('Approved Users', User::where('approval_status', 'approved')->count()),
|
||||
Stat::make('Pending Users', User::where('approval_status', 'pending')->count()),
|
||||
Stat::make('Rejected Users', User::where('approval_status', 'rejected')->count()),
|
||||
Stat::make('Active Users', User::where('status', 'active')->count()),
|
||||
Stat::make('Users with Businesses', User::whereHas('businesses')->count()),
|
||||
Stat::make('Suspended', User::where('status', 'suspended')->count()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class BuyerRegisteredUserController extends Controller
|
||||
'position' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'dispensary_name' => ['required', 'string', 'max:255'],
|
||||
'business_name' => ['required', 'string', 'max:255'],
|
||||
'market' => ['required', 'string', 'max:255'],
|
||||
'terms' => ['required', 'accepted'],
|
||||
]);
|
||||
@@ -55,8 +55,8 @@ class BuyerRegisteredUserController extends Controller
|
||||
'user_type' => 'buyer', // Set as buyer
|
||||
'approval_status' => 'approved', // Buyers get instant approval
|
||||
'approved_at' => now(),
|
||||
// Store temporary dispensary info until dispensary setup is completed
|
||||
'temp_business_name' => $request->dispensary_name,
|
||||
// Store temporary business info until business setup is completed
|
||||
'temp_business_name' => $request->business_name,
|
||||
'temp_market' => $request->market,
|
||||
]);
|
||||
|
||||
|
||||
@@ -54,17 +54,9 @@ class NewPasswordController extends Controller
|
||||
);
|
||||
|
||||
if ($status == Password::PASSWORD_RESET) {
|
||||
$user = User::where('email', $request->email)->with('retailer')->first();
|
||||
$user = User::where('email', $request->email)->first();
|
||||
if ($user) {
|
||||
Auth::login($user);
|
||||
if (! $user->hasRole('Super Admin')) {
|
||||
if (! $user->isApproved()) {
|
||||
Auth::logout();
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => 'Your account is not approved yet. Please wait for admin approval.'
|
||||
]);
|
||||
}
|
||||
}
|
||||
// Redirect to main dashboard after password reset
|
||||
return redirect(dashboard_url());
|
||||
}
|
||||
|
||||
@@ -28,14 +28,6 @@ class UnifiedAuthenticatedSessionController extends Controller
|
||||
|
||||
$user = Auth::user();
|
||||
|
||||
// Check approval based on user type - buyers use browse-first, sellers still need approval
|
||||
if (! $user->hasRole('Super Admin') && $user->user_type === 'seller' && ! $user->isApproved()) {
|
||||
Auth::logout();
|
||||
return redirect()->route('login')->withErrors([
|
||||
'email' => 'Your seller account is pending approval. Please wait for admin approval.'
|
||||
]);
|
||||
}
|
||||
|
||||
$request->session()->regenerate();
|
||||
|
||||
// Smart routing based on user type
|
||||
|
||||
@@ -24,7 +24,8 @@ class BusinessAuthController extends Controller
|
||||
|
||||
// If user already has business associations, redirect to dashboard
|
||||
if ($user->businesses()->exists()) {
|
||||
return redirect()->route('business.dashboard');
|
||||
$dashboardRoute = $user->user_type === 'seller' ? 'seller.dashboard' : 'buyer.dashboard';
|
||||
return redirect()->route($dashboardRoute);
|
||||
}
|
||||
|
||||
return view('business.profile', compact('user'));
|
||||
|
||||
@@ -11,32 +11,27 @@ use Illuminate\Http\Request;
|
||||
|
||||
class BusinessSetupController extends Controller
|
||||
{
|
||||
const STEPS = [
|
||||
// Seller onboarding steps (4 steps)
|
||||
const SELLER_STEPS = [
|
||||
1 => [
|
||||
'name' => 'Business Profile',
|
||||
'description' => 'Basic business information and address',
|
||||
'view' => 'business.setup.steps.business-profile',
|
||||
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state', 'physical_zipcode', 'location_phone', 'location_email']
|
||||
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state', 'physical_zipcode', 'business_phone', 'business_email']
|
||||
],
|
||||
2 => [
|
||||
'name' => 'Contact & Billing',
|
||||
'description' => 'Contact information and billing details',
|
||||
'view' => 'business.setup.steps.contact-billing',
|
||||
'fields' => ['phone', 'email', 'billing_address', 'billing_city', 'billing_state', 'billing_zipcode', 'payment_method', 'ap_contact_name', 'ap_contact_phone', 'ap_contact_email', 'ap_contact_sms', 'ap_preferred_contact_method']
|
||||
'fields' => ['business_phone', 'business_email', 'billing_address', 'billing_city', 'billing_state', 'billing_zipcode', 'ap_contact_first_name', 'ap_contact_last_name', 'ap_contact_phone', 'ap_contact_email', 'ap_contact_sms', 'ap_preferred_contact_method']
|
||||
],
|
||||
3 => [
|
||||
'name' => 'Delivery Information',
|
||||
'description' => 'Shipping and delivery preferences',
|
||||
'view' => 'business.setup.steps.delivery-info',
|
||||
'fields' => ['shipping_address', 'shipping_city', 'shipping_state', 'shipping_zipcode', 'delivery_preferences', 'delivery_directions', 'delivery_contact_name', 'delivery_contact_phone', 'delivery_contact_email', 'delivery_contact_sms', 'delivery_schedule']
|
||||
],
|
||||
4 => [
|
||||
'name' => 'Required Documents',
|
||||
'description' => 'Upload compliance documents',
|
||||
'view' => 'business.setup.steps.documents',
|
||||
'fields' => ['cannabis_license_path', 'w9_form_path', 'insurance_certificate_path', 'business_license_path']
|
||||
],
|
||||
5 => [
|
||||
4 => [
|
||||
'name' => 'Review & Submit',
|
||||
'description' => 'Review your information and submit',
|
||||
'view' => 'business.setup.steps.review',
|
||||
@@ -44,28 +39,77 @@ class BusinessSetupController extends Controller
|
||||
]
|
||||
];
|
||||
|
||||
// Buyer onboarding steps (4 steps - simplified)
|
||||
const BUYER_STEPS = [
|
||||
1 => [
|
||||
'name' => 'Business Profile',
|
||||
'description' => 'Business information and retail license',
|
||||
'view' => 'buyer.setup.steps.dispensary-profile',
|
||||
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state', 'physical_zipcode', 'business_phone', 'business_email']
|
||||
],
|
||||
2 => [
|
||||
'name' => 'Billing & Delivery',
|
||||
'description' => 'Payment terms and delivery information',
|
||||
'view' => 'buyer.setup.steps.billing-delivery',
|
||||
'fields' => ['billing_address', 'billing_city', 'billing_state', 'billing_zipcode', 'payment_method', 'ap_contact_first_name', 'ap_contact_last_name', 'ap_contact_phone', 'ap_contact_email', 'shipping_address', 'shipping_city', 'shipping_state', 'shipping_zipcode', 'delivery_contact_first_name', 'delivery_contact_last_name', 'delivery_contact_phone', 'delivery_preferences', 'delivery_directions', 'delivery_schedule']
|
||||
],
|
||||
3 => [
|
||||
'name' => 'Required Documents',
|
||||
'description' => 'Upload compliance documents',
|
||||
'view' => 'buyer.setup.steps.documents',
|
||||
'fields' => ['cannabis_license_path', 'w9_form_path', 'insurance_certificate_path', 'business_license_path']
|
||||
],
|
||||
4 => [
|
||||
'name' => 'Review & Submit',
|
||||
'description' => 'Review and submit your application',
|
||||
'view' => 'buyer.setup.steps.review',
|
||||
'fields' => ['buyer_name', 'buyer_email', 'preferred_contact_method']
|
||||
]
|
||||
];
|
||||
|
||||
/**
|
||||
* Get steps configuration based on user type
|
||||
*/
|
||||
private function getSteps($userType)
|
||||
{
|
||||
return $userType === 'buyer' ? self::BUYER_STEPS : self::SELLER_STEPS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get max step number based on user type
|
||||
*/
|
||||
private function getMaxStep($userType)
|
||||
{
|
||||
return 4; // Both buyer and seller now have 4 steps
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the business setup form step
|
||||
*/
|
||||
public function create(Request $request, $step = 1)
|
||||
{
|
||||
$step = max(1, min(5, (int)$step));
|
||||
|
||||
$user = $request->user();
|
||||
$userType = $user->user_type ?? 'seller';
|
||||
$maxStep = $this->getMaxStep($userType);
|
||||
$steps = $this->getSteps($userType);
|
||||
|
||||
$step = max(1, min($maxStep, (int)$step));
|
||||
|
||||
$business = $user->primaryBusiness() ?? $user->businesses()->first();
|
||||
|
||||
// If user has no business, create a blank one to start the setup process
|
||||
if (!$business) {
|
||||
$business = new Business([
|
||||
'name' => $user->temp_business_name ?? '',
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'business_email' => $user->email,
|
||||
'business_phone' => $user->phone,
|
||||
// Pre-populate contact fields from user registration
|
||||
'ap_contact_name' => trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')),
|
||||
'ap_contact_first_name' => $user->first_name,
|
||||
'ap_contact_last_name' => $user->last_name,
|
||||
'ap_contact_email' => $user->email,
|
||||
'ap_contact_phone' => $user->phone,
|
||||
'delivery_contact_name' => trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')),
|
||||
'delivery_contact_email' => $user->email,
|
||||
'delivery_contact_first_name' => $user->first_name,
|
||||
'delivery_contact_last_name' => $user->last_name,
|
||||
'delivery_contact_phone' => $user->phone,
|
||||
'buyer_name' => trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')),
|
||||
'buyer_email' => $user->email,
|
||||
@@ -77,9 +121,12 @@ class BusinessSetupController extends Controller
|
||||
'steps' => []
|
||||
];
|
||||
|
||||
$stepConfig = self::STEPS[$step];
|
||||
$stepConfig = $steps[$step];
|
||||
|
||||
return view('business.setup.wizard', compact('business', 'setupProgress', 'step', 'stepConfig'));
|
||||
// Determine which wizard view to use based on user type
|
||||
$wizardView = $userType === 'buyer' ? 'buyer.setup.wizard' : 'business.setup.wizard';
|
||||
|
||||
return view($wizardView, compact('business', 'setupProgress', 'step', 'stepConfig', 'steps', 'userType', 'maxStep'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,23 +134,69 @@ class BusinessSetupController extends Controller
|
||||
*/
|
||||
public function store(Request $request, $step = 1)
|
||||
{
|
||||
$step = max(1, min(5, (int)$step));
|
||||
$user = $request->user();
|
||||
$userType = $user->user_type ?? 'seller';
|
||||
$maxStep = $this->getMaxStep($userType);
|
||||
|
||||
$step = max(1, min($maxStep, (int)$step));
|
||||
$business = $user->primaryBusiness() ?? $user->businesses()->first();
|
||||
|
||||
// Debug logging
|
||||
Log::info('Business Setup Store', [
|
||||
'user_type' => $userType,
|
||||
'step' => $step,
|
||||
'action' => $request->input('action'),
|
||||
'request_data' => $request->except(['_token'])
|
||||
]);
|
||||
|
||||
// Get validation rules for this step
|
||||
$stepValidation = $this->getStepValidation($step);
|
||||
$stepValidation = $this->getStepValidation($step, $userType);
|
||||
|
||||
// If action is "save" (save progress), make all required fields optional and only validate present fields
|
||||
$isSaveAction = $request->input('action') === 'save';
|
||||
if ($isSaveAction) {
|
||||
// Only validate fields that are actually present in the request
|
||||
$presentFields = array_keys($request->except(['_token', 'action']));
|
||||
$stepValidation = collect($stepValidation)
|
||||
->filter(function($rules, $field) use ($presentFields) {
|
||||
// Only include validation for fields that are present
|
||||
return in_array($field, $presentFields);
|
||||
})
|
||||
->map(function($rules, $field) use ($business, $step) {
|
||||
// Keep 'name' required on step 1 if creating a new business (it's a NOT NULL database column)
|
||||
if ($field === 'name' && $step === 1 && (!$business || !$business->exists)) {
|
||||
return $rules; // Keep original required validation
|
||||
}
|
||||
|
||||
if (is_string($rules)) {
|
||||
// Replace 'required' with 'nullable' in pipe-delimited string
|
||||
$rules = preg_replace('/\brequired\b/', 'nullable', $rules);
|
||||
// Also remove 'required' from beginning/end if it's there
|
||||
$rules = preg_replace('/^required\|/', 'nullable|', $rules);
|
||||
$rules = preg_replace('/\|required$/', '|nullable', $rules);
|
||||
$rules = str_replace('|required|', '|nullable|', $rules);
|
||||
} elseif (is_array($rules)) {
|
||||
// Replace 'required' with 'nullable' in array of rules
|
||||
$rules = array_map(function($rule) {
|
||||
return $rule === 'required' ? 'nullable' : $rule;
|
||||
}, $rules);
|
||||
}
|
||||
return $rules;
|
||||
})
|
||||
->toArray();
|
||||
}
|
||||
|
||||
$validatedData = $request->validate($stepValidation);
|
||||
|
||||
// If saving progress, filter out empty values to avoid overwriting with blanks
|
||||
if ($isSaveAction) {
|
||||
$validatedData = array_filter($validatedData, function($value) {
|
||||
return $value !== null && $value !== '';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle document uploads
|
||||
$documentFields = ['cannabis_license', 'w9_form', 'insurance_certificate', 'business_license'];
|
||||
$documentFields = ['cannabis_license', 'w9_form', 'resale_certificate', 'insurance_certificate', 'business_license'];
|
||||
foreach ($documentFields as $field) {
|
||||
if ($request->hasFile($field)) {
|
||||
$file = $request->file($field);
|
||||
@@ -115,6 +208,24 @@ class BusinessSetupController extends Controller
|
||||
|
||||
if ($business && $business->exists) {
|
||||
// Update existing business
|
||||
// Only regenerate slug if name changed and slug doesn't exist or if explicitly updating name
|
||||
if (isset($validatedData['name']) && (!$business->slug || $business->name !== $validatedData['name'])) {
|
||||
$validatedData['slug'] = Str::slug($validatedData['name']);
|
||||
|
||||
// Ensure slug is unique (excluding current business)
|
||||
$originalSlug = $validatedData['slug'];
|
||||
$counter = 1;
|
||||
while (Business::where('slug', $validatedData['slug'])->where('id', '!=', $business->id)->exists()) {
|
||||
$validatedData['slug'] = $originalSlug . '-' . $counter;
|
||||
$counter++;
|
||||
}
|
||||
}
|
||||
|
||||
// Update status to in_progress if user is just starting
|
||||
if ($business->status === 'not_started') {
|
||||
$validatedData['status'] = 'in_progress';
|
||||
}
|
||||
|
||||
$business->update($validatedData);
|
||||
} else {
|
||||
// Create new business - generate slug if business name is provided
|
||||
@@ -130,6 +241,9 @@ class BusinessSetupController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial status for new business
|
||||
$validatedData['status'] = 'in_progress'; // They're saving step 1, so it's in progress
|
||||
|
||||
$business = Business::create($validatedData);
|
||||
|
||||
// Associate with user
|
||||
@@ -139,9 +253,6 @@ class BusinessSetupController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
// Update setup status based on current progress
|
||||
$business->updateSetupStatus();
|
||||
|
||||
// Check if setup is complete and mark accordingly
|
||||
$progress = $business->getSetupProgress();
|
||||
if ($progress['percentage'] >= 95) {
|
||||
@@ -166,18 +277,35 @@ class BusinessSetupController extends Controller
|
||||
// Final submission - mark setup as complete for admin review
|
||||
$business->markSetupComplete();
|
||||
|
||||
return redirect()->route('business.dashboard')
|
||||
->with('success', 'Business application submitted successfully! We\'ll review and get back to you within 1-2 business days.');
|
||||
// Mark user's business_onboarding_completed flag
|
||||
$user->business_onboarding_completed = true;
|
||||
$user->save();
|
||||
|
||||
// Notify admins of new application submission
|
||||
$sellerNotificationService = app(\App\Services\SellerNotificationService::class);
|
||||
$sellerNotificationService->businessApplicationSubmitted($business);
|
||||
|
||||
// Determine redirect route based on user type
|
||||
$dashboardRoute = $userType === 'buyer' ? 'buyer.dashboard' : 'seller.dashboard';
|
||||
$successMessage = $userType === 'buyer'
|
||||
? 'Business profile submitted successfully! We\'ll review and get back to you within 1-2 business days.'
|
||||
: 'Business application submitted successfully! We\'ll review and get back to you within 1-2 business days.';
|
||||
|
||||
return redirect()->route($dashboardRoute)
|
||||
->with('success', $successMessage);
|
||||
} else {
|
||||
// Save and continue to next step or stay on current step
|
||||
$nextStep = $request->input('action') === 'continue' ? min(5, $step + 1) : $step;
|
||||
$nextStep = $request->input('action') === 'continue' ? min($maxStep, $step + 1) : $step;
|
||||
|
||||
// Determine setup route based on user type
|
||||
$setupRoute = $userType === 'buyer' ? 'buyer.setup.create' : 'seller.setup.create';
|
||||
|
||||
if ($request->input('action') === 'continue') {
|
||||
// Continue to next step without toast notification
|
||||
return redirect()->route('business.setup.create', $nextStep);
|
||||
return redirect()->route($setupRoute, $nextStep);
|
||||
} else {
|
||||
// Save progress and show toast notification
|
||||
return redirect()->route('business.setup.create', $nextStep)
|
||||
return redirect()->route($setupRoute, $nextStep)
|
||||
->with('success', 'Progress saved successfully! ' . round($progress['percentage']) . '% complete.');
|
||||
}
|
||||
}
|
||||
@@ -186,7 +314,69 @@ class BusinessSetupController extends Controller
|
||||
/**
|
||||
* Get validation rules for specific step
|
||||
*/
|
||||
private function getStepValidation($step)
|
||||
private function getStepValidation($step, $userType = 'seller')
|
||||
{
|
||||
if ($userType === 'buyer') {
|
||||
return $this->getBuyerStepValidation($step);
|
||||
}
|
||||
|
||||
// Seller validation (4-step flow)
|
||||
switch ($step) {
|
||||
case 1: // Business Profile
|
||||
return [
|
||||
'name' => 'required|string|max:255',
|
||||
'dba_name' => 'nullable|string|max:255',
|
||||
'license_number' => 'required|string|max:255',
|
||||
'tin_ein' => 'required|string|max:255',
|
||||
'business_type' => 'required|string|in:' . implode(',', array_keys(Business::BUSINESS_TYPES)),
|
||||
'physical_address' => 'required|string|max:255',
|
||||
'physical_city' => 'required|string|max:100',
|
||||
'physical_state' => 'required|string|max:2',
|
||||
'physical_zipcode' => 'required|string|max:10',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
];
|
||||
|
||||
case 2: // Contact & Billing
|
||||
return [
|
||||
'business_phone' => 'required|string|max:20',
|
||||
'business_email' => 'required|email|max:255',
|
||||
'billing_address' => 'required|string|max:255',
|
||||
'billing_city' => 'required|string|max:100',
|
||||
'billing_state' => 'required|string|max:2',
|
||||
'billing_zipcode' => 'required|string|max:10',
|
||||
'ap_contact_first_name' => 'nullable|string|max:255',
|
||||
'ap_contact_last_name' => 'nullable|string|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:20',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_sms' => 'nullable|string|max:20',
|
||||
'ap_preferred_contact_method' => 'nullable|in:email,phone,sms',
|
||||
];
|
||||
|
||||
case 3: // Documents
|
||||
return [
|
||||
'cannabis_license' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'w9_form' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'insurance_certificate' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'business_license' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
];
|
||||
|
||||
case 4: // Review & Submit
|
||||
return [
|
||||
'buyer_name' => 'nullable|string|max:255',
|
||||
'buyer_email' => 'nullable|email|max:255',
|
||||
'preferred_contact_method' => 'nullable|in:email,phone,sms',
|
||||
];
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation rules for buyer onboarding steps
|
||||
*/
|
||||
private function getBuyerStepValidation($step)
|
||||
{
|
||||
switch ($step) {
|
||||
case 1: // Business Profile
|
||||
@@ -200,50 +390,43 @@ class BusinessSetupController extends Controller
|
||||
'physical_city' => 'required|string|max:100',
|
||||
'physical_state' => 'required|string|max:2',
|
||||
'physical_zipcode' => 'required|string|max:10',
|
||||
'location_phone' => 'nullable|string|max:20',
|
||||
'location_email' => 'nullable|email|max:255',
|
||||
'business_phone' => 'nullable|string|max:20',
|
||||
'business_email' => 'nullable|email|max:255',
|
||||
];
|
||||
|
||||
case 2: // Contact & Billing
|
||||
case 2: // Billing & Delivery (combined step for buyers)
|
||||
return [
|
||||
'phone' => 'required|string|max:20',
|
||||
'email' => 'required|email|max:255',
|
||||
'billing_address' => 'required|string|max:255',
|
||||
'billing_city' => 'required|string|max:100',
|
||||
'billing_state' => 'required|string|max:2',
|
||||
'billing_zipcode' => 'required|string|max:10',
|
||||
'payment_method' => 'required|in:cod,net_15,net_30,net_60,net_90',
|
||||
'ap_contact_name' => 'nullable|string|max:255',
|
||||
'ap_contact_phone' => 'nullable|string|max:20',
|
||||
'ap_contact_email' => 'nullable|email|max:255',
|
||||
'ap_contact_sms' => 'nullable|string|max:20',
|
||||
'ap_preferred_contact_method' => 'nullable|in:email,phone,sms',
|
||||
];
|
||||
|
||||
case 3: // Delivery Information
|
||||
return [
|
||||
'shipping_address' => 'nullable|string|max:255',
|
||||
'shipping_city' => 'nullable|string|max:100',
|
||||
'shipping_state' => 'nullable|string|max:2',
|
||||
'shipping_zipcode' => 'nullable|string|max:10',
|
||||
'ap_contact_first_name' => 'required|string|max:255',
|
||||
'ap_contact_last_name' => 'required|string|max:255',
|
||||
'ap_contact_phone' => 'required|string|max:20',
|
||||
'ap_contact_email' => 'required|email|max:255',
|
||||
'shipping_address' => 'required|string|max:255',
|
||||
'shipping_city' => 'required|string|max:100',
|
||||
'shipping_state' => 'required|string|max:2',
|
||||
'shipping_zipcode' => 'required|string|max:10',
|
||||
'delivery_contact_first_name' => 'required|string|max:255',
|
||||
'delivery_contact_last_name' => 'required|string|max:255',
|
||||
'delivery_contact_phone' => 'required|string|max:20',
|
||||
'delivery_preferences' => 'nullable|string|max:1000',
|
||||
'delivery_directions' => 'nullable|string|max:1000',
|
||||
'delivery_contact_name' => 'nullable|string|max:255',
|
||||
'delivery_contact_phone' => 'nullable|string|max:20',
|
||||
'delivery_contact_email' => 'nullable|email|max:255',
|
||||
'delivery_contact_sms' => 'nullable|string|max:20',
|
||||
'delivery_schedule' => 'nullable|string|max:1000',
|
||||
];
|
||||
|
||||
case 4: // Documents
|
||||
case 3: // Documents
|
||||
return [
|
||||
'cannabis_license' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'w9_form' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'resale_certificate' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'insurance_certificate' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
'business_license' => 'nullable|file|mimes:pdf,jpg,jpeg,png|max:10240',
|
||||
];
|
||||
|
||||
case 5: // Review & Submit
|
||||
case 4: // Review & Submit
|
||||
return [
|
||||
'buyer_name' => 'nullable|string|max:255',
|
||||
'buyer_email' => 'nullable|email|max:255',
|
||||
|
||||
@@ -170,16 +170,17 @@ class CheckoutController extends Controller
|
||||
$sellerNotificationService->newOrderReceived($order);
|
||||
|
||||
// Redirect to success page
|
||||
return redirect()->route('buyer.checkout.success', ['order' => $order->id])
|
||||
return redirect()->route('buyer.checkout.success', ['order' => $order->order_number])
|
||||
->with('success', 'Order placed successfully!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display order confirmation page.
|
||||
*/
|
||||
public function success(Request $request, int $orderId): View|RedirectResponse
|
||||
public function success(Request $request, Order $order): View|RedirectResponse
|
||||
{
|
||||
$order = Order::with(['items.product', 'company'])->findOrFail($orderId);
|
||||
// Load relationships
|
||||
$order->load(['items.product', 'company']);
|
||||
|
||||
// Ensure user owns this order
|
||||
if ($order->user_id !== $request->user()->id) {
|
||||
|
||||
@@ -6,7 +6,10 @@ use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\OrderItem;
|
||||
use App\Services\OrderModificationService;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
@@ -16,11 +19,12 @@ class InvoiceController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$invoices = Invoice::with(['order', 'company'])
|
||||
->whereHas('order', function ($query) use ($user) {
|
||||
$invoices = Invoice::with(['order', 'business'])
|
||||
->whereHas('order', function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('company_id', $user->company_id);
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -46,7 +50,7 @@ class InvoiceController extends Controller
|
||||
abort(403, 'Unauthorized to view this invoice.');
|
||||
}
|
||||
|
||||
$invoice->load(['order.items', 'company']);
|
||||
$invoice->load(['order.items', 'business']);
|
||||
|
||||
// Prepare invoice items data for Alpine.js
|
||||
$invoiceItems = $invoice->order->items->map(function($item) {
|
||||
@@ -188,17 +192,41 @@ class InvoiceController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download invoice PDF.
|
||||
*/
|
||||
public function downloadPdf(Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
{
|
||||
if (!$this->canAccessInvoice($invoice)) {
|
||||
abort(403, 'Unauthorized to download this invoice.');
|
||||
}
|
||||
|
||||
// Regenerate PDF if it doesn't exist
|
||||
if (!$invoice->pdf_path || !Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$invoiceService->regeneratePdf($invoice);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
$pdf = Storage::disk('local')->get($invoice->pdf_path);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . $invoice->invoice_number . '.pdf"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access the invoice.
|
||||
*/
|
||||
protected function canAccessInvoice(Invoice $invoice): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
$order = $invoice->order;
|
||||
|
||||
return $order && (
|
||||
$order->user_id === $user->id ||
|
||||
$order->company_id === $user->company_id
|
||||
in_array($order->business_id, $userBusinessIds)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,12 @@ class OrderController extends Controller
|
||||
public function index()
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
$orders = Order::with(['items', 'company', 'location'])
|
||||
->where('user_id', $user->id)
|
||||
->orWhereHas('company', function ($query) use ($user) {
|
||||
$query->where('id', $user->company_id);
|
||||
$orders = Order::with(['items', 'business', 'location'])
|
||||
->where(function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -44,7 +45,7 @@ class OrderController extends Controller
|
||||
abort(403, 'Unauthorized to view this order.');
|
||||
}
|
||||
|
||||
$order->load(['items.product', 'company', 'location', 'user', 'invoice', 'manifest']);
|
||||
$order->load(['items.product', 'business', 'location', 'user', 'invoice', 'manifest']);
|
||||
|
||||
return view('buyer.orders.show', compact('order'));
|
||||
}
|
||||
@@ -104,7 +105,7 @@ class OrderController extends Controller
|
||||
|
||||
$validated = $request->validate([
|
||||
'delivery_method' => 'required|in:delivery,pickup',
|
||||
'location_id' => 'nullable|exists:companies,id',
|
||||
'location_id' => 'nullable|exists:businesses,id',
|
||||
'pickup_driver_first_name' => 'nullable|string|max:255',
|
||||
'pickup_driver_last_name' => 'nullable|string|max:255',
|
||||
'pickup_driver_license' => 'nullable|string|max:255',
|
||||
@@ -162,8 +163,9 @@ class OrderController extends Controller
|
||||
protected function canAccessOrder(Order $order): bool
|
||||
{
|
||||
$user = auth()->user();
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
return $order->user_id === $user->id ||
|
||||
$order->company_id === $user->company_id;
|
||||
in_array($order->business_id, $userBusinessIds);
|
||||
}
|
||||
}
|
||||
|
||||
155
app/Http/Controllers/Buyer/RegistrationController.php
Normal file
155
app/Http/Controllers/Buyer/RegistrationController.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Buyer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Buyer\CompleteRegistrationRequest;
|
||||
use App\Services\EmailVerificationService;
|
||||
use App\Services\ContactToUserService;
|
||||
use App\Models\User;
|
||||
use App\Models\Contact;
|
||||
use App\Enums\ContactType;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class RegistrationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected EmailVerificationService $emailVerificationService,
|
||||
protected ContactToUserService $contactToUserService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* STEP 1: Show the email collection form
|
||||
*/
|
||||
public function showEmailForm(): View
|
||||
{
|
||||
return view('buyer.auth.register-email');
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 2: Handle email submission and verification
|
||||
* Simplified: Just check if user exists, then send verification
|
||||
*/
|
||||
public function checkEmail(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
]);
|
||||
|
||||
$email = $request->email;
|
||||
|
||||
// Check if email already exists as a user
|
||||
if (User::where('email', $email)->exists()) {
|
||||
return response()->json([
|
||||
'status' => 'exists',
|
||||
'message' => 'This email already has an account. Please try logging in or use the forgot password option.'
|
||||
]);
|
||||
}
|
||||
|
||||
// Create verification token and send email
|
||||
try {
|
||||
$token = $this->emailVerificationService->createVerification($email);
|
||||
$this->emailVerificationService->sendVerificationEmail($email, $token);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'verification_sent',
|
||||
'message' => 'Please check your email for a verification link to complete your registration.'
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Registration verification email error', [
|
||||
'error' => $e->getMessage(),
|
||||
'email' => $email,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'status' => 'error',
|
||||
'message' => 'There was an error sending verification email. Please try again.'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 3: Show the complete registration form after token verification
|
||||
* Simplified: Always show full registration form
|
||||
*/
|
||||
public function showCompleteForm(string $token): View|RedirectResponse
|
||||
{
|
||||
$verification = $this->emailVerificationService->validateToken($token);
|
||||
|
||||
if (!$verification) {
|
||||
return redirect()->route('buyer.register')
|
||||
->withErrors(['token' => 'Invalid or expired verification link. Please try again.']);
|
||||
}
|
||||
|
||||
// Get buyer-specific contact types
|
||||
$contactTypes = collect(ContactType::buyerTypes())->mapWithKeys(function ($type) {
|
||||
return [$type->value => $type->label()];
|
||||
})->toArray();
|
||||
|
||||
return view('buyer.auth.register-complete', [
|
||||
'token' => $token,
|
||||
'prefill' => ['email' => $verification->email],
|
||||
'isExistingContact' => false, // Always false for now (contacts created later during company setup)
|
||||
'contactTypes' => $contactTypes,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* STEP 4: Process the complete registration
|
||||
* Simplified: Always create new user account
|
||||
*/
|
||||
public function processRegistration(CompleteRegistrationRequest $request): RedirectResponse
|
||||
{
|
||||
// Validate token first
|
||||
$verification = $this->emailVerificationService->validateToken($request->token);
|
||||
|
||||
if (!$verification) {
|
||||
return back()->withErrors(['token' => 'Invalid or expired verification link. Please request a new one.']);
|
||||
}
|
||||
|
||||
try {
|
||||
// Create new user account
|
||||
$user = User::create([
|
||||
'first_name' => $request->first_name,
|
||||
'last_name' => $request->last_name,
|
||||
'email' => $verification->email,
|
||||
'password' => Hash::make($request->password),
|
||||
'phone' => $request->phone,
|
||||
'user_type' => 'buyer',
|
||||
'status' => 'active',
|
||||
'temp_business_name' => $request->business_name,
|
||||
'temp_market' => $request->market,
|
||||
'temp_contact_type' => $request->contact_type, // Store for later use in company setup
|
||||
]);
|
||||
|
||||
// Fire registered event
|
||||
event(new \Illuminate\Auth\Events\Registered($user));
|
||||
|
||||
// Consume the verification token
|
||||
$this->emailVerificationService->consumeToken($request->token);
|
||||
|
||||
// Log the user in
|
||||
Auth::login($user);
|
||||
|
||||
// Redirect to dashboard
|
||||
return redirect()->route('buyer.dashboard')->with([
|
||||
'success' => true,
|
||||
'success_header' => "Welcome to Cannabrands, {$user->first_name}!",
|
||||
'success_message' => "Your account has been created. Let's set up your company profile to start ordering."
|
||||
]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Registration completion failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'email' => $verification->email,
|
||||
]);
|
||||
|
||||
return back()->withErrors(['general' => 'There was an error completing your registration. Please try again.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,20 +16,36 @@ class BuyerDashboardController extends Controller
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
// Get user's primary business
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Check if user needs to complete onboarding
|
||||
$needsOnboarding = !$user->business_onboarding_completed
|
||||
|| ($business && in_array($business->status, ['not_started', 'in_progress', 'rejected']));
|
||||
|
||||
// Check if application is pending approval
|
||||
$isPending = $business && $business->status === 'submitted';
|
||||
|
||||
// Check if application was rejected
|
||||
$isRejected = $business && $business->status === 'rejected';
|
||||
|
||||
// Get user's businesses
|
||||
$userBusinessIds = $user->businesses->pluck('id')->toArray();
|
||||
|
||||
// Get orders
|
||||
$orders = Order::with(['items', 'company', 'invoice'])
|
||||
->where('user_id', $user->id)
|
||||
->orWhereHas('company', function ($query) use ($user) {
|
||||
$query->where('id', $user->company_id);
|
||||
$orders = Order::with(['items', 'business', 'invoice'])
|
||||
->where(function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
// Get invoices
|
||||
$invoices = Invoice::with(['order', 'company'])
|
||||
->whereHas('order', function ($query) use ($user) {
|
||||
$invoices = Invoice::with(['order', 'business'])
|
||||
->whereHas('order', function ($query) use ($user, $userBusinessIds) {
|
||||
$query->where('user_id', $user->id)
|
||||
->orWhere('company_id', $user->company_id);
|
||||
->orWhereIn('business_id', $userBusinessIds);
|
||||
})
|
||||
->latest()
|
||||
->get();
|
||||
@@ -38,7 +54,7 @@ class BuyerDashboardController extends Controller
|
||||
$stats = [
|
||||
// Order stats
|
||||
'active_orders' => $orders->whereIn('status', ['new', 'accepted', 'in_progress', 'ready_for_invoice'])->count(),
|
||||
'in_delivery' => $orders->whereIn('status', ['ready_for_delivery', 'manifest_created'])->count(),
|
||||
'in_delivery' => $orders->whereIn('status', ['ready_for_delivery', 'ready_for_manifest'])->count(),
|
||||
'total_orders' => $orders->count(),
|
||||
|
||||
// Invoice stats
|
||||
@@ -54,7 +70,7 @@ class BuyerDashboardController extends Controller
|
||||
$recentOrders = $orders->take(5);
|
||||
|
||||
// Upcoming deliveries
|
||||
$upcomingDeliveries = $orders->whereIn('status', ['ready_for_delivery', 'manifest_created'])
|
||||
$upcomingDeliveries = $orders->whereIn('status', ['ready_for_delivery', 'ready_for_manifest'])
|
||||
->sortBy('created_at')
|
||||
->take(5);
|
||||
|
||||
@@ -70,6 +86,10 @@ class BuyerDashboardController extends Controller
|
||||
|
||||
return view('buyer.dashboard', compact(
|
||||
'user',
|
||||
'needsOnboarding',
|
||||
'isPending',
|
||||
'isRejected',
|
||||
'business',
|
||||
'stats',
|
||||
'recentOrders',
|
||||
'upcomingDeliveries',
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
||||
use Log;
|
||||
use Illuminate\Support\Str;
|
||||
use App\Models\Business;
|
||||
use App\Models\Contact;
|
||||
use App\Notifications\CompleteBusinessProfileNotification;
|
||||
use App\Services\BusinessProgressService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -16,7 +17,7 @@ class BuyerSetupController extends Controller
|
||||
'name' => 'Business Profile',
|
||||
'description' => 'Basic business information and dispensary details',
|
||||
'view' => 'buyer.setup.steps.business-profile',
|
||||
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state', 'physical_zipcode', 'location_phone', 'location_email']
|
||||
'fields' => ['name', 'dba_name', 'license_number', 'tin_ein', 'business_type', 'physical_address', 'physical_city', 'physical_state', 'physical_zipcode', 'business_phone', 'business_email']
|
||||
],
|
||||
2 => [
|
||||
'name' => 'Contact & Billing',
|
||||
@@ -93,11 +94,12 @@ class BuyerSetupController extends Controller
|
||||
'name' => $request->input('name', 'Pending'),
|
||||
'business_type' => 'retailer', // Buyers are typically retailers/dispensaries
|
||||
'is_active' => false,
|
||||
'owner_user_id' => $user->id, // Set account owner
|
||||
]);
|
||||
|
||||
// Associate user with business
|
||||
$user->businesses()->attach($business->id, [
|
||||
'contact_type' => 'primary',
|
||||
'contact_type' => $user->temp_contact_type ?? 'staff',
|
||||
'is_primary' => true,
|
||||
]);
|
||||
}
|
||||
@@ -117,6 +119,36 @@ class BuyerSetupController extends Controller
|
||||
|
||||
// If this is the final step, mark as completed and notify admin
|
||||
if ($step == count(self::STEPS)) {
|
||||
// Create primary contact record
|
||||
$isPrimaryContact = $request->input('is_primary_contact', '1') === '1';
|
||||
|
||||
if ($isPrimaryContact) {
|
||||
// Owner is primary contact
|
||||
Contact::create([
|
||||
'business_id' => $business->id,
|
||||
'user_id' => $user->id,
|
||||
'first_name' => $user->first_name,
|
||||
'last_name' => $user->last_name,
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'contact_type' => $user->temp_contact_type ?? 'staff',
|
||||
'is_primary' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
} else {
|
||||
// Designated person is primary contact
|
||||
Contact::create([
|
||||
'business_id' => $business->id,
|
||||
'first_name' => $request->input('primary_contact_first_name'),
|
||||
'last_name' => $request->input('primary_contact_last_name'),
|
||||
'email' => $request->input('primary_contact_email'),
|
||||
'phone' => $request->input('primary_contact_phone'),
|
||||
'contact_type' => $request->input('primary_contact_role'),
|
||||
'is_primary' => true,
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
$business->update([
|
||||
'setup_status' => 'completed',
|
||||
'is_active' => true
|
||||
@@ -150,7 +182,7 @@ class BuyerSetupController extends Controller
|
||||
'physical_city' => 'required|string|max:100',
|
||||
'physical_state' => 'required|string|max:2',
|
||||
'physical_zipcode' => 'required|string|max:10',
|
||||
'location_phone' => 'required|string|max:20',
|
||||
'business_phone' => 'required|string|max:20',
|
||||
];
|
||||
case 2:
|
||||
return [
|
||||
@@ -172,8 +204,12 @@ class BuyerSetupController extends Controller
|
||||
];
|
||||
case 5:
|
||||
return [
|
||||
'primary_buyer_name' => 'required|string|max:255',
|
||||
'primary_buyer_email' => 'required|email|max:255',
|
||||
'is_primary_contact' => 'nullable|boolean',
|
||||
'primary_contact_first_name' => 'required_if:is_primary_contact,0|string|max:255',
|
||||
'primary_contact_last_name' => 'required_if:is_primary_contact,0|string|max:255',
|
||||
'primary_contact_email' => 'required_if:is_primary_contact,0|email|max:255',
|
||||
'primary_contact_phone' => 'required_if:is_primary_contact,0|string|max:20',
|
||||
'primary_contact_role' => 'required_if:is_primary_contact,0|string',
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
|
||||
@@ -15,8 +15,15 @@ class DashboardController extends Controller
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Get user's company
|
||||
$company = $user->company_id ? Company::find($user->company_id) : null;
|
||||
// Get user's primary business
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Check onboarding status
|
||||
$needsOnboarding = !$user->business_onboarding_completed
|
||||
|| ($business && in_array($business->status, ['not_started', 'in_progress', 'rejected']));
|
||||
|
||||
$isPending = $business && $business->status === 'submitted';
|
||||
$isRejected = $business && $business->status === 'rejected';
|
||||
|
||||
// TODO: Replace with real data from models
|
||||
$dashboardData = [
|
||||
@@ -48,7 +55,10 @@ class DashboardController extends Controller
|
||||
|
||||
return view('dashboard.nexus', [
|
||||
'user' => $user,
|
||||
'company' => $company,
|
||||
'business' => $business,
|
||||
'needsOnboarding' => $needsOnboarding,
|
||||
'isPending' => $isPending,
|
||||
'isRejected' => $isRejected,
|
||||
'dashboardData' => $dashboardData,
|
||||
'progressData' => $progressData,
|
||||
'progressSummary' => $progressSummary,
|
||||
|
||||
@@ -13,7 +13,15 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$drivers = Driver::forCompany(Auth::user()->company_id)
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// If no business, show empty state
|
||||
if (!$business) {
|
||||
return view('seller.fleet.drivers', ['drivers' => collect()]);
|
||||
}
|
||||
|
||||
$drivers = Driver::forCompany($business->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -25,6 +33,15 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (!$business) {
|
||||
return redirect()
|
||||
->route('seller.fleet.drivers.index')
|
||||
->with('error', 'Please complete your business profile first.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
@@ -36,7 +53,7 @@ class DriverController extends Controller
|
||||
|
||||
Driver::create([
|
||||
...$validated,
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'company_id' => $business->id,
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
@@ -51,8 +68,11 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Driver $driver)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure driver belongs to the current user's company
|
||||
if ($driver->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $driver->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -77,8 +97,11 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function destroy(Driver $driver)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure driver belongs to the current user's company
|
||||
if ($driver->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $driver->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -94,8 +117,11 @@ class DriverController extends Controller
|
||||
*/
|
||||
public function toggle(Driver $driver)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure driver belongs to the current user's company
|
||||
if ($driver->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $driver->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ class OrderController extends Controller
|
||||
'accepted',
|
||||
'in_progress',
|
||||
'ready_for_invoice',
|
||||
'invoiced',
|
||||
'manifest_created',
|
||||
'awaiting_invoice_approval',
|
||||
'ready_for_manifest',
|
||||
'ready_for_delivery',
|
||||
'delivered',
|
||||
'rejected',
|
||||
@@ -193,9 +193,9 @@ class OrderController extends Controller
|
||||
$invoiceService = app(\App\Services\InvoiceService::class);
|
||||
$invoice = $invoiceService->generateFromOrder($order);
|
||||
|
||||
// Update order to invoiced status
|
||||
// Update order to awaiting invoice approval status
|
||||
$order->update([
|
||||
'status' => 'invoiced',
|
||||
'status' => 'awaiting_invoice_approval',
|
||||
'invoiced_at' => now(),
|
||||
]);
|
||||
|
||||
@@ -208,18 +208,18 @@ class OrderController extends Controller
|
||||
/**
|
||||
* Show manifest creation form.
|
||||
*/
|
||||
public function createManifest(Order $order): View
|
||||
public function createManifest(Order $order): View|RedirectResponse
|
||||
{
|
||||
// Only allow manifest creation for orders in manifest_created status
|
||||
if ($order->status !== 'manifest_created') {
|
||||
abort(403, 'Cannot create manifest for this order at this stage.');
|
||||
}
|
||||
|
||||
// Check if manifest already exists
|
||||
// Check if manifest already exists first - redirect if it does
|
||||
if ($order->manifest) {
|
||||
return redirect()->route('seller.orders.manifest.show', $order);
|
||||
}
|
||||
|
||||
// Only allow manifest creation for orders in ready_for_manifest status
|
||||
if ($order->status !== 'ready_for_manifest') {
|
||||
abort(403, 'Cannot create manifest for this order at this stage.');
|
||||
}
|
||||
|
||||
$order->load(['company', 'location']);
|
||||
|
||||
// Load active drivers and vehicles for the seller's company
|
||||
@@ -241,8 +241,8 @@ class OrderController extends Controller
|
||||
*/
|
||||
public function storeManifest(Order $order, Request $request, ManifestService $manifestService)
|
||||
{
|
||||
// Only allow manifest creation for orders in manifest_created status
|
||||
if ($order->status !== 'manifest_created') {
|
||||
// Only allow manifest creation for orders in ready_for_manifest status
|
||||
if ($order->status !== 'ready_for_manifest') {
|
||||
return back()->with('error', 'Cannot create manifest for this order at this stage.');
|
||||
}
|
||||
|
||||
|
||||
63
app/Http/Controllers/Seller/InvoiceController.php
Normal file
63
app/Http/Controllers/Seller/InvoiceController.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Seller;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\InvoiceService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class InvoiceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the seller's invoices.
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
// For now, sellers can see all invoices (matches OrderController behavior)
|
||||
$invoices = Invoice::with(['order', 'company'])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
$stats = [
|
||||
'total' => $invoices->count(),
|
||||
'unpaid' => $invoices->where('payment_status', 'unpaid')->count(),
|
||||
'partially_paid' => $invoices->where('payment_status', 'partially_paid')->count(),
|
||||
'paid' => $invoices->where('payment_status', 'paid')->count(),
|
||||
'overdue' => $invoices->filter(fn($inv) => $inv->isOverdue())->count(),
|
||||
];
|
||||
|
||||
return view('seller.invoices.index', compact('invoices', 'stats'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified invoice.
|
||||
*/
|
||||
public function show(Invoice $invoice)
|
||||
{
|
||||
$invoice->load(['order.items', 'company']);
|
||||
|
||||
return view('seller.invoices.show', compact('invoice'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download invoice PDF.
|
||||
*/
|
||||
public function downloadPdf(Invoice $invoice, InvoiceService $invoiceService): Response
|
||||
{
|
||||
// Regenerate PDF if it doesn't exist
|
||||
if (!$invoice->pdf_path || !Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
$invoiceService->regeneratePdf($invoice);
|
||||
$invoice->refresh();
|
||||
}
|
||||
|
||||
$pdf = Storage::disk('local')->get($invoice->pdf_path);
|
||||
|
||||
return response($pdf, 200, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="' . $invoice->invoice_number . '.pdf"',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,15 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$vehicles = Vehicle::forCompany(Auth::user()->company_id)
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// If no business, show empty state
|
||||
if (!$business) {
|
||||
return view('seller.fleet.vehicles', ['vehicles' => collect()]);
|
||||
}
|
||||
|
||||
$vehicles = Vehicle::forCompany($business->id)
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
@@ -25,6 +33,15 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
if (!$business) {
|
||||
return redirect()
|
||||
->route('seller.fleet.vehicles.index')
|
||||
->with('error', 'Please complete your business profile first.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'make' => ['required', 'string', 'max:255'],
|
||||
@@ -37,7 +54,7 @@ class VehicleController extends Controller
|
||||
|
||||
Vehicle::create([
|
||||
...$validated,
|
||||
'company_id' => Auth::user()->company_id,
|
||||
'company_id' => $business->id,
|
||||
'created_by' => Auth::id(),
|
||||
'is_active' => true,
|
||||
]);
|
||||
@@ -52,8 +69,11 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function update(Request $request, Vehicle $vehicle)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure vehicle belongs to the current user's company
|
||||
if ($vehicle->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $vehicle->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -79,8 +99,11 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function destroy(Vehicle $vehicle)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure vehicle belongs to the current user's company
|
||||
if ($vehicle->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $vehicle->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
@@ -96,8 +119,11 @@ class VehicleController extends Controller
|
||||
*/
|
||||
public function toggle(Vehicle $vehicle)
|
||||
{
|
||||
$user = Auth::user();
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Ensure vehicle belongs to the current user's company
|
||||
if ($vehicle->company_id !== Auth::user()->company_id) {
|
||||
if (!$business || $vehicle->company_id !== $business->id) {
|
||||
abort(403, 'Unauthorized action.');
|
||||
}
|
||||
|
||||
|
||||
@@ -22,10 +22,41 @@ class EnsureUserApproved
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
// Check if user is approved
|
||||
if ($user && !$user->isApproved()) {
|
||||
return redirect()->route('pending.approval')
|
||||
->with('warning', 'Your account is still pending approval. Please wait for admin approval.');
|
||||
// Check if user's business is approved
|
||||
if ($user) {
|
||||
$business = $user->primaryBusiness();
|
||||
|
||||
// Determine setup and dashboard routes based on user type
|
||||
$setupRoute = $user->user_type === 'seller' ? 'seller.setup.create' : 'buyer.setup.create';
|
||||
$dashboardRoute = $user->user_type === 'seller' ? 'seller.dashboard' : 'buyer.dashboard';
|
||||
|
||||
// If no business, redirect to setup
|
||||
if (!$business) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Please complete your business profile setup.',
|
||||
'redirect' => route($setupRoute, 1)
|
||||
], 403);
|
||||
}
|
||||
return redirect()->route($setupRoute, 1)
|
||||
->with('warning', 'Please complete your business profile setup.');
|
||||
}
|
||||
|
||||
// If business not approved, block access
|
||||
if (!$business->isApproved()) {
|
||||
// For AJAX requests (like add to cart), return JSON error
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your business application is pending approval. You cannot make purchases until an administrator approves your account.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
// For regular requests, redirect to dashboard
|
||||
return redirect()->route($dashboardRoute)
|
||||
->with('warning', 'Your business application is pending approval. You cannot make purchases until an administrator approves your account.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
|
||||
56
app/Http/Requests/Buyer/CompleteRegistrationRequest.php
Normal file
56
app/Http/Requests/Buyer/CompleteRegistrationRequest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Buyer;
|
||||
|
||||
use App\Enums\ContactType;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class CompleteRegistrationRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'token' => ['required', 'string'],
|
||||
'first_name' => ['required', 'string', 'max:255'],
|
||||
'last_name' => ['required', 'string', 'max:255'],
|
||||
'phone' => ['required', 'string', 'max:15'],
|
||||
'contact_type' => ['required', new Enum(ContactType::class)],
|
||||
'business_name' => ['required', 'string', 'max:255'],
|
||||
'market' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'confirmed', Password::defaults()],
|
||||
'terms' => ['required', 'accepted'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get custom attribute names for validator errors.
|
||||
*/
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'first_name' => 'first name',
|
||||
'last_name' => 'last name',
|
||||
'phone' => 'phone number',
|
||||
'contact_type' => 'role',
|
||||
'business_name' => 'business name',
|
||||
'market' => 'market',
|
||||
'password' => 'password',
|
||||
'terms' => 'terms and conditions',
|
||||
];
|
||||
}
|
||||
}
|
||||
60
app/Mail/ExistingContactWelcome.php
Normal file
60
app/Mail/ExistingContactWelcome.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Contact;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class ExistingContactWelcome extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public Contact $contact
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Welcome to Cannabrands - Your Account is Ready!',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'emails.existing-contact-welcome',
|
||||
with: [
|
||||
'user' => $this->user,
|
||||
'contact' => $this->contact,
|
||||
'company' => $this->contact->company,
|
||||
'loginUrl' => route('login'),
|
||||
'dashboardUrl' => route('buyer.dashboard'),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
58
app/Mail/RegistrationVerificationMail.php
Normal file
58
app/Mail/RegistrationVerificationMail.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class RegistrationVerificationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Create a new message instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public string $verificationUrl,
|
||||
public string $email
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the message envelope.
|
||||
*/
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return new Envelope(
|
||||
subject: 'Complete Your Cannabrands Registration',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the message content definition.
|
||||
*/
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
view: 'emails.registration.verification',
|
||||
with: [
|
||||
'verification_url' => $this->verificationUrl,
|
||||
'email' => $this->email,
|
||||
'logo_url' => asset('assets/images/canna_white.png'),
|
||||
'account_name' => 'Cannabrands',
|
||||
'support_email' => 'hello@cannabrands.com',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attachments for the message.
|
||||
*/
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ class Brand extends Model
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
// Ownership (Brand belongs to licensed Company)
|
||||
'company_id',
|
||||
// Ownership (Brand belongs to licensed Business)
|
||||
'business_id',
|
||||
|
||||
// Brand Identity
|
||||
'name',
|
||||
@@ -88,12 +88,12 @@ class Brand extends Model
|
||||
// Relationships
|
||||
|
||||
/**
|
||||
* Brand belongs to a licensed Company (the license holder)
|
||||
* The Company provides all legal authority and compliance
|
||||
* Brand belongs to a licensed Business (the license holder)
|
||||
* The Business provides all legal authority and compliance
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,9 +123,9 @@ class Brand extends Model
|
||||
return $query->where('is_featured', true);
|
||||
}
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
{
|
||||
return $query->where('company_id', $companyId);
|
||||
return $query->where('business_id', $businessId);
|
||||
}
|
||||
|
||||
public function scopeByCategory($query, string $category)
|
||||
@@ -155,22 +155,22 @@ class Brand extends Model
|
||||
|
||||
/**
|
||||
* Check if this brand can legally operate
|
||||
* (depends on the parent Company having active licenses)
|
||||
* (depends on the parent Business having active licenses)
|
||||
*/
|
||||
public function canOperate(): bool
|
||||
{
|
||||
return $this->is_active &&
|
||||
$this->company &&
|
||||
$this->company->is_active &&
|
||||
$this->company->getActiveLicenses()->isNotEmpty();
|
||||
$this->business &&
|
||||
$this->business->is_active &&
|
||||
$this->business->getActiveLicenses()->isNotEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the legal entity behind this brand
|
||||
*/
|
||||
public function getLegalEntity(): Company
|
||||
public function getLegalEntity(): Business
|
||||
{
|
||||
return $this->company;
|
||||
return $this->business;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,9 +186,9 @@ class Brand extends Model
|
||||
*/
|
||||
public function isAvailableInState(string $state): bool
|
||||
{
|
||||
// If no restrictions, available everywhere the company operates
|
||||
// If no restrictions, available everywhere the business operates
|
||||
if (empty($this->geographic_restrictions)) {
|
||||
return $this->company->getActiveLicenses()
|
||||
return $this->business->getActiveLicenses()
|
||||
->filter(function ($license) use ($state) {
|
||||
return $license->location &&
|
||||
$license->location->state === $state;
|
||||
@@ -199,11 +199,11 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get compliance status from parent Company
|
||||
* Get compliance status from parent Business
|
||||
*/
|
||||
public function isCompliant(): bool
|
||||
{
|
||||
return $this->company && $this->company->isApproved();
|
||||
return $this->business && $this->business->isApproved();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -238,7 +238,7 @@ class Brand extends Model
|
||||
/**
|
||||
* Check if retailer can carry this brand
|
||||
*/
|
||||
public function canBeCarriedBy(Company $retailer): bool
|
||||
public function canBeCarriedBy(Business $retailer): bool
|
||||
{
|
||||
// Must be an active buyer
|
||||
if (!$retailer->isBuyer() || !$retailer->is_active) {
|
||||
@@ -246,7 +246,7 @@ class Brand extends Model
|
||||
}
|
||||
|
||||
// Check exclusive restrictions
|
||||
if ($this->exclusive_to_business && $this->company_id !== $retailer->id) {
|
||||
if ($this->exclusive_to_business && $this->business_id !== $retailer->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,26 +4,27 @@ namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
class Company extends Model
|
||||
class Business extends Model implements AuditableContract
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes, Auditable;
|
||||
|
||||
protected $table = 'companies';
|
||||
|
||||
// Company Types (buyer/seller/both)
|
||||
// User type (buyer/seller/both)
|
||||
public const TYPES = [
|
||||
'buyer' => 'Buyer (Dispensary/Retailer)',
|
||||
'seller' => 'Seller (Brand/Manufacturer)',
|
||||
'both' => 'Both (Vertically Integrated)',
|
||||
];
|
||||
|
||||
// Industry Types aligned with cannabis industry
|
||||
public const INDUSTRY_TYPES = [
|
||||
// Business types aligned with cannabis industry (stored in business_type column)
|
||||
public const BUSINESS_TYPES = [
|
||||
'brand' => 'Brand/Manufacturer',
|
||||
'retailer' => 'Retailer/Dispensary',
|
||||
'distributor' => 'Distributor',
|
||||
@@ -34,13 +35,6 @@ class Company extends Model
|
||||
];
|
||||
|
||||
// Setup Status constants
|
||||
public const SETUP_STATUSES = [
|
||||
'incomplete' => 'Incomplete',
|
||||
'in_progress' => 'In Progress',
|
||||
'pending_review' => 'Pending Review',
|
||||
'complete' => 'Complete',
|
||||
];
|
||||
|
||||
// Services that can be offered
|
||||
public const SERVICES = [
|
||||
'cultivation',
|
||||
@@ -54,6 +48,9 @@ class Company extends Model
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
// Account Ownership
|
||||
'owner_user_id',
|
||||
|
||||
// Core Company Identity (LeafLink Company equivalent)
|
||||
'name',
|
||||
'slug',
|
||||
@@ -61,12 +58,11 @@ class Company extends Model
|
||||
'description',
|
||||
'logo',
|
||||
'website',
|
||||
'email',
|
||||
'phone',
|
||||
|
||||
// Company Classification
|
||||
'type', // buyer, seller, or both
|
||||
'industry_type', // Primary industry type
|
||||
'business_type', // Business type from onboarding (same as industry_type)
|
||||
'services', // JSON: Array of services offered
|
||||
|
||||
// Corporate Structure
|
||||
@@ -75,6 +71,7 @@ class Company extends Model
|
||||
|
||||
// Tax & Legal Information (LICENSE HOLDER DETAILS)
|
||||
'tin_ein', // Tax Identification Number
|
||||
'license_number', // Cannabis retail license number
|
||||
'legal_name', // Official legal entity name
|
||||
'legal_structure', // LLC, Corporation, Partnership, etc.
|
||||
|
||||
@@ -95,6 +92,39 @@ class Company extends Model
|
||||
'corporate_zipcode',
|
||||
'corporate_unit',
|
||||
|
||||
// Physical Address (business location)
|
||||
'physical_address',
|
||||
'physical_city',
|
||||
'physical_state',
|
||||
'physical_zipcode',
|
||||
|
||||
// Location Contact
|
||||
'business_phone',
|
||||
'business_email',
|
||||
|
||||
// Billing Address
|
||||
'billing_address',
|
||||
'billing_city',
|
||||
'billing_state',
|
||||
'billing_zipcode',
|
||||
|
||||
// Shipping/Delivery Address
|
||||
'shipping_address',
|
||||
'shipping_city',
|
||||
'shipping_state',
|
||||
'shipping_zipcode',
|
||||
|
||||
// AP Contact
|
||||
'ap_contact_first_name',
|
||||
'ap_contact_last_name',
|
||||
'ap_contact_phone',
|
||||
'ap_contact_email',
|
||||
|
||||
// Delivery Contact
|
||||
'delivery_contact_first_name',
|
||||
'delivery_contact_last_name',
|
||||
'delivery_contact_phone',
|
||||
|
||||
// Financial & Payment Settings
|
||||
'payment_terms', // net_30, net_15, etc.
|
||||
'commission_rate',
|
||||
@@ -104,14 +134,14 @@ class Company extends Model
|
||||
// Onboarding & Application
|
||||
'referral_source',
|
||||
'referral_code',
|
||||
'application_status', // pending, approved, rejected, under_review
|
||||
'status', // not_started, in_progress, submitted, approved, rejected
|
||||
'rejected_at',
|
||||
'rejection_reason',
|
||||
'application_submitted_at',
|
||||
'onboarding_completed',
|
||||
'setup_status',
|
||||
|
||||
// Status & Settings (LeafLink archival pattern)
|
||||
'is_active', // Active vs Archived
|
||||
'is_approved',
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'archived_at',
|
||||
@@ -139,8 +169,8 @@ class Company extends Model
|
||||
'physical_city',
|
||||
'physical_state',
|
||||
'physical_zipcode',
|
||||
'location_phone',
|
||||
'location_email',
|
||||
'business_phone',
|
||||
'business_email',
|
||||
'license_number',
|
||||
'billing_address',
|
||||
'billing_city',
|
||||
@@ -150,12 +180,14 @@ class Company extends Model
|
||||
'shipping_city',
|
||||
'shipping_state',
|
||||
'shipping_zipcode',
|
||||
'ap_contact_name',
|
||||
'ap_contact_first_name',
|
||||
'ap_contact_last_name',
|
||||
'ap_contact_phone',
|
||||
'ap_contact_email',
|
||||
'ap_contact_sms',
|
||||
'ap_preferred_contact_method',
|
||||
'delivery_contact_name',
|
||||
'delivery_contact_first_name',
|
||||
'delivery_contact_last_name',
|
||||
'delivery_contact_phone',
|
||||
'delivery_contact_email',
|
||||
'delivery_contact_sms',
|
||||
@@ -167,9 +199,9 @@ class Company extends Model
|
||||
'setup_progress' => 'array',
|
||||
'setup_completed_at' => 'datetime',
|
||||
'is_active' => 'boolean',
|
||||
'is_approved' => 'boolean',
|
||||
'onboarding_completed' => 'boolean',
|
||||
'approved_at' => 'datetime',
|
||||
'rejected_at' => 'datetime',
|
||||
'application_submitted_at' => 'datetime',
|
||||
'archived_at' => 'datetime',
|
||||
'compliance_documents_updated_at' => 'datetime',
|
||||
@@ -180,16 +212,21 @@ class Company extends Model
|
||||
// LeafLink-aligned Relationships
|
||||
public function users(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(User::class, 'company_user')
|
||||
return $this->belongsToMany(User::class, 'business_user')
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function owner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'owner_user_id');
|
||||
}
|
||||
|
||||
public function locations(): HasMany
|
||||
{
|
||||
// TODO: Locations not yet implemented - table is empty placeholder
|
||||
// When implemented, should use 'company_id' foreign key for consistency
|
||||
return $this->hasMany(Location::class, 'company_id');
|
||||
// When implemented, should use 'business_id' foreign key for consistency
|
||||
return $this->hasMany(Location::class, 'business_id');
|
||||
}
|
||||
|
||||
public function licenses(): HasMany
|
||||
@@ -223,14 +260,14 @@ class Company extends Model
|
||||
}
|
||||
|
||||
// Corporate Structure
|
||||
public function parentCompany()
|
||||
public function parentBusiness()
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'parent_company_id');
|
||||
return $this->belongsTo(Business::class, 'parent_business_id');
|
||||
}
|
||||
|
||||
public function subsidiaries(): HasMany
|
||||
{
|
||||
return $this->hasMany(Company::class, 'parent_company_id');
|
||||
return $this->hasMany(Business::class, 'parent_business_id');
|
||||
}
|
||||
|
||||
public function approver()
|
||||
@@ -292,12 +329,17 @@ class Company extends Model
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->application_status === 'pending';
|
||||
return $this->status === 'submitted';
|
||||
}
|
||||
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->application_status === 'approved' && $this->is_approved;
|
||||
return $this->status === 'approved';
|
||||
}
|
||||
|
||||
public function isRejected(): bool
|
||||
{
|
||||
return $this->status === 'rejected';
|
||||
}
|
||||
|
||||
// Corporate Structure Helpers
|
||||
@@ -332,6 +374,11 @@ class Company extends Model
|
||||
return $this->contacts()->where('is_primary', true)->first();
|
||||
}
|
||||
|
||||
public function getAccountOwner()
|
||||
{
|
||||
return $this->owner;
|
||||
}
|
||||
|
||||
// COMPLIANCE & LICENSE HOLDER METHODS
|
||||
|
||||
/**
|
||||
@@ -465,7 +512,7 @@ class Company extends Model
|
||||
'contact_billing' => [
|
||||
'name' => 'Contact & Billing',
|
||||
'weight' => 25,
|
||||
'fields' => ['phone', 'email', 'billing_address', 'billing_city', 'billing_state', 'payment_method', 'ap_contact_name', 'ap_contact_email']
|
||||
'fields' => ['business_phone', 'business_email', 'billing_address', 'billing_city', 'billing_state', 'ap_contact_first_name', 'ap_contact_last_name', 'ap_contact_email']
|
||||
],
|
||||
'delivery_info' => [
|
||||
'name' => 'Delivery Information',
|
||||
@@ -509,48 +556,14 @@ class Company extends Model
|
||||
$totalProgress += ($stepPercentage / 100) * $stepConfig['weight'];
|
||||
}
|
||||
|
||||
// Determine setup status based on progress
|
||||
$setupStatus = $this->determineSetupStatus($totalProgress);
|
||||
|
||||
return [
|
||||
'percentage' => round($totalProgress, 1),
|
||||
'steps' => $stepResults,
|
||||
'is_complete' => $totalProgress >= 95, // 95% threshold for completion
|
||||
'completed_at' => $this->setup_completed_at,
|
||||
'status' => $setupStatus,
|
||||
'status_label' => self::SETUP_STATUSES[$setupStatus] ?? 'Unknown',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine setup status based on completion percentage
|
||||
*/
|
||||
private function determineSetupStatus(float $percentage): string
|
||||
{
|
||||
if ($percentage < 5) {
|
||||
return 'incomplete';
|
||||
} elseif ($percentage < 75) {
|
||||
return 'in_progress';
|
||||
} elseif ($percentage < 95) {
|
||||
return 'pending_review';
|
||||
} else {
|
||||
return 'complete';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update setup status based on current progress
|
||||
*/
|
||||
public function updateSetupStatus(): void
|
||||
{
|
||||
$progress = $this->getSetupProgress();
|
||||
$newStatus = $progress['status'];
|
||||
|
||||
if ($this->setup_status !== $newStatus) {
|
||||
$this->update(['setup_status' => $newStatus]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if company setup is complete enough to enable purchasing
|
||||
*/
|
||||
@@ -560,7 +573,7 @@ class Company extends Model
|
||||
|
||||
// Must have minimum required fields
|
||||
$requiredForPurchase = [
|
||||
'name', 'license_number', 'phone', 'email',
|
||||
'name', 'license_number', 'business_phone', 'business_email',
|
||||
'billing_address', 'payment_method',
|
||||
'cannabis_license_path', 'w9_form_path', 'business_license_path'
|
||||
];
|
||||
@@ -575,13 +588,15 @@ class Company extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark company setup as complete
|
||||
* Mark company setup as complete and submit for approval
|
||||
*/
|
||||
public function markSetupComplete(): void
|
||||
{
|
||||
$this->update([
|
||||
'setup_completed_at' => now(),
|
||||
'onboarding_completed' => true,
|
||||
'status' => 'submitted', // Submit for admin approval
|
||||
'application_submitted_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ class Contact extends Model
|
||||
'it' => 'IT/Technical Contact',
|
||||
'legal' => 'Legal Contact',
|
||||
'staff' => 'Staff Member',
|
||||
'emergency' => 'Emergency Contact',
|
||||
];
|
||||
|
||||
// Communication Preferences
|
||||
@@ -43,8 +42,8 @@ class Contact extends Model
|
||||
|
||||
protected $fillable = [
|
||||
// Ownership
|
||||
'business_id',
|
||||
'location_id', // Optional - can be business-wide or location-specific
|
||||
'company_id',
|
||||
'location_id', // Optional - can be company-wide or location-specific
|
||||
'user_id', // Optional - linked user account
|
||||
|
||||
// Personal Information
|
||||
@@ -185,9 +184,9 @@ class Contact extends Model
|
||||
return $query->where('is_emergency_contact', true);
|
||||
}
|
||||
|
||||
public function scopeForBusiness($query, int $businessId)
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('business_id', $businessId);
|
||||
return $query->where('company_id', $companyId);
|
||||
}
|
||||
|
||||
public function scopeForLocation($query, int $locationId)
|
||||
@@ -324,8 +323,8 @@ class Contact extends Model
|
||||
->where('id', '!=', $this->id)
|
||||
->update(['is_primary' => false]);
|
||||
} else {
|
||||
// Business-wide primary
|
||||
static::where('business_id', $this->business_id)
|
||||
// Company-wide primary
|
||||
static::where('company_id', $this->company_id)
|
||||
->whereNull('location_id')
|
||||
->where('id', '!=', $this->id)
|
||||
->update(['is_primary' => false]);
|
||||
|
||||
@@ -15,7 +15,7 @@ class Driver extends Model
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'business_id',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'license_number',
|
||||
@@ -34,9 +34,9 @@ class Driver extends Model
|
||||
* Relationships
|
||||
*/
|
||||
|
||||
public function company(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(Business::class, 'business_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
@@ -60,7 +60,7 @@ class Driver extends Model
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('company_id', $companyId);
|
||||
return $query->where('business_id', $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
68
app/Models/EmailVerification.php
Normal file
68
app/Models/EmailVerification.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EmailVerification extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
*
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $fillable = [
|
||||
'email',
|
||||
'token',
|
||||
'expires_at',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be cast.
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if the verification token has expired
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expires_at->isPast();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the verification token is still valid
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isValid(): bool
|
||||
{
|
||||
return !$this->isExpired();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the time remaining before expiration
|
||||
*
|
||||
* @return \Carbon\CarbonInterval|null
|
||||
*/
|
||||
public function timeRemaining(): ?\Carbon\CarbonInterval
|
||||
{
|
||||
if ($this->isExpired()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::now()->diffAsCarbonInterval($this->expires_at);
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,11 @@ class Invoice extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the company (buyer) for the invoice.
|
||||
* Get the business (buyer) for the invoice.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -274,7 +274,7 @@ class Invoice extends Model
|
||||
if ($saved) {
|
||||
// Proceed to manifest creation
|
||||
$this->order->update([
|
||||
'status' => 'manifest_created',
|
||||
'status' => 'ready_for_manifest',
|
||||
'manifest_created_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ class Manifest extends Model
|
||||
|
||||
protected $fillable = [
|
||||
'order_id',
|
||||
'seller_company_id',
|
||||
'buyer_company_id',
|
||||
'seller_business_id',
|
||||
'buyer_business_id',
|
||||
'created_by',
|
||||
'driver_id',
|
||||
'vehicle_id',
|
||||
@@ -105,14 +105,14 @@ class Manifest extends Model
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function sellerCompany(): BelongsTo
|
||||
public function sellerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'seller_company_id');
|
||||
return $this->belongsTo(Business::class, 'seller_business_id');
|
||||
}
|
||||
|
||||
public function buyerCompany(): BelongsTo
|
||||
public function buyerBusiness(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'buyer_company_id');
|
||||
return $this->belongsTo(Business::class, 'buyer_business_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
|
||||
@@ -27,7 +27,7 @@ class Order extends Model
|
||||
protected $fillable = [
|
||||
'order_number',
|
||||
'picking_ticket_number',
|
||||
'company_id',
|
||||
'business_id',
|
||||
'user_id',
|
||||
'location_id',
|
||||
'subtotal',
|
||||
@@ -83,11 +83,11 @@ class Order extends Model
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the company that placed the order.
|
||||
* Get the business that placed the order.
|
||||
*/
|
||||
public function company(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(Business::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -103,7 +103,7 @@ class Order extends Model
|
||||
*/
|
||||
public function location(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class, 'location_id');
|
||||
return $this->belongsTo(Location::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,19 +179,19 @@ class Order extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Get invoiced orders.
|
||||
* Scope: Get orders awaiting invoice approval.
|
||||
*/
|
||||
public function scopeInvoiced($query)
|
||||
public function scopeAwaitingInvoiceApproval($query)
|
||||
{
|
||||
return $query->where('status', 'invoiced');
|
||||
return $query->where('status', 'awaiting_invoice_approval');
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope: Get orders with manifest created.
|
||||
* Scope: Get orders ready for manifest.
|
||||
*/
|
||||
public function scopeManifestCreated($query)
|
||||
public function scopeReadyForManifest($query)
|
||||
{
|
||||
return $query->where('status', 'manifest_created');
|
||||
return $query->where('status', 'ready_for_manifest');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -309,8 +309,8 @@ class Order extends Model
|
||||
$this->load('invoice');
|
||||
}
|
||||
|
||||
// Automatically move to invoiced status
|
||||
$marked = $this->markInvoiced();
|
||||
// Automatically move to awaiting_invoice_approval status
|
||||
$marked = $this->markAwaitingInvoiceApproval();
|
||||
|
||||
// Send invoice notification
|
||||
if ($marked && $this->invoice) {
|
||||
@@ -322,21 +322,21 @@ class Order extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark order as invoiced (invoice sent to buyer for approval).
|
||||
* Mark order as awaiting invoice approval (invoice sent to buyer for approval).
|
||||
*/
|
||||
public function markInvoiced(): bool
|
||||
public function markAwaitingInvoiceApproval(): bool
|
||||
{
|
||||
$this->status = 'invoiced';
|
||||
$this->status = 'awaiting_invoice_approval';
|
||||
$this->invoiced_at = now();
|
||||
return $this->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark manifest as created (buyer approved invoice).
|
||||
* Mark order as ready for manifest (buyer approved invoice).
|
||||
*/
|
||||
public function createManifest(): bool
|
||||
public function markReadyForManifest(): bool
|
||||
{
|
||||
$this->status = 'manifest_created';
|
||||
$this->status = 'ready_for_manifest';
|
||||
$this->manifest_created_at = now();
|
||||
return $this->save();
|
||||
}
|
||||
@@ -587,7 +587,7 @@ class Order extends Model
|
||||
|
||||
$this->amendment_completed_at = now();
|
||||
$this->amendment_completed_by = $labMember->id;
|
||||
$this->status = 'manifest_created';
|
||||
$this->status = 'ready_for_manifest';
|
||||
$this->manifest_created_at = now();
|
||||
|
||||
$saved = $this->save();
|
||||
@@ -666,11 +666,11 @@ class Order extends Model
|
||||
|
||||
/**
|
||||
* Check if fulfillment method can be changed.
|
||||
* Method can only be changed before manifest is created.
|
||||
* Buyers can change fulfillment method until the order is in transit or delivered.
|
||||
*/
|
||||
public function canChangeFulfillmentMethod(): bool
|
||||
{
|
||||
return !$this->manifest()->exists();
|
||||
return !in_array($this->status, ['in_transit', 'delivered', 'cancelled']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,12 +42,11 @@ class User extends Authenticatable implements FilamentUser
|
||||
'phone',
|
||||
'position', // Job title/role
|
||||
'user_type', // admin, buyer, seller
|
||||
'approval_status', // pending, approved, rejected
|
||||
'approved_at',
|
||||
'approved_by',
|
||||
'status', // active, inactive, suspended
|
||||
'business_onboarding_completed',
|
||||
'temp_business_name',
|
||||
'temp_market',
|
||||
'temp_contact_type',
|
||||
|
||||
// Contact preferences
|
||||
'preferred_contact_method', // email, phone, sms
|
||||
@@ -76,7 +75,6 @@ class User extends Authenticatable implements FilamentUser
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'status' => 'string',
|
||||
'approved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -148,7 +146,7 @@ class User extends Authenticatable implements FilamentUser
|
||||
// Relationships (Business-centric approach)
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'company_user')
|
||||
return $this->belongsToMany(Business::class, 'business_user')
|
||||
->withPivot('contact_type', 'is_primary', 'permissions')
|
||||
->withTimestamps();
|
||||
}
|
||||
@@ -208,19 +206,47 @@ class User extends Authenticatable implements FilamentUser
|
||||
})->unique('id');
|
||||
}
|
||||
|
||||
// Approval status helper methods
|
||||
public function isPending(): bool
|
||||
// Status helper methods
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->approval_status === 'pending';
|
||||
return $this->status === 'active';
|
||||
}
|
||||
|
||||
public function isInactive(): bool
|
||||
{
|
||||
return $this->status === 'inactive';
|
||||
}
|
||||
|
||||
public function isSuspended(): bool
|
||||
{
|
||||
return $this->status === 'suspended';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user's primary business is approved
|
||||
*/
|
||||
public function isApproved(): bool
|
||||
{
|
||||
return $this->approval_status === 'approved';
|
||||
$business = $this->primaryBusiness();
|
||||
return $business && $business->status === 'approved';
|
||||
}
|
||||
|
||||
public function isRejected(): bool
|
||||
/**
|
||||
* Send the password reset notification with user-type-specific URL.
|
||||
*
|
||||
* @param string $token
|
||||
* @return void
|
||||
*/
|
||||
public function sendPasswordResetNotification($token)
|
||||
{
|
||||
return $this->approval_status === 'rejected';
|
||||
$routeName = match($this->user_type) {
|
||||
'seller' => 'seller.password.reset',
|
||||
'buyer' => 'buyer.password.reset',
|
||||
default => 'buyer.password.reset', // Default to buyer for safety
|
||||
};
|
||||
|
||||
$url = route($routeName, ['token' => $token, 'email' => $this->email]);
|
||||
|
||||
$this->notify(new \App\Notifications\ResetPasswordNotification($token, $url));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ class Vehicle extends Model
|
||||
use HasFactory, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'company_id',
|
||||
'business_id',
|
||||
'name',
|
||||
'make',
|
||||
'model',
|
||||
@@ -36,9 +36,9 @@ class Vehicle extends Model
|
||||
* Relationships
|
||||
*/
|
||||
|
||||
public function company(): BelongsTo
|
||||
public function business(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Company::class);
|
||||
return $this->belongsTo(Business::class, 'business_id');
|
||||
}
|
||||
|
||||
public function creator(): BelongsTo
|
||||
@@ -62,7 +62,7 @@ class Vehicle extends Model
|
||||
|
||||
public function scopeForCompany($query, int $companyId)
|
||||
{
|
||||
return $query->where('company_id', $companyId);
|
||||
return $query->where('business_id', $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -35,11 +35,13 @@ class AccountApprovedNotification extends Notification
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$dashboardRoute = $notifiable->user_type === 'seller' ? route('seller.dashboard') : route('buyer.dashboard');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Welcome to Cannabrands - Account Approved!')
|
||||
->line('Congratulations! Your Cannabrands account has been approved.')
|
||||
->line('You can now access the full platform and start building your business profile.')
|
||||
->action('Access Dashboard', route('business.dashboard'))
|
||||
->action('Access Dashboard', $dashboardRoute)
|
||||
->line('Thank you for joining the Cannabrands marketplace!');
|
||||
}
|
||||
|
||||
@@ -51,12 +53,13 @@ class AccountApprovedNotification extends Notification
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$style = NotificationStyleService::getStyle('approved');
|
||||
$dashboardRoute = $notifiable->user_type === 'seller' ? route('seller.dashboard') : route('buyer.dashboard');
|
||||
|
||||
return [
|
||||
'type' => 'approved',
|
||||
'title' => 'Account Approved! 🎉',
|
||||
'message' => 'Welcome to Cannabrands! Your account has been approved and you now have full access to the platform.',
|
||||
'action_url' => route('business.dashboard'),
|
||||
'action_url' => $dashboardRoute,
|
||||
'action_text' => 'Explore Dashboard',
|
||||
'icon' => $style['icon'],
|
||||
'color' => $style['color']
|
||||
|
||||
58
app/Notifications/ApplicationRejectedNotification.php
Normal file
58
app/Notifications/ApplicationRejectedNotification.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Business;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ApplicationRejectedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public Business $business,
|
||||
public string $rejectionReason
|
||||
) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail', 'database'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Your ' . $this->business->name . ' Application Has Been Reviewed')
|
||||
->markdown('emails.application-rejected', [
|
||||
'business' => $this->business,
|
||||
'rejectionReason' => $this->rejectionReason,
|
||||
'resubmitUrl' => route('buyer.setup.create', 1),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'type' => 'application_rejected',
|
||||
'business_id' => $this->business->id,
|
||||
'business_name' => $this->business->name,
|
||||
'rejection_reason' => $this->rejectionReason,
|
||||
'title' => 'Application Rejected',
|
||||
'message' => 'Your application for ' . $this->business->name . ' has been rejected. Please review the feedback and resubmit.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -37,12 +37,14 @@ class BusinessSetupCompleteNotification extends Notification
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$dashboardRoute = $notifiable->user_type === 'seller' ? route('seller.dashboard') : route('buyer.dashboard');
|
||||
|
||||
return (new MailMessage)
|
||||
->subject('Business Setup Complete - Ready to Start Selling!')
|
||||
->line('Congratulations! You have successfully completed your business setup.')
|
||||
->line('Your business profile is now complete and ready for review.')
|
||||
->line('We will review your information and get back to you within 1-2 business days.')
|
||||
->action('View Dashboard', route('business.dashboard'))
|
||||
->action('View Dashboard', $dashboardRoute)
|
||||
->line('Thank you for choosing Cannabrands!');
|
||||
}
|
||||
|
||||
@@ -54,6 +56,7 @@ class BusinessSetupCompleteNotification extends Notification
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
$style = NotificationStyleService::getStyle('completed');
|
||||
$dashboardRoute = $notifiable->user_type === 'seller' ? route('seller.dashboard') : route('buyer.dashboard');
|
||||
|
||||
return [
|
||||
'type' => 'completed',
|
||||
@@ -61,7 +64,7 @@ class BusinessSetupCompleteNotification extends Notification
|
||||
'message' => $this->businessName
|
||||
? "Congratulations! {$this->businessName} setup is complete and under review."
|
||||
: 'Your business setup is complete and under review. We\'ll get back to you within 1-2 business days.',
|
||||
'action_url' => route('business.dashboard'),
|
||||
'action_url' => $dashboardRoute,
|
||||
'action_text' => 'View Dashboard',
|
||||
'icon' => $style['icon'],
|
||||
'color' => $style['color']
|
||||
|
||||
@@ -24,6 +24,15 @@ class CompleteBusinessProfileNotification extends Notification
|
||||
$this->nextStep = $nextStep;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the setup route based on user type
|
||||
*/
|
||||
private function getSetupRoute($notifiable): string
|
||||
{
|
||||
$userType = $notifiable->user_type ?? 'seller';
|
||||
return $userType === 'buyer' ? route('buyer.setup.create', 1) : route('seller.setup.create', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
@@ -42,7 +51,7 @@ class CompleteBusinessProfileNotification extends Notification
|
||||
return (new MailMessage)
|
||||
->subject('Complete Your Business Profile - Cannabrands')
|
||||
->line('Welcome to Cannabrands! To get started, please complete your business profile.')
|
||||
->action('Complete Profile', route('business.setup'))
|
||||
->action('Complete Profile', $this->getSetupRoute($notifiable))
|
||||
->line('This will help us verify your business and get you selling quickly.');
|
||||
}
|
||||
|
||||
@@ -61,7 +70,7 @@ class CompleteBusinessProfileNotification extends Notification
|
||||
'message' => $this->progressPercentage > 0
|
||||
? "You're {$this->progressPercentage}% complete. Continue setting up your business profile to start selling."
|
||||
: 'Welcome to Cannabrands! Complete your business profile to get started.',
|
||||
'action_url' => route('business.setup'),
|
||||
'action_url' => $this->getSetupRoute($notifiable),
|
||||
'action_text' => 'Complete Profile',
|
||||
'progress' => $this->progressPercentage,
|
||||
'next_step' => $this->nextStep,
|
||||
|
||||
74
app/Notifications/ResetPasswordNotification.php
Normal file
74
app/Notifications/ResetPasswordNotification.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ResetPasswordNotification extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* The password reset token.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $token;
|
||||
|
||||
/**
|
||||
* The password reset URL.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $url;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*
|
||||
* @param string $token
|
||||
* @param string $url
|
||||
* @return void
|
||||
*/
|
||||
public function __construct($token, $url)
|
||||
{
|
||||
$this->token = $token;
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Reset Password Notification')
|
||||
->line('You are receiving this email because we received a password reset request for your account.')
|
||||
->action('Reset Password', $this->url)
|
||||
->line('This password reset link will expire in 60 minutes.')
|
||||
->line('If you did not request a password reset, no further action is required.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,27 +2,27 @@
|
||||
|
||||
namespace App\Observers;
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Business;
|
||||
use App\Notifications\BusinessSetupCompleteNotification;
|
||||
|
||||
class CompanyObserver
|
||||
{
|
||||
/**
|
||||
* Handle the Company "updated" event.
|
||||
* Triggers when admin approves a company from Filament panel
|
||||
* Handle the Business "updated" event.
|
||||
* Triggers when admin approves a business from Filament panel
|
||||
*/
|
||||
public function updated(Company $company)
|
||||
public function updated(Business $business)
|
||||
{
|
||||
// Check if company was just approved (is_approved changed from false to true)
|
||||
if ($company->isDirty('is_approved') && $company->is_approved) {
|
||||
// Check if business was just approved (is_approved changed from false to true)
|
||||
if ($business->isDirty('is_approved') && $business->is_approved) {
|
||||
// Update the approved_at timestamp if not already set
|
||||
if (!$company->approved_at) {
|
||||
$company->update(['approved_at' => now()]);
|
||||
if (!$business->approved_at) {
|
||||
$business->update(['approved_at' => now()]);
|
||||
}
|
||||
|
||||
// Send notification to all company users
|
||||
$company->users()->each(function ($user) use ($company) {
|
||||
$user->notify(new BusinessSetupCompleteNotification($company->name));
|
||||
// Send notification to all business users
|
||||
$business->users()->each(function ($user) use ($business) {
|
||||
$user->notify(new BusinessSetupCompleteNotification($business->name));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Company;
|
||||
use App\Models\Business;
|
||||
use App\Observers\UserObserver;
|
||||
use App\Observers\CompanyObserver;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
@@ -28,9 +29,17 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
// Configure password validation defaults
|
||||
Password::defaults(function () {
|
||||
return Password::min(8)
|
||||
->mixedCase()
|
||||
->numbers()
|
||||
->symbols();
|
||||
});
|
||||
|
||||
// Register model observers
|
||||
User::observe(UserObserver::class);
|
||||
Company::observe(CompanyObserver::class);
|
||||
Business::observe(CompanyObserver::class);
|
||||
|
||||
View::composer('*', function ($view) {
|
||||
$version = 'unknown';
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Providers\Filament;
|
||||
|
||||
use Filament\Pages\Dashboard;
|
||||
use App\Filament\Widgets\PendingActionsCard;
|
||||
use App\Filament\Widgets\BusinessOverview;
|
||||
use App\Filament\Widgets\OrderMetrics;
|
||||
use App\Filament\Widgets\UserStats;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Widgets\FilamentInfoWidget;
|
||||
use App\Filament\Widgets\SalesMetrics;
|
||||
use App\Filament\Widgets\RevenueChart;
|
||||
use App\Filament\Widgets\PendingUsers;
|
||||
use App\Filament\Widgets\RolesOverview;
|
||||
use App\Filament\Widgets\LatestNotifications;
|
||||
use App\Filament\Widgets\RecentOrders;
|
||||
use Filament\Widgets\AccountWidget;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\AuthenticateSession;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
@@ -44,12 +47,17 @@ class AdminPanelProvider extends PanelProvider
|
||||
])
|
||||
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
|
||||
->widgets([
|
||||
UserStats::class,
|
||||
// Row 1: Revenue Metrics
|
||||
SalesMetrics::class,
|
||||
|
||||
// Row 2: Revenue Trend Chart
|
||||
RevenueChart::class,
|
||||
|
||||
// Row 3: Recent Orders
|
||||
RecentOrders::class,
|
||||
|
||||
// Utility
|
||||
AccountWidget::class,
|
||||
LatestNotifications::class,
|
||||
PendingUsers::class,
|
||||
RolesOverview::class,
|
||||
FilamentInfoWidget::class,
|
||||
])
|
||||
->middleware([
|
||||
EncryptCookies::class,
|
||||
|
||||
64
app/Providers/TelescopeServiceProvider.php
Normal file
64
app/Providers/TelescopeServiceProvider.php
Normal file
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Laravel\Telescope\IncomingEntry;
|
||||
use Laravel\Telescope\Telescope;
|
||||
use Laravel\Telescope\TelescopeApplicationServiceProvider;
|
||||
|
||||
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// Telescope::night();
|
||||
|
||||
$this->hideSensitiveRequestDetails();
|
||||
|
||||
$isLocal = $this->app->environment('local');
|
||||
|
||||
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
|
||||
return $isLocal ||
|
||||
$entry->isReportableException() ||
|
||||
$entry->isFailedRequest() ||
|
||||
$entry->isFailedJob() ||
|
||||
$entry->isScheduledTask() ||
|
||||
$entry->hasMonitoredTag();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prevent sensitive request details from being logged by Telescope.
|
||||
*/
|
||||
protected function hideSensitiveRequestDetails(): void
|
||||
{
|
||||
if ($this->app->environment('local')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Telescope::hideRequestParameters(['_token']);
|
||||
|
||||
Telescope::hideRequestHeaders([
|
||||
'cookie',
|
||||
'x-csrf-token',
|
||||
'x-xsrf-token',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the Telescope gate.
|
||||
*
|
||||
* This gate determines who can access Telescope in non-local environments.
|
||||
*/
|
||||
protected function gate(): void
|
||||
{
|
||||
Gate::define('viewTelescope', function ($user) {
|
||||
return in_array($user->email, [
|
||||
//
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
222
app/Services/ContactService.php
Normal file
222
app/Services/ContactService.php
Normal file
@@ -0,0 +1,222 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Contact;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ContactService
|
||||
{
|
||||
/**
|
||||
* Get all contacts for a company
|
||||
*/
|
||||
public function getCompanyContacts(Company $company): Collection
|
||||
{
|
||||
return $company->contacts()
|
||||
->with('user.roles')
|
||||
->orderBy('is_primary', 'desc')
|
||||
->orderBy('last_name')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new contact
|
||||
*/
|
||||
public function createContact(Company $company, array $data): Contact
|
||||
{
|
||||
return DB::transaction(function () use ($company, $data) {
|
||||
$contact = $company->contacts()->create([
|
||||
'first_name' => $data['first_name'],
|
||||
'last_name' => $data['last_name'],
|
||||
'email' => $data['email'] ?? null,
|
||||
'phone' => $data['phone'] ?? null,
|
||||
'mobile' => $data['mobile'] ?? null,
|
||||
'position' => $data['position'] ?? null,
|
||||
'department' => $data['department'] ?? null,
|
||||
'contact_type' => $data['contact_type'] ?? null,
|
||||
'is_primary' => $data['is_primary'] ?? false,
|
||||
'is_active' => true,
|
||||
'can_approve_orders' => $data['can_approve_orders'] ?? false,
|
||||
'can_place_orders' => $data['can_place_orders'] ?? false,
|
||||
'can_receive_invoices' => $data['can_receive_invoices'] ?? false,
|
||||
'receive_notifications' => $data['receive_notifications'] ?? true,
|
||||
'preferred_contact_method' => $data['preferred_contact_method'] ?? 'email',
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
// If marked as primary, ensure no other contacts are primary
|
||||
if ($contact->is_primary) {
|
||||
$this->ensureSinglePrimary($company, $contact);
|
||||
}
|
||||
|
||||
return $contact;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing contact
|
||||
*/
|
||||
public function updateContact(Contact $contact, array $data): Contact
|
||||
{
|
||||
return DB::transaction(function () use ($contact, $data) {
|
||||
$contact->update([
|
||||
'first_name' => $data['first_name'] ?? $contact->first_name,
|
||||
'last_name' => $data['last_name'] ?? $contact->last_name,
|
||||
'email' => $data['email'] ?? $contact->email,
|
||||
'phone' => $data['phone'] ?? $contact->phone,
|
||||
'mobile' => $data['mobile'] ?? $contact->mobile,
|
||||
'position' => $data['position'] ?? $contact->position,
|
||||
'department' => $data['department'] ?? $contact->department,
|
||||
'contact_type' => $data['contact_type'] ?? $contact->contact_type,
|
||||
'is_primary' => $data['is_primary'] ?? $contact->is_primary,
|
||||
'can_approve_orders' => $data['can_approve_orders'] ?? $contact->can_approve_orders,
|
||||
'can_place_orders' => $data['can_place_orders'] ?? $contact->can_place_orders,
|
||||
'can_receive_invoices' => $data['can_receive_invoices'] ?? $contact->can_receive_invoices,
|
||||
'receive_notifications' => $data['receive_notifications'] ?? $contact->receive_notifications,
|
||||
'preferred_contact_method' => $data['preferred_contact_method'] ?? $contact->preferred_contact_method,
|
||||
'notes' => $data['notes'] ?? $contact->notes,
|
||||
]);
|
||||
|
||||
// If marked as primary, ensure no other contacts are primary
|
||||
if ($contact->is_primary) {
|
||||
$this->ensureSinglePrimary($contact->company, $contact);
|
||||
}
|
||||
|
||||
return $contact->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a contact (soft delete)
|
||||
*/
|
||||
public function deleteContact(Contact $contact): bool
|
||||
{
|
||||
// Prevent deleting if contact has a linked user
|
||||
if ($contact->user_id) {
|
||||
throw new \Exception('Cannot delete contact with linked user account. Unlink the user first.');
|
||||
}
|
||||
|
||||
return $contact->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive a contact
|
||||
*/
|
||||
public function archiveContact(Contact $contact, string $reason = null, User $archivedBy = null): void
|
||||
{
|
||||
$contact->archive($reason, $archivedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore an archived contact
|
||||
*/
|
||||
public function restoreContact(Contact $contact, User $restoredBy = null): void
|
||||
{
|
||||
$contact->restore($restoredBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a contact as primary
|
||||
*/
|
||||
public function setPrimary(Contact $contact): void
|
||||
{
|
||||
$contact->makePrimary();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure only one primary contact per company
|
||||
*/
|
||||
private function ensureSinglePrimary(Company $company, Contact $primaryContact): void
|
||||
{
|
||||
$company->contacts()
|
||||
->where('id', '!=', $primaryContact->id)
|
||||
->where('is_primary', true)
|
||||
->update(['is_primary' => false]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a contact to a user account
|
||||
*/
|
||||
public function linkToUser(Contact $contact, User $user): void
|
||||
{
|
||||
if ($contact->user_id) {
|
||||
throw new \Exception('Contact already linked to a user');
|
||||
}
|
||||
|
||||
$contact->update(['user_id' => $user->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink a contact from a user account
|
||||
*/
|
||||
public function unlinkFromUser(Contact $contact): void
|
||||
{
|
||||
$contact->update(['user_id' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search contacts by name or email
|
||||
*/
|
||||
public function searchContacts(Company $company, string $query): Collection
|
||||
{
|
||||
return $company->contacts()
|
||||
->where(function ($q) use ($query) {
|
||||
$q->where('first_name', 'like', "%{$query}%")
|
||||
->orWhere('last_name', 'like', "%{$query}%")
|
||||
->orWhere('email', 'like', "%{$query}%")
|
||||
->orWhere('phone', 'like', "%{$query}%");
|
||||
})
|
||||
->with('user.roles')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts by type
|
||||
*/
|
||||
public function getContactsByType(Company $company, string $type): Collection
|
||||
{
|
||||
return $company->contacts()
|
||||
->byType($type)
|
||||
->with('user.roles')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts that can approve orders
|
||||
*/
|
||||
public function getApprovers(Company $company): Collection
|
||||
{
|
||||
return $company->contacts()
|
||||
->canApproveOrders()
|
||||
->active()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contacts that can place orders
|
||||
*/
|
||||
public function getBuyers(Company $company): Collection
|
||||
{
|
||||
return $company->contacts()
|
||||
->canPlaceOrders()
|
||||
->active()
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email already exists for this company
|
||||
*/
|
||||
public function emailExists(Company $company, string $email, ?int $excludeContactId = null): bool
|
||||
{
|
||||
$query = $company->contacts()->where('email', $email);
|
||||
|
||||
if ($excludeContactId) {
|
||||
$query->where('id', '!=', $excludeContactId);
|
||||
}
|
||||
|
||||
return $query->exists();
|
||||
}
|
||||
}
|
||||
158
app/Services/ContactToUserService.php
Normal file
158
app/Services/ContactToUserService.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Contact;
|
||||
use App\Models\Company;
|
||||
use App\Enums\ContactType;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use App\Mail\ExistingContactWelcome;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class ContactToUserService
|
||||
{
|
||||
/**
|
||||
* Convert an existing contact to a user account
|
||||
*/
|
||||
public function convertContactToUser(Contact $contact, array $userData): User
|
||||
{
|
||||
return DB::transaction(function () use ($contact, $userData) {
|
||||
// Create the user account
|
||||
$user = User::create([
|
||||
'first_name' => $userData['first_name'],
|
||||
'last_name' => $userData['last_name'],
|
||||
'email' => $contact->email,
|
||||
'password' => Hash::make($userData['password']),
|
||||
'user_type' => $userData['user_type'] ?? 'buyer',
|
||||
'approval_status' => 'approved',
|
||||
'approved_at' => now(),
|
||||
]);
|
||||
|
||||
// Link the contact to the user
|
||||
$contact->update(['user_id' => $user->id]);
|
||||
|
||||
// Link user to company via pivot table
|
||||
if ($contact->company_id) {
|
||||
$this->linkUserToCompany($user, $contact);
|
||||
}
|
||||
|
||||
// Assign appropriate role
|
||||
$this->assignRole($contact, $user);
|
||||
|
||||
// Fire the registered event
|
||||
event(new Registered($user));
|
||||
|
||||
// Send welcome email
|
||||
$this->sendWelcomeEmail($user, $contact);
|
||||
|
||||
return $user;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Link user to company via pivot table with contact type
|
||||
*/
|
||||
private function linkUserToCompany(User $user, Contact $contact): void
|
||||
{
|
||||
$user->companies()->attach($contact->company_id, [
|
||||
'contact_type' => $contact->contact_type,
|
||||
'is_primary' => $contact->is_primary,
|
||||
'permissions' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign role based on contact type and company context
|
||||
*/
|
||||
private function assignRole(Contact $contact, User $user): void
|
||||
{
|
||||
// If primary contact or first user, always assign owner role
|
||||
if ($contact->is_primary || $this->isFirstUser($contact)) {
|
||||
$rolePrefix = $user->user_type === 'seller' ? 'company' : 'buyer';
|
||||
$user->assignRole("{$rolePrefix}-owner");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use ContactType enum to determine appropriate role
|
||||
if ($contact->contact_type) {
|
||||
try {
|
||||
$contactType = ContactType::from($contact->contact_type);
|
||||
$role = $contactType->suggestedRole($user->user_type);
|
||||
$user->assignRole($role);
|
||||
return;
|
||||
} catch (\ValueError $e) {
|
||||
// Invalid contact type, fall through to default
|
||||
\Log::warning('Invalid contact type for role assignment', [
|
||||
'contact_id' => $contact->id,
|
||||
'contact_type' => $contact->contact_type,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to regular user role
|
||||
$rolePrefix = $user->user_type === 'seller' ? 'company' : 'buyer';
|
||||
$user->assignRole("{$rolePrefix}-user");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is the first user for the company
|
||||
*/
|
||||
private function isFirstUser(Contact $contact): bool
|
||||
{
|
||||
if (!$contact->company_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Contact::where('company_id', $contact->company_id)
|
||||
->whereNotNull('user_id')
|
||||
->where('id', '!=', $contact->id)
|
||||
->count() === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send welcome email to converted contact
|
||||
*/
|
||||
private function sendWelcomeEmail(User $user, Contact $contact): void
|
||||
{
|
||||
try {
|
||||
Mail::to($user->email)->send(new ExistingContactWelcome($user, $contact));
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('Failed to send existing contact welcome email', [
|
||||
'error' => $e->getMessage(),
|
||||
'user_id' => $user->id,
|
||||
'contact_id' => $contact->id,
|
||||
]);
|
||||
// Don't throw - email failure shouldn't break account creation
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get contact with company information by email
|
||||
*/
|
||||
public function getContactWithCompany(string $email): ?array
|
||||
{
|
||||
$contact = Contact::where('email', $email)
|
||||
->with('company')
|
||||
->first();
|
||||
|
||||
if (!$contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'contact' => $contact,
|
||||
'company' => $contact->company,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email exists as a contact
|
||||
*/
|
||||
public function emailExistsAsContact(string $email): bool
|
||||
{
|
||||
return Contact::where('email', $email)->exists();
|
||||
}
|
||||
}
|
||||
141
app/Services/EmailVerificationService.php
Normal file
141
app/Services/EmailVerificationService.php
Normal file
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\EmailVerification;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Str;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EmailVerificationService
|
||||
{
|
||||
/**
|
||||
* Token expiry time in hours
|
||||
*/
|
||||
const TOKEN_EXPIRY_HOURS = 24;
|
||||
|
||||
/**
|
||||
* Generate and store email verification token
|
||||
*
|
||||
* @param string $email
|
||||
* @param array $metadata Optional metadata to store with verification
|
||||
* @return string The generated token
|
||||
*/
|
||||
public function createVerification(string $email, array $metadata = []): string
|
||||
{
|
||||
// Clean up any existing verifications for this email
|
||||
$this->cleanupExpiredTokens($email);
|
||||
|
||||
// Generate secure token
|
||||
$token = $this->generateSecureToken();
|
||||
|
||||
// Create verification record
|
||||
EmailVerification::create([
|
||||
'email' => $email,
|
||||
'token' => $token,
|
||||
'expires_at' => Carbon::now()->addHours(self::TOKEN_EXPIRY_HOURS),
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate verification token
|
||||
*
|
||||
* @param string $token
|
||||
* @return EmailVerification|null
|
||||
*/
|
||||
public function validateToken(string $token): ?EmailVerification
|
||||
{
|
||||
return EmailVerification::where('token', $token)
|
||||
->where('expires_at', '>', Carbon::now())
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume verification token (mark as used by deleting)
|
||||
*
|
||||
* @param string $token
|
||||
* @return bool
|
||||
*/
|
||||
public function consumeToken(string $token): bool
|
||||
{
|
||||
$verification = $this->validateToken($token);
|
||||
|
||||
if ($verification) {
|
||||
$verification->delete();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send verification email
|
||||
*
|
||||
* @param string $email
|
||||
* @param string $token
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function sendVerificationEmail(string $email, string $token): void
|
||||
{
|
||||
$verificationUrl = route('buyer.register.verify', ['token' => $token]);
|
||||
|
||||
Mail::to($email)->send(new \App\Mail\RegistrationVerificationMail($verificationUrl, $email));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired tokens for specific email or all expired tokens
|
||||
*
|
||||
* @param string|null $email
|
||||
* @return int Number of deleted records
|
||||
*/
|
||||
public function cleanupExpiredTokens(string $email = null): int
|
||||
{
|
||||
$query = EmailVerification::where('expires_at', '<=', Carbon::now());
|
||||
|
||||
if ($email) {
|
||||
$query->where('email', $email);
|
||||
}
|
||||
|
||||
return $query->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cryptographically secure token
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function generateSecureToken(): string
|
||||
{
|
||||
return Str::random(64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if email has pending verification
|
||||
*
|
||||
* @param string $email
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPendingVerification(string $email): bool
|
||||
{
|
||||
return EmailVerification::where('email', $email)
|
||||
->where('expires_at', '>', Carbon::now())
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get verification by email
|
||||
*
|
||||
* @param string $email
|
||||
* @return EmailVerification|null
|
||||
*/
|
||||
public function getVerificationByEmail(string $email): ?EmailVerification
|
||||
{
|
||||
return EmailVerification::where('email', $email)
|
||||
->where('expires_at', '>', Carbon::now())
|
||||
->first();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ namespace App\Services;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\LaravelPdf\Facades\Pdf;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class InvoiceService
|
||||
{
|
||||
@@ -100,12 +102,55 @@ class InvoiceService
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF for invoice (placeholder for future implementation).
|
||||
* Generate PDF document for invoice.
|
||||
*/
|
||||
public function generatePDF(Invoice $invoice): string
|
||||
public function generatePdf(Invoice $invoice): string
|
||||
{
|
||||
// TODO: Implement PDF generation using Laravel Snappy or similar
|
||||
// For now, return placeholder path
|
||||
return "invoices/{$invoice->invoice_number}.pdf";
|
||||
// Load all necessary relationships
|
||||
$invoice->load([
|
||||
'order.items.product.brand',
|
||||
'order.company',
|
||||
'order.user',
|
||||
'company',
|
||||
]);
|
||||
|
||||
// Generate PDF using Blade view with Spatie Laravel PDF
|
||||
$pdf = Pdf::view('pdfs.invoice', [
|
||||
'invoice' => $invoice,
|
||||
'order' => $invoice->order,
|
||||
'company' => $invoice->company,
|
||||
'items' => $invoice->order->items,
|
||||
])
|
||||
->format('letter'); // Use letter format for US standard
|
||||
|
||||
// Generate filename
|
||||
$filename = "invoices/{$invoice->invoice_number}.pdf";
|
||||
|
||||
// Ensure directory exists
|
||||
$directory = Storage::disk('local')->path('invoices');
|
||||
if (!file_exists($directory)) {
|
||||
mkdir($directory, 0755, true);
|
||||
}
|
||||
|
||||
// Save PDF to storage
|
||||
$pdf->save(Storage::disk('local')->path($filename));
|
||||
|
||||
// Update invoice with PDF path
|
||||
$invoice->update(['pdf_path' => $filename]);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate PDF for an existing invoice.
|
||||
*/
|
||||
public function regeneratePdf(Invoice $invoice): string
|
||||
{
|
||||
// Delete old PDF if exists
|
||||
if ($invoice->pdf_path && Storage::disk('local')->exists($invoice->pdf_path)) {
|
||||
Storage::disk('local')->delete($invoice->pdf_path);
|
||||
}
|
||||
|
||||
return $this->generatePdf($invoice);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,4 +97,33 @@ class SellerNotificationService
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify admins when a new business application is submitted.
|
||||
*/
|
||||
public function businessApplicationSubmitted(\App\Models\Business $business): void
|
||||
{
|
||||
$admins = $this->getSellerUsers();
|
||||
|
||||
foreach ($admins as $admin) {
|
||||
// Determine business type label
|
||||
$businessTypeLabel = $business->business_type
|
||||
? (\App\Models\Business::BUSINESS_TYPES[$business->business_type] ?? $business->business_type)
|
||||
: 'Unknown';
|
||||
|
||||
// Create in-app notification
|
||||
$this->notificationService->create(
|
||||
user: $admin,
|
||||
type: 'business_application_submitted',
|
||||
title: 'New Business Application',
|
||||
message: "{$business->name} ({$businessTypeLabel}) has submitted their application for review.",
|
||||
actionUrl: route('filament.admin.resources.businesses.edit', $business),
|
||||
notifiable: $business,
|
||||
data: [
|
||||
'business_type' => $business->business_type,
|
||||
'license_number' => $business->license_number,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\Filament\AdminPanelProvider::class,
|
||||
App\Providers\TelescopeServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
"filament/filament": "^4.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"owen-it/laravel-auditing": "^14.0",
|
||||
"rahulhaque/laravel-filepond": "*",
|
||||
"spatie/laravel-pdf": "^1.8",
|
||||
"spatie/laravel-permission": "^6.18"
|
||||
"spatie/laravel-permission": "^6.18",
|
||||
"tapp/filament-auditing": "^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
@@ -21,6 +23,7 @@
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.13",
|
||||
"laravel/sail": "^1.41",
|
||||
"laravel/telescope": "^5.14",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^4.0",
|
||||
|
||||
232
composer.lock
generated
232
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ccfd037f746ffc8c400e458e1f8e4d2e",
|
||||
"content-hash": "1a8b334881def7857756db3a2561ba0f",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
@@ -3975,6 +3975,90 @@
|
||||
],
|
||||
"time": "2025-07-07T06:15:55+00:00"
|
||||
},
|
||||
{
|
||||
"name": "owen-it/laravel-auditing",
|
||||
"version": "v14.0.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/owen-it/laravel-auditing.git",
|
||||
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/owen-it/laravel-auditing/zipball/f92602d1b3f53df29ddd577290e9d735ea707c53",
|
||||
"reference": "f92602d1b3f53df29ddd577290e9d735ea707c53",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"illuminate/console": "^11.0|^12.0",
|
||||
"illuminate/database": "^11.0|^12.0",
|
||||
"illuminate/filesystem": "^11.0|^12.0",
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"mockery/mockery": "^1.5.1",
|
||||
"orchestra/testbench": "^9.0|^10.0",
|
||||
"phpunit/phpunit": "^11.0"
|
||||
},
|
||||
"type": "package",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"OwenIt\\Auditing\\AuditingServiceProvider"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-master": "v14-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OwenIt\\Auditing\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Antério Vieira",
|
||||
"email": "anteriovieira@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Raphael França",
|
||||
"email": "raphaelfrancabsb@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Morten D. Hansen",
|
||||
"email": "morten@visia.dk"
|
||||
}
|
||||
],
|
||||
"description": "Audit changes of your Eloquent models in Laravel",
|
||||
"homepage": "https://laravel-auditing.com",
|
||||
"keywords": [
|
||||
"Accountability",
|
||||
"Audit",
|
||||
"auditing",
|
||||
"changes",
|
||||
"eloquent",
|
||||
"history",
|
||||
"laravel",
|
||||
"log",
|
||||
"logging",
|
||||
"lumen",
|
||||
"observer",
|
||||
"record",
|
||||
"revision",
|
||||
"tracking"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/owen-it/laravel-auditing/issues",
|
||||
"source": "https://github.com/owen-it/laravel-auditing"
|
||||
},
|
||||
"time": "2025-02-26T16:40:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "paragonie/constant_time_encoding",
|
||||
"version": "v3.0.0",
|
||||
@@ -8181,6 +8265,83 @@
|
||||
],
|
||||
"time": "2025-07-29T20:02:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tapp/filament-auditing",
|
||||
"version": "v4.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/TappNetwork/filament-auditing.git",
|
||||
"reference": "2e079adae23a842739807214af3a3eeb13f54db4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/TappNetwork/filament-auditing/zipball/2e079adae23a842739807214af3a3eeb13f54db4",
|
||||
"reference": "2e079adae23a842739807214af3a3eeb13f54db4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"filament/filament": "^4.0",
|
||||
"owen-it/laravel-auditing": "^14.0||^13.0",
|
||||
"php": "^8.2",
|
||||
"spatie/laravel-package-tools": "^1.16"
|
||||
},
|
||||
"require-dev": {
|
||||
"larastan/larastan": "^2.9||^3.0",
|
||||
"laravel/pint": "^1.14",
|
||||
"nunomaduro/collision": "^8.1.1||^7.10.0",
|
||||
"orchestra/testbench": "^10.0.0||^9.0.0",
|
||||
"pestphp/pest": "^3.0||^2.34",
|
||||
"pestphp/pest-plugin-arch": "^3.0||^2.7",
|
||||
"pestphp/pest-plugin-laravel": "^3.0||^2.3",
|
||||
"phpstan/extension-installer": "^1.3||^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.1||^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.3||^2.0",
|
||||
"spatie/laravel-ray": "^1.35"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Tapp\\FilamentAuditing\\FilamentAuditingServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Tapp\\FilamentAuditing\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Tapp Network",
|
||||
"email": "steve@tappnetwork.com",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Tapp Network",
|
||||
"email": "andreia.bohner@tappnetwork.com",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Filament Laravel Auditing plugin.",
|
||||
"homepage": "https://github.com/TappNetwork/filament-auditing",
|
||||
"keywords": [
|
||||
"Audit",
|
||||
"auditing",
|
||||
"filament",
|
||||
"laravel",
|
||||
"tapp network"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/TappNetwork/filament-auditing/issues",
|
||||
"source": "https://github.com/TappNetwork/filament-auditing"
|
||||
},
|
||||
"time": "2025-09-28T23:49:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "tijsverkoyen/css-to-inline-styles",
|
||||
"version": "v2.3.0",
|
||||
@@ -9290,6 +9451,75 @@
|
||||
},
|
||||
"time": "2025-07-04T16:17:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/telescope",
|
||||
"version": "v5.14.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/telescope.git",
|
||||
"reference": "67d9794d9577df56b3421bd6e7caae9fc17c913f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/telescope/zipball/67d9794d9577df56b3421bd6e7caae9fc17c913f",
|
||||
"reference": "67d9794d9577df56b3421bd6e7caae9fc17c913f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-json": "*",
|
||||
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0",
|
||||
"php": "^8.0",
|
||||
"symfony/console": "^5.3|^6.0|^7.0",
|
||||
"symfony/var-dumper": "^5.0|^6.0|^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-gd": "*",
|
||||
"guzzlehttp/guzzle": "^6.0|^7.0",
|
||||
"laravel/octane": "^1.4|^2.0|dev-develop",
|
||||
"orchestra/testbench": "^6.40|^7.37|^8.17|^9.0|^10.0",
|
||||
"phpstan/phpstan": "^1.10",
|
||||
"phpunit/phpunit": "^9.0|^10.5|^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"providers": [
|
||||
"Laravel\\Telescope\\TelescopeServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Laravel\\Telescope\\": "src/",
|
||||
"Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Taylor Otwell",
|
||||
"email": "taylor@laravel.com"
|
||||
},
|
||||
{
|
||||
"name": "Mohamed Said",
|
||||
"email": "mohamed@laravel.com"
|
||||
}
|
||||
],
|
||||
"description": "An elegant debug assistant for the Laravel framework.",
|
||||
"keywords": [
|
||||
"debugging",
|
||||
"laravel",
|
||||
"monitoring"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/laravel/telescope/issues",
|
||||
"source": "https://github.com/laravel/telescope/tree/v5.14.1"
|
||||
},
|
||||
"time": "2025-10-12T14:22:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mockery/mockery",
|
||||
"version": "1.6.12",
|
||||
|
||||
198
config/audit.php
Normal file
198
config/audit.php
Normal file
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
'enabled' => env('AUDITING_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Implementation
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define which Audit model implementation should be used.
|
||||
|
|
||||
*/
|
||||
|
||||
'implementation' => OwenIt\Auditing\Models\Audit::class,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Morph prefix & Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the morph prefix and authentication guards for the User resolver.
|
||||
|
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'user',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
],
|
||||
'resolver' => OwenIt\Auditing\Resolvers\UserResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Resolvers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Define the IP Address, User Agent and URL resolver implementations.
|
||||
|
|
||||
*/
|
||||
'resolvers' => [
|
||||
'ip_address' => OwenIt\Auditing\Resolvers\IpAddressResolver::class,
|
||||
'user_agent' => OwenIt\Auditing\Resolvers\UserAgentResolver::class,
|
||||
'url' => OwenIt\Auditing\Resolvers\UrlResolver::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Events
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The Eloquent events that trigger an Audit.
|
||||
|
|
||||
*/
|
||||
|
||||
'events' => [
|
||||
'created',
|
||||
'updated',
|
||||
'deleted',
|
||||
'restored',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Strict Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Enable the strict mode when auditing?
|
||||
|
|
||||
*/
|
||||
|
||||
'strict' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Global exclude
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Have something you always want to exclude by default? - add it here.
|
||||
| Note that this is overwritten (not merged) with local exclude
|
||||
|
|
||||
*/
|
||||
|
||||
'exclude' => [],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Empty Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should Audit records be stored when the recorded old_values & new_values
|
||||
| are both empty?
|
||||
|
|
||||
| Some events may be empty on purpose. Use allowed_empty_values to exclude
|
||||
| those from the empty values check. For example when auditing
|
||||
| model retrieved events which will never have new and old values.
|
||||
|
|
||||
|
|
||||
*/
|
||||
|
||||
'empty_values' => true,
|
||||
'allowed_empty_values' => [
|
||||
'retrieved',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed Array Values
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the array values be audited?
|
||||
|
|
||||
| By default, array values are not allowed. This is to prevent performance
|
||||
| issues when storing large amounts of data. You can override this by
|
||||
| setting allow_array_values to true.
|
||||
*/
|
||||
'allowed_array_values' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Timestamps
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Should the created_at, updated_at and deleted_at timestamps be audited?
|
||||
|
|
||||
*/
|
||||
|
||||
'timestamps' => false,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Threshold
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Specify a threshold for the amount of Audit records a model can have.
|
||||
| Zero means no limit.
|
||||
|
|
||||
*/
|
||||
|
||||
'threshold' => 0,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The default audit driver used to keep track of changes.
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => 'database',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Driver Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit drivers and respective configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'drivers' => [
|
||||
'database' => [
|
||||
'table' => 'audits',
|
||||
'connection' => null,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Queue Configurations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Available audit queue configurations.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
'enable' => false,
|
||||
'connection' => 'sync',
|
||||
'queue' => 'default',
|
||||
'delay' => 0,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Audit Console
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Whether console events should be audited (eg. php artisan db:seed).
|
||||
|
|
||||
*/
|
||||
|
||||
'console' => false,
|
||||
];
|
||||
46
config/filament-auditing.php
Normal file
46
config/filament-auditing.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Tapp\FilamentAuditing\Filament\Resources\Audits\AuditResource;
|
||||
|
||||
return [
|
||||
|
||||
'audits_sort' => [
|
||||
'column' => 'created_at',
|
||||
'direction' => 'desc',
|
||||
],
|
||||
|
||||
'is_lazy' => false, // Set to false to enable event-based refresh
|
||||
|
||||
'grouped_table_actions' => true, // Use three-dot dropdown menu for actions
|
||||
|
||||
/**
|
||||
* Extending Columns
|
||||
* --------------------------------------------------------------------------
|
||||
* In case you need to add a column to the AuditsRelationManager that does
|
||||
* not already exist in the table, you can add it here, and it will be
|
||||
* prepended to the table builder.
|
||||
*/
|
||||
'audits_extend' => [
|
||||
// 'url' => [
|
||||
// 'class' => \Filament\Tables\Columns\TextColumn::class,
|
||||
// 'methods' => [
|
||||
// 'sortable',
|
||||
// 'searchable' => true,
|
||||
// 'default' => 'N/A'
|
||||
// ]
|
||||
// ],
|
||||
],
|
||||
|
||||
'custom_audits_view' => false,
|
||||
|
||||
'custom_view_parameters' => [
|
||||
],
|
||||
|
||||
'mapping' => [
|
||||
],
|
||||
|
||||
'resources' => [
|
||||
'AuditResource' => AuditResource::class,
|
||||
],
|
||||
|
||||
];
|
||||
208
config/telescope.php
Normal file
208
config/telescope.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Telescope\Http\Middleware\Authorize;
|
||||
use Laravel\Telescope\Watchers;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Master Switch
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option may be used to disable all Telescope watchers regardless
|
||||
| of their individual configuration, which simply provides a single
|
||||
| and convenient way to enable or disable Telescope data storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'enabled' => env('TELESCOPE_ENABLED', true),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Domain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the subdomain where Telescope will be accessible from. If the
|
||||
| setting is null, Telescope will reside under the same domain as the
|
||||
| application. Otherwise, this value will be used as the subdomain.
|
||||
|
|
||||
*/
|
||||
|
||||
'domain' => env('TELESCOPE_DOMAIN'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This is the URI path where Telescope will be accessible from. Feel free
|
||||
| to change this path to anything you like. Note that the URI will not
|
||||
| affect the paths of its internal API that aren't exposed to users.
|
||||
|
|
||||
*/
|
||||
|
||||
'path' => env('TELESCOPE_PATH', 'telescope'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Storage Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This configuration options determines the storage driver that will
|
||||
| be used to store Telescope's data. In addition, you may set any
|
||||
| custom options as needed by the particular driver you choose.
|
||||
|
|
||||
*/
|
||||
|
||||
'driver' => env('TELESCOPE_DRIVER', 'database'),
|
||||
|
||||
'storage' => [
|
||||
'database' => [
|
||||
'connection' => env('DB_CONNECTION', 'mysql'),
|
||||
'chunk' => 1000,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Queue
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This configuration options determines the queue connection and queue
|
||||
| which will be used to process ProcessPendingUpdate jobs. This can
|
||||
| be changed if you would prefer to use a non-default connection.
|
||||
|
|
||||
*/
|
||||
|
||||
'queue' => [
|
||||
'connection' => env('TELESCOPE_QUEUE_CONNECTION'),
|
||||
'queue' => env('TELESCOPE_QUEUE'),
|
||||
'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Route Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These middleware will be assigned to every Telescope route, giving you
|
||||
| the chance to add your own middleware to this list or change any of
|
||||
| the existing middleware. Or, you can simply stick with this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => [
|
||||
'web',
|
||||
Authorize::class,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Allowed / Ignored Paths & Commands
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following array lists the URI paths and Artisan commands that will
|
||||
| not be watched by Telescope. In addition to this list, some Laravel
|
||||
| commands, like migrations and queue commands, are always ignored.
|
||||
|
|
||||
*/
|
||||
|
||||
'only_paths' => [
|
||||
// 'api/*'
|
||||
],
|
||||
|
||||
'ignore_paths' => [
|
||||
'livewire*',
|
||||
'nova-api*',
|
||||
'pulse*',
|
||||
'_boost*',
|
||||
],
|
||||
|
||||
'ignore_commands' => [
|
||||
//
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Telescope Watchers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The following array lists the "watchers" that will be registered with
|
||||
| Telescope. The watchers gather the application's profile data when
|
||||
| a request or task is executed. Feel free to customize this list.
|
||||
|
|
||||
*/
|
||||
|
||||
'watchers' => [
|
||||
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
|
||||
|
||||
Watchers\CacheWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
|
||||
'hidden' => [],
|
||||
'ignore' => [],
|
||||
],
|
||||
|
||||
Watchers\ClientRequestWatcher::class => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
|
||||
|
||||
Watchers\CommandWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
|
||||
'ignore' => [],
|
||||
],
|
||||
|
||||
Watchers\DumpWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
|
||||
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
|
||||
],
|
||||
|
||||
Watchers\EventWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
|
||||
'ignore' => [],
|
||||
],
|
||||
|
||||
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
|
||||
|
||||
Watchers\GateWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
|
||||
'ignore_abilities' => [],
|
||||
'ignore_packages' => true,
|
||||
'ignore_paths' => [],
|
||||
],
|
||||
|
||||
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
|
||||
|
||||
Watchers\LogWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
|
||||
'level' => 'error',
|
||||
],
|
||||
|
||||
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
|
||||
|
||||
Watchers\ModelWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
|
||||
'events' => ['eloquent.*'],
|
||||
'hydrations' => true,
|
||||
],
|
||||
|
||||
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
|
||||
|
||||
Watchers\QueryWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
|
||||
'ignore_packages' => true,
|
||||
'ignore_paths' => [],
|
||||
'slow' => 100,
|
||||
],
|
||||
|
||||
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
|
||||
|
||||
Watchers\RequestWatcher::class => [
|
||||
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
|
||||
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
|
||||
'ignore_http_methods' => [],
|
||||
'ignore_status_codes' => [],
|
||||
],
|
||||
|
||||
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
|
||||
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
|
||||
],
|
||||
];
|
||||
@@ -69,12 +69,12 @@ class BusinessFactory extends Factory
|
||||
|
||||
// Status
|
||||
'is_active' => true,
|
||||
'is_approved' => true,
|
||||
'approved_at' => now(),
|
||||
'application_status' => 'approved',
|
||||
'setup_status' => 'complete',
|
||||
'approved_by' => 1,
|
||||
'status' => 'approved',
|
||||
'onboarding_completed' => true,
|
||||
'setup_completed_at' => now(),
|
||||
'application_submitted_at' => now(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -84,9 +84,12 @@ class BusinessFactory extends Factory
|
||||
public function incomplete(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'setup_status' => 'in_progress',
|
||||
'status' => 'in_progress',
|
||||
'onboarding_completed' => false,
|
||||
'setup_completed_at' => null,
|
||||
'application_submitted_at' => null,
|
||||
'approved_at' => null,
|
||||
'approved_by' => null,
|
||||
'license_number' => null,
|
||||
'tin_ein' => null,
|
||||
]);
|
||||
@@ -122,9 +125,10 @@ class BusinessFactory extends Factory
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'is_approved' => false,
|
||||
'status' => 'submitted',
|
||||
'approved_at' => null,
|
||||
'application_status' => 'pending',
|
||||
'approved_by' => null,
|
||||
'application_submitted_at' => now()->subDays(rand(1, 7)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,57 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('locations', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Core Location Identity
|
||||
$table->foreignId('business_id')->constrained()->onDelete('cascade');
|
||||
$table->string('name');
|
||||
$table->string('slug')->nullable();
|
||||
$table->string('location_type')->nullable(); // dispensary, cultivation, manufacturing, etc.
|
||||
$table->text('description')->nullable();
|
||||
|
||||
// Primary Address
|
||||
$table->string('address')->nullable();
|
||||
$table->string('unit')->nullable();
|
||||
$table->string('city')->nullable();
|
||||
$table->string('state')->nullable();
|
||||
$table->string('zipcode')->nullable();
|
||||
$table->string('country')->default('USA');
|
||||
|
||||
// Contact Information
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('fax')->nullable();
|
||||
$table->string('website')->nullable();
|
||||
|
||||
// Operating Information
|
||||
$table->json('hours_of_operation')->nullable();
|
||||
$table->string('timezone')->nullable();
|
||||
$table->integer('capacity')->nullable();
|
||||
$table->integer('square_footage')->nullable();
|
||||
|
||||
// License & Compliance
|
||||
$table->string('license_number')->nullable();
|
||||
$table->string('license_type')->nullable();
|
||||
$table->string('license_status')->nullable();
|
||||
$table->date('license_expiration')->nullable();
|
||||
|
||||
// Delivery & Logistics
|
||||
$table->boolean('accepts_deliveries')->default(true);
|
||||
$table->text('delivery_instructions')->nullable();
|
||||
$table->json('delivery_hours')->nullable();
|
||||
$table->boolean('loading_dock_available')->default(false);
|
||||
$table->text('parking_instructions')->nullable();
|
||||
|
||||
// Status & Settings
|
||||
$table->boolean('is_primary')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_public')->default(true);
|
||||
$table->timestamp('archived_at')->nullable();
|
||||
$table->text('archived_reason')->nullable();
|
||||
$table->foreignId('transferred_to_business_id')->nullable()->constrained('businesses')->onDelete('set null');
|
||||
$table->json('settings')->nullable();
|
||||
$table->text('notes')->nullable();
|
||||
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,7 +13,71 @@ return new class extends Migration
|
||||
{
|
||||
Schema::create('contacts', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// Ownership
|
||||
$table->foreignId('business_id')->nullable()->constrained('businesses')->nullOnDelete();
|
||||
$table->foreignId('location_id')->nullable()->constrained('locations')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
// Personal Information
|
||||
$table->string('first_name');
|
||||
$table->string('last_name');
|
||||
$table->string('title')->nullable(); // Mr., Ms., Dr., etc.
|
||||
$table->string('position')->nullable(); // Job title
|
||||
$table->string('department')->nullable();
|
||||
|
||||
// Contact Information
|
||||
$table->string('email')->nullable()->index();
|
||||
$table->string('phone')->nullable();
|
||||
$table->string('mobile')->nullable();
|
||||
$table->string('fax')->nullable();
|
||||
$table->string('extension')->nullable();
|
||||
|
||||
// Business Details
|
||||
$table->string('contact_type')->nullable(); // primary, buyer, owner, etc.
|
||||
$table->json('responsibilities')->nullable(); // array of responsibilities
|
||||
$table->json('permissions')->nullable(); // what they can approve/access
|
||||
|
||||
// Communication Preferences
|
||||
$table->string('preferred_contact_method')->nullable();
|
||||
$table->json('communication_preferences')->nullable(); // when/how to contact
|
||||
$table->string('language_preference')->nullable();
|
||||
$table->string('timezone')->nullable();
|
||||
|
||||
// Schedule & Availability
|
||||
$table->json('work_hours')->nullable(); // schedule
|
||||
$table->text('availability_notes')->nullable();
|
||||
$table->string('emergency_contact')->nullable();
|
||||
|
||||
// Status & Settings
|
||||
$table->boolean('is_primary')->default(false);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_emergency_contact')->default(false);
|
||||
$table->boolean('can_approve_orders')->default(false);
|
||||
$table->boolean('can_receive_invoices')->default(false);
|
||||
$table->boolean('can_place_orders')->default(false);
|
||||
$table->boolean('receive_notifications')->default(true);
|
||||
$table->boolean('receive_marketing')->default(false);
|
||||
|
||||
// Internal Notes
|
||||
$table->text('notes')->nullable();
|
||||
$table->timestamp('last_contact_date')->nullable();
|
||||
$table->timestamp('next_followup_date')->nullable();
|
||||
$table->text('relationship_notes')->nullable();
|
||||
|
||||
// Account Management
|
||||
$table->timestamp('archived_at')->nullable();
|
||||
$table->string('archived_reason')->nullable();
|
||||
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['business_id', 'is_primary']);
|
||||
$table->index(['business_id', 'contact_type']);
|
||||
$table->index(['business_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Step 1: Update existing records
|
||||
DB::statement("UPDATE orders SET status = 'ready_for_manifest' WHERE status = 'manifest_created'");
|
||||
DB::statement("UPDATE orders SET status = 'awaiting_invoice_approval' WHERE status = 'invoiced'");
|
||||
|
||||
// Step 2: Drop the old check constraint
|
||||
DB::statement('ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_check');
|
||||
|
||||
// Step 3: Add new check constraint with updated values
|
||||
DB::statement("
|
||||
ALTER TABLE orders ADD CONSTRAINT orders_status_check
|
||||
CHECK (status IN (
|
||||
'new',
|
||||
'buyer_modified',
|
||||
'seller_modified',
|
||||
'accepted',
|
||||
'in_progress',
|
||||
'ready_for_invoice',
|
||||
'awaiting_invoice_approval',
|
||||
'ready_for_manifest',
|
||||
'ready_for_delivery',
|
||||
'delivered',
|
||||
'cancelled',
|
||||
'rejected'
|
||||
))
|
||||
");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Step 1: Revert records to old values
|
||||
DB::statement("UPDATE orders SET status = 'manifest_created' WHERE status = 'ready_for_manifest'");
|
||||
DB::statement("UPDATE orders SET status = 'invoiced' WHERE status = 'awaiting_invoice_approval'");
|
||||
|
||||
// Step 2: Drop the new check constraint
|
||||
DB::statement('ALTER TABLE orders DROP CONSTRAINT IF EXISTS orders_status_check');
|
||||
|
||||
// Step 3: Restore old check constraint
|
||||
DB::statement("
|
||||
ALTER TABLE orders ADD CONSTRAINT orders_status_check
|
||||
CHECK (status IN (
|
||||
'new',
|
||||
'buyer_modified',
|
||||
'seller_modified',
|
||||
'accepted',
|
||||
'in_progress',
|
||||
'ready_for_invoice',
|
||||
'invoiced',
|
||||
'manifest_created',
|
||||
'ready_for_delivery',
|
||||
'delivered',
|
||||
'cancelled',
|
||||
'rejected'
|
||||
))
|
||||
");
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('email_verifications', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('email')->index();
|
||||
$table->string('token', 64)->unique()->index();
|
||||
$table->timestamp('expires_at')->index();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('email_verifications');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
// Ownership
|
||||
$table->foreignId('company_id')->nullable()->after('id')->constrained('companies')->nullOnDelete();
|
||||
$table->foreignId('location_id')->nullable()->after('company_id')->constrained('locations')->nullOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->after('location_id')->constrained('users')->nullOnDelete();
|
||||
|
||||
// Personal Information
|
||||
$table->string('first_name')->after('user_id');
|
||||
$table->string('last_name')->after('first_name');
|
||||
$table->string('title')->nullable()->after('last_name');
|
||||
$table->string('position')->nullable()->after('title');
|
||||
$table->string('department')->nullable()->after('position');
|
||||
|
||||
// Contact Information
|
||||
$table->string('email')->nullable()->after('department')->index();
|
||||
$table->string('phone')->nullable()->after('email');
|
||||
$table->string('mobile')->nullable()->after('phone');
|
||||
$table->string('fax')->nullable()->after('mobile');
|
||||
$table->string('extension')->nullable()->after('fax');
|
||||
|
||||
// Business Details
|
||||
$table->string('contact_type')->nullable()->after('extension');
|
||||
$table->json('responsibilities')->nullable()->after('contact_type');
|
||||
$table->json('permissions')->nullable()->after('responsibilities');
|
||||
|
||||
// Communication Preferences
|
||||
$table->string('preferred_contact_method')->nullable()->after('permissions');
|
||||
$table->json('communication_preferences')->nullable()->after('preferred_contact_method');
|
||||
$table->string('language_preference')->nullable()->after('communication_preferences');
|
||||
$table->string('timezone')->nullable()->after('language_preference');
|
||||
|
||||
// Schedule & Availability
|
||||
$table->json('work_hours')->nullable()->after('timezone');
|
||||
$table->text('availability_notes')->nullable()->after('work_hours');
|
||||
$table->string('emergency_contact')->nullable()->after('availability_notes');
|
||||
|
||||
// Status & Settings
|
||||
$table->boolean('is_primary')->default(false)->after('emergency_contact');
|
||||
$table->boolean('is_active')->default(true)->after('is_primary');
|
||||
$table->boolean('is_emergency_contact')->default(false)->after('is_active');
|
||||
$table->boolean('can_approve_orders')->default(false)->after('is_emergency_contact');
|
||||
$table->boolean('can_receive_invoices')->default(false)->after('can_approve_orders');
|
||||
$table->boolean('can_place_orders')->default(false)->after('can_receive_invoices');
|
||||
$table->boolean('receive_notifications')->default(true)->after('can_place_orders');
|
||||
$table->boolean('receive_marketing')->default(false)->after('receive_notifications');
|
||||
|
||||
// Internal Notes
|
||||
$table->text('notes')->nullable()->after('receive_marketing');
|
||||
$table->timestamp('last_contact_date')->nullable()->after('notes');
|
||||
$table->timestamp('next_followup_date')->nullable()->after('last_contact_date');
|
||||
$table->text('relationship_notes')->nullable()->after('next_followup_date');
|
||||
|
||||
// Account Management
|
||||
$table->timestamp('archived_at')->nullable()->after('relationship_notes');
|
||||
$table->string('archived_reason')->nullable()->after('archived_at');
|
||||
$table->foreignId('created_by')->nullable()->after('archived_reason')->constrained('users')->nullOnDelete();
|
||||
$table->foreignId('updated_by')->nullable()->after('created_by')->constrained('users')->nullOnDelete();
|
||||
|
||||
// Soft deletes
|
||||
$table->softDeletes()->after('updated_at');
|
||||
|
||||
// Indexes for performance
|
||||
$table->index(['company_id', 'is_primary']);
|
||||
$table->index(['company_id', 'contact_type']);
|
||||
$table->index(['company_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('contacts', function (Blueprint $table) {
|
||||
// Drop indexes first
|
||||
$table->dropIndex(['company_id', 'is_active']);
|
||||
$table->dropIndex(['company_id', 'contact_type']);
|
||||
$table->dropIndex(['company_id', 'is_primary']);
|
||||
|
||||
// Drop columns
|
||||
$table->dropSoftDeletes();
|
||||
$table->dropColumn([
|
||||
'company_id', 'location_id', 'user_id',
|
||||
'first_name', 'last_name', 'title', 'position', 'department',
|
||||
'email', 'phone', 'mobile', 'fax', 'extension',
|
||||
'contact_type', 'responsibilities', 'permissions',
|
||||
'preferred_contact_method', 'communication_preferences', 'language_preference', 'timezone',
|
||||
'work_hours', 'availability_notes', 'emergency_contact',
|
||||
'is_primary', 'is_active', 'is_emergency_contact',
|
||||
'can_approve_orders', 'can_receive_invoices', 'can_place_orders',
|
||||
'receive_notifications', 'receive_marketing',
|
||||
'notes', 'last_contact_date', 'next_followup_date', 'relationship_notes',
|
||||
'archived_at', 'archived_reason', 'created_by', 'updated_by'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('temp_contact_type')->nullable()->after('temp_market');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('temp_contact_type');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->string('phone')->nullable()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('phone');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Rename companies table to businesses
|
||||
Schema::rename('companies', 'businesses');
|
||||
|
||||
// 2. Rename company_user pivot table to business_user
|
||||
if (Schema::hasTable('company_user')) {
|
||||
Schema::rename('company_user', 'business_user');
|
||||
}
|
||||
|
||||
// 3. Rename foreign key columns in business_user pivot table
|
||||
if (Schema::hasTable('business_user')) {
|
||||
Schema::table('business_user', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('business_user', 'company_id')) {
|
||||
$table->renameColumn('company_id', 'business_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Rename company_id to business_id in related tables
|
||||
$tablesToUpdate = [
|
||||
'orders',
|
||||
'drivers',
|
||||
'vehicles',
|
||||
'invoices',
|
||||
'contacts',
|
||||
'locations',
|
||||
];
|
||||
|
||||
foreach ($tablesToUpdate as $tableName) {
|
||||
if (Schema::hasTable($tableName) && Schema::hasColumn($tableName, 'company_id')) {
|
||||
Schema::table($tableName, function (Blueprint $table) {
|
||||
$table->renameColumn('company_id', 'business_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Rename special company columns in manifests table
|
||||
if (Schema::hasTable('manifests')) {
|
||||
Schema::table('manifests', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('manifests', 'seller_company_id')) {
|
||||
$table->renameColumn('seller_company_id', 'seller_business_id');
|
||||
}
|
||||
if (Schema::hasColumn('manifests', 'buyer_company_id')) {
|
||||
$table->renameColumn('buyer_company_id', 'buyer_business_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Rename parent_company_id to parent_business_id in businesses table
|
||||
if (Schema::hasTable('businesses') && Schema::hasColumn('businesses', 'parent_company_id')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->renameColumn('parent_company_id', 'parent_business_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Reverse in opposite order
|
||||
|
||||
// 1. Rename parent_business_id back to parent_company_id
|
||||
if (Schema::hasTable('businesses') && Schema::hasColumn('businesses', 'parent_business_id')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->renameColumn('parent_business_id', 'parent_company_id');
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Rename special business columns in manifests table
|
||||
if (Schema::hasTable('manifests')) {
|
||||
Schema::table('manifests', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('manifests', 'seller_business_id')) {
|
||||
$table->renameColumn('seller_business_id', 'seller_company_id');
|
||||
}
|
||||
if (Schema::hasColumn('manifests', 'buyer_business_id')) {
|
||||
$table->renameColumn('buyer_business_id', 'buyer_company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Rename business_id back to company_id in related tables
|
||||
$tablesToUpdate = [
|
||||
'orders',
|
||||
'drivers',
|
||||
'vehicles',
|
||||
'invoices',
|
||||
'contacts',
|
||||
'locations',
|
||||
];
|
||||
|
||||
foreach ($tablesToUpdate as $tableName) {
|
||||
if (Schema::hasTable($tableName) && Schema::hasColumn($tableName, 'business_id')) {
|
||||
Schema::table($tableName, function (Blueprint $table) {
|
||||
$table->renameColumn('business_id', 'company_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Rename business_id back to company_id in business_user pivot table
|
||||
if (Schema::hasTable('business_user')) {
|
||||
Schema::table('business_user', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('business_user', 'business_id')) {
|
||||
$table->renameColumn('business_id', 'company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Rename business_user pivot table back to company_user
|
||||
if (Schema::hasTable('business_user')) {
|
||||
Schema::rename('business_user', 'company_user');
|
||||
}
|
||||
|
||||
// 6. Rename businesses table back to companies
|
||||
Schema::rename('businesses', 'companies');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('slug')->unique()->nullable()->after('name');
|
||||
$table->index('slug');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropIndex(['slug']);
|
||||
$table->dropColumn('slug');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
// Check and add each column individually
|
||||
$columns = [
|
||||
'tin_ein' => fn() => $table->string('tin_ein')->nullable(),
|
||||
'dba_name' => fn() => $table->string('dba_name')->nullable(),
|
||||
'license_number' => fn() => $table->string('license_number')->nullable(),
|
||||
'business_type' => fn() => $table->string('business_type')->nullable(),
|
||||
'location_phone' => fn() => $table->string('location_phone')->nullable(),
|
||||
'location_email' => fn() => $table->string('location_email')->nullable(),
|
||||
'physical_address' => fn() => $table->string('physical_address')->nullable(),
|
||||
'physical_city' => fn() => $table->string('physical_city')->nullable(),
|
||||
'physical_state' => fn() => $table->string('physical_state', 2)->nullable(),
|
||||
'physical_zipcode' => fn() => $table->string('physical_zipcode', 10)->nullable(),
|
||||
'billing_address' => fn() => $table->string('billing_address')->nullable(),
|
||||
'billing_city' => fn() => $table->string('billing_city')->nullable(),
|
||||
'billing_state' => fn() => $table->string('billing_state', 2)->nullable(),
|
||||
'billing_zipcode' => fn() => $table->string('billing_zipcode', 10)->nullable(),
|
||||
'shipping_address' => fn() => $table->string('shipping_address')->nullable(),
|
||||
'shipping_city' => fn() => $table->string('shipping_city')->nullable(),
|
||||
'shipping_state' => fn() => $table->string('shipping_state', 2)->nullable(),
|
||||
'shipping_zipcode' => fn() => $table->string('shipping_zipcode', 10)->nullable(),
|
||||
'ap_contact_name' => fn() => $table->string('ap_contact_name')->nullable(),
|
||||
'ap_contact_phone' => fn() => $table->string('ap_contact_phone')->nullable(),
|
||||
'ap_contact_email' => fn() => $table->string('ap_contact_email')->nullable(),
|
||||
'delivery_contact_name' => fn() => $table->string('delivery_contact_name')->nullable(),
|
||||
'delivery_contact_phone' => fn() => $table->string('delivery_contact_phone')->nullable(),
|
||||
];
|
||||
|
||||
foreach ($columns as $column => $closure) {
|
||||
if (!Schema::hasColumn('businesses', $column)) {
|
||||
$closure();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'tin_ein', 'dba_name', 'license_number',
|
||||
'location_phone', 'location_email',
|
||||
'physical_address', 'physical_city', 'physical_state', 'physical_zipcode',
|
||||
'billing_address', 'billing_city', 'billing_state', 'billing_zipcode',
|
||||
'shipping_address', 'shipping_city', 'shipping_state', 'shipping_zipcode',
|
||||
'ap_contact_name', 'ap_contact_phone', 'ap_contact_email',
|
||||
'delivery_contact_name', 'delivery_contact_phone'
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add new columns if they don't exist
|
||||
if (!Schema::hasColumn('businesses', 'ap_contact_first_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('ap_contact_first_name')->nullable()->after('shipping_zipcode');
|
||||
});
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('businesses', 'ap_contact_last_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('ap_contact_last_name')->nullable()->after('ap_contact_first_name');
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate data from old column to new columns if old column exists
|
||||
if (Schema::hasColumn('businesses', 'ap_contact_name')) {
|
||||
DB::table('businesses')
|
||||
->whereNotNull('ap_contact_name')
|
||||
->get()
|
||||
->each(function ($business) {
|
||||
$nameParts = explode(' ', $business->ap_contact_name, 2);
|
||||
DB::table('businesses')
|
||||
->where('id', $business->id)
|
||||
->update([
|
||||
'ap_contact_first_name' => $nameParts[0] ?? null,
|
||||
'ap_contact_last_name' => $nameParts[1] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
// Drop the old column
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropColumn('ap_contact_name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Add back the old column if it doesn't exist
|
||||
if (!Schema::hasColumn('businesses', 'ap_contact_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('ap_contact_name')->nullable()->after('shipping_zipcode');
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate data back to old column
|
||||
DB::table('businesses')
|
||||
->where(function ($query) {
|
||||
$query->whereNotNull('ap_contact_first_name')
|
||||
->orWhereNotNull('ap_contact_last_name');
|
||||
})
|
||||
->get()
|
||||
->each(function ($business) {
|
||||
$fullName = trim(($business->ap_contact_first_name ?? '') . ' ' . ($business->ap_contact_last_name ?? ''));
|
||||
if ($fullName) {
|
||||
DB::table('businesses')
|
||||
->where('id', $business->id)
|
||||
->update(['ap_contact_name' => $fullName]);
|
||||
}
|
||||
});
|
||||
|
||||
// Drop the new columns
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('businesses', 'ap_contact_first_name')) {
|
||||
$table->dropColumn('ap_contact_first_name');
|
||||
}
|
||||
if (Schema::hasColumn('businesses', 'ap_contact_last_name')) {
|
||||
$table->dropColumn('ap_contact_last_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Add new columns if they don't exist
|
||||
if (!Schema::hasColumn('businesses', 'delivery_contact_first_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('delivery_contact_first_name')->nullable()->after('ap_contact_email');
|
||||
});
|
||||
}
|
||||
|
||||
if (!Schema::hasColumn('businesses', 'delivery_contact_last_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('delivery_contact_last_name')->nullable()->after('delivery_contact_first_name');
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate data from old column to new columns if old column exists
|
||||
if (Schema::hasColumn('businesses', 'delivery_contact_name')) {
|
||||
DB::table('businesses')
|
||||
->whereNotNull('delivery_contact_name')
|
||||
->get()
|
||||
->each(function ($business) {
|
||||
$nameParts = explode(' ', $business->delivery_contact_name, 2);
|
||||
DB::table('businesses')
|
||||
->where('id', $business->id)
|
||||
->update([
|
||||
'delivery_contact_first_name' => $nameParts[0] ?? null,
|
||||
'delivery_contact_last_name' => $nameParts[1] ?? null,
|
||||
]);
|
||||
});
|
||||
|
||||
// Drop the old column
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropColumn('delivery_contact_name');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
// Add back the old column if it doesn't exist
|
||||
if (!Schema::hasColumn('businesses', 'delivery_contact_name')) {
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->string('delivery_contact_name')->nullable()->after('ap_contact_email');
|
||||
});
|
||||
}
|
||||
|
||||
// Migrate data back to old column
|
||||
DB::table('businesses')
|
||||
->where(function ($query) {
|
||||
$query->whereNotNull('delivery_contact_first_name')
|
||||
->orWhereNotNull('delivery_contact_last_name');
|
||||
})
|
||||
->get()
|
||||
->each(function ($business) {
|
||||
$fullName = trim(($business->delivery_contact_first_name ?? '') . ' ' . ($business->delivery_contact_last_name ?? ''));
|
||||
if ($fullName) {
|
||||
DB::table('businesses')
|
||||
->where('id', $business->id)
|
||||
->update(['delivery_contact_name' => $fullName]);
|
||||
}
|
||||
});
|
||||
|
||||
// Drop the new columns
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('businesses', 'delivery_contact_first_name')) {
|
||||
$table->dropColumn('delivery_contact_first_name');
|
||||
}
|
||||
if (Schema::hasColumn('businesses', 'delivery_contact_last_name')) {
|
||||
$table->dropColumn('delivery_contact_last_name');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('businesses', 'resale_certificate_path')) {
|
||||
$table->string('resale_certificate_path')->nullable()->after('w9_form_path');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('businesses', 'resale_certificate_path')) {
|
||||
$table->dropColumn('resale_certificate_path');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (!Schema::hasColumn('businesses', 'application_status')) {
|
||||
$table->string('application_status')->nullable()->after('onboarding_completed')
|
||||
->comment('pending, approved, rejected, under_review');
|
||||
}
|
||||
if (!Schema::hasColumn('businesses', 'application_submitted_at')) {
|
||||
$table->timestamp('application_submitted_at')->nullable()->after('application_status');
|
||||
}
|
||||
|
||||
// Set pending status for businesses that have completed onboarding but have no status set
|
||||
// This migrates existing completed businesses to pending approval
|
||||
});
|
||||
|
||||
// Update existing businesses that completed onboarding to pending status
|
||||
DB::table('businesses')
|
||||
->where('onboarding_completed', true)
|
||||
->whereNull('application_status')
|
||||
->update([
|
||||
'application_status' => 'pending',
|
||||
'application_submitted_at' => DB::raw('COALESCE(setup_completed_at, NOW())'),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('businesses', 'application_status')) {
|
||||
$table->dropColumn('application_status');
|
||||
}
|
||||
if (Schema::hasColumn('businesses', 'application_submitted_at')) {
|
||||
$table->dropColumn('application_submitted_at');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->foreignId('owner_user_id')->nullable()->after('id')->constrained('users')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('businesses', function (Blueprint $table) {
|
||||
$table->dropForeign(['owner_user_id']);
|
||||
$table->dropColumn('owner_user_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Add new status column
|
||||
$table->string('status')->default('active')->after('user_type');
|
||||
});
|
||||
|
||||
// Migrate existing data: set all users to 'active' (since approval_status was auto-approved anyway)
|
||||
DB::table('users')->update(['status' => 'active']);
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Drop old approval fields
|
||||
$table->dropColumn(['approval_status', 'approved_at', 'approved_by']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Restore old approval fields
|
||||
$table->string('approval_status')->default('pending')->after('user_type');
|
||||
$table->timestamp('approved_at')->nullable();
|
||||
$table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete();
|
||||
});
|
||||
|
||||
// Migrate data back: set all active users to approved
|
||||
DB::table('users')->where('status', 'active')->update(['approval_status' => 'approved']);
|
||||
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
// Drop status column
|
||||
$table->dropColumn('status');
|
||||
});
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user