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:
Jon Leopard
2025-10-15 11:17:15 -07:00
parent 9558e8a5c2
commit 7e5ea2bc10
183 changed files with 11075 additions and 2901 deletions

View 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;
}
}

View File

@@ -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(),
]);
}

View File

@@ -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
View 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,
];
}
}

View File

@@ -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')

View File

@@ -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'),

View File

@@ -19,7 +19,7 @@ class BrandsTable
{
return $table
->columns([
TextColumn::make('company.name')
TextColumn::make('business.name')
->searchable(),
TextColumn::make('name')
->searchable(),

View 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'),
];
}
}

View File

@@ -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;
}

View 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();
}
}

View File

@@ -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
{

View File

@@ -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;

View File

@@ -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'),
];
}
}

View File

@@ -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;
}

View File

@@ -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
];
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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')

View File

@@ -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(),

View File

@@ -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(),
]);
}
}

View File

@@ -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');
}

View 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'),
];
}
}

View File

@@ -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()

View 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'),
];
}
}

View 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'),
];
}
}

View File

@@ -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)),
];
}
}

View 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)),
];
}
}

View 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(); }',
],
],
],
];
}
}

View File

@@ -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();

View 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'),
];
}
}

View File

@@ -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()),
];
}
}

View File

@@ -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,
]);

View File

@@ -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());
}

View File

@@ -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

View File

@@ -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'));

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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)
);
}
}

View File

@@ -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);
}
}

View 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.']);
}
}
}

View File

@@ -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',

View File

@@ -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 [];

View File

@@ -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,

View File

@@ -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.');
}

View File

@@ -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.');
}

View 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"',
]);
}
}

View File

@@ -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.');
}

View File

@@ -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);

View 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',
];
}
}

View 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 [];
}
}

View 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 [];
}
}

View File

@@ -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;
}

View File

@@ -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(),
]);
}

View File

@@ -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]);

View File

@@ -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);
}
/**

View 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);
}
}

View File

@@ -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(),
]);

View File

@@ -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

View File

@@ -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']);
}
/**

View File

@@ -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));
}
}

View File

@@ -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);
}
/**

View File

@@ -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']

View 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.',
];
}
}

View File

@@ -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']

View File

@@ -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,

View 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 [
//
];
}
}

View File

@@ -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));
});
}
}

View File

@@ -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';

View File

@@ -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,

View 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, [
//
]);
});
}
}

View 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();
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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);
}
}

View File

@@ -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,
]
);
}
}
}

View File

@@ -3,4 +3,5 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\Filament\AdminPanelProvider::class,
App\Providers\TelescopeServiceProvider::class,
];

View File

@@ -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
View File

@@ -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
View 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,
];

View 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
View 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),
],
];

View File

@@ -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)),
]);
}
}

View File

@@ -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();
});
}

View File

@@ -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']);
});
}

View File

@@ -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'
))
");
}
};

View File

@@ -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');
}
};

View File

@@ -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'
]);
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('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');
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
});
}
};

View File

@@ -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'
]);
});
}
};

View File

@@ -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');
}
});
}
};

View File

@@ -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');
}
});
}
};

View File

@@ -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');
}
});
}
};

View File

@@ -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');
}
});
}
};

View File

@@ -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');
});
}
};

View File

@@ -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