From dddbbb8f9bff3627a4f80860aaaf06744eb7f525 Mon Sep 17 00:00:00 2001 From: Nisse Lommerde Date: Fri, 14 Feb 2025 22:23:51 -0500 Subject: [PATCH] #102 Create 'Ready for invoicing'-status, and associated ways to create invoices from bulk orders --- app/Enums/IconEnum.php | 1 + app/Enums/OrderStatus.php | 33 ++++--- .../InvoiceResource/Pages/ListInvoices.php | 4 + .../Admin/Resources/OrderResource.php | 98 +++++++++++++++++-- .../OrderResource/Pages/ListOrders.php | 81 +++++++-------- .../Admin/Widgets/ActiveOrdersTable.php | 2 + .../Admin/Widgets/RushOrdersTable.php | 2 + app/Observers/OrderObserver.php | 10 +- app/Providers/Filament/AdminPanelProvider.php | 7 +- database/factories/TaxRateFactory.php | 3 +- tests/Unit/InvoiceTest.php | 16 +-- tests/Unit/OrderTest.php | 1 + tests/Unit/QuoteTest.php | 26 ++--- 13 files changed, 190 insertions(+), 94 deletions(-) diff --git a/app/Enums/IconEnum.php b/app/Enums/IconEnum.php index 4674982..7923179 100644 --- a/app/Enums/IconEnum.php +++ b/app/Enums/IconEnum.php @@ -51,6 +51,7 @@ enum IconEnum: string case APPROVED = 'lucide-check-check'; case PRODUCTION = 'lucide-refresh-cw'; case SHIPPED = 'lucide-send'; + case INVOICING = 'lucide-calendar'; case INVOICED = 'lucide-credit-card'; // Shipping Types (THEY_SHIP => SHIPPING_ENTRY) diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php index b598a37..decc509 100644 --- a/app/Enums/OrderStatus.php +++ b/app/Enums/OrderStatus.php @@ -8,11 +8,12 @@ enum OrderStatus: string implements HasColor, HasIcon, HasLabel { - case DRAFT = 'Draft'; - case APPROVED = 'Approved'; - case PRODUCTION = 'Production'; - case SHIPPED = 'Shipped'; - case INVOICED = 'Invoiced'; + case DRAFT = 'Draft'; + case APPROVED = 'Approved'; + case PRODUCTION = 'Production'; + case SHIPPED = 'Shipped'; + case READY_FOR_INVOICE = 'Ready for Invoice'; + case INVOICED = 'Invoiced'; public function getLabel(): ?string { @@ -22,22 +23,24 @@ public function getLabel(): ?string public function getColor(): string|array|null { return match ($this) { - self::DRAFT => 'gray', - self::APPROVED => 'success', - self::PRODUCTION => 'primary', - self::SHIPPED => 'warning', - self::INVOICED => 'invoiced', + self::DRAFT => 'gray', + self::APPROVED => 'success', + self::PRODUCTION => 'primary', + self::SHIPPED => 'warning', + self::READY_FOR_INVOICE => 'invoicing', + self::INVOICED => 'invoiced', }; } public function getIcon(): ?string { return match ($this) { - self::DRAFT => IconEnum::DRAFT->value, - self::APPROVED => IconEnum::APPROVED->value, - self::PRODUCTION => IconEnum::PRODUCTION->value, - self::SHIPPED => IconEnum::SHIPPED->value, - self::INVOICED => IconEnum::INVOICED->value, + self::DRAFT => IconEnum::DRAFT->value, + self::APPROVED => IconEnum::APPROVED->value, + self::PRODUCTION => IconEnum::PRODUCTION->value, + self::SHIPPED => IconEnum::SHIPPED->value, + self::READY_FOR_INVOICE => IconEnum::INVOICING->value, + self::INVOICED => IconEnum::INVOICED->value, }; } } diff --git a/app/Filament/Admin/Resources/InvoiceResource/Pages/ListInvoices.php b/app/Filament/Admin/Resources/InvoiceResource/Pages/ListInvoices.php index f918abe..172ccc8 100644 --- a/app/Filament/Admin/Resources/InvoiceResource/Pages/ListInvoices.php +++ b/app/Filament/Admin/Resources/InvoiceResource/Pages/ListInvoices.php @@ -23,6 +23,10 @@ public function getTabs(): array ->query(fn ($query) => $query->where('status', InvoiceStatus::UNPAID)) ->icon(InvoiceStatus::UNPAID->getIcon()), + 'partially_paid' => Tab::make('Partially Paid') + ->query(fn ($query) => $query->where('status', InvoiceStatus::PARTIALLY_PAID)) + ->icon(InvoiceStatus::PARTIALLY_PAID->getIcon()), + 'paid' => Tab::make('Paid') ->query(fn ($query) => $query->where('status', InvoiceStatus::PAID)) ->icon(InvoiceStatus::PAID->getIcon()), diff --git a/app/Filament/Admin/Resources/OrderResource.php b/app/Filament/Admin/Resources/OrderResource.php index 3d0b939..17264a6 100644 --- a/app/Filament/Admin/Resources/OrderResource.php +++ b/app/Filament/Admin/Resources/OrderResource.php @@ -3,10 +3,12 @@ namespace App\Filament\Admin\Resources; use App\Enums\IconEnum; +use App\Enums\InvoiceStatus; use App\Enums\OrderAttributes; use App\Enums\OrderStatus; use App\Enums\OrderType; use App\Models\Customer; +use App\Models\Invoice; use App\Models\Order; use App\Models\OrderProduct; use App\Models\ProductService; @@ -28,6 +30,8 @@ use Filament\Resources\Resource; use Filament\Support\Enums\MaxWidth; use Filament\Tables; +use Filament\Tables\Actions\BulkAction; +use Filament\Tables\Actions\BulkActionGroup; use Filament\Tables\Columns\IconColumn\IconColumnSize; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Table; @@ -202,13 +206,13 @@ public static function form(Form $form): Form ->createOptionUsing(function (array $data): int { return ServiceType::create($data)->getKey(); }), - TextInput::make('placement') - ->datalist(ProductService::all()->unique('placement')->pluck('placement')->toArray()) - ->columnSpan(3), TextInput::make('serviceFileName') ->datalist(ServiceFile::all()->unique('name')->pluck('name')->toArray()) ->columnSpan(3) ->label('Logo Name'), + TextInput::make('placement') + ->datalist(ProductService::all()->unique('placement')->pluck('placement')->toArray()) + ->columnSpan(3), TextInput::make('serviceFileSetupNumber') ->label('Setup') ->columnSpan(1) @@ -343,7 +347,6 @@ public static function table(Table $table): Table Tables\Actions\EditAction::make(), ]) ->bulkActions([ - Tables\Actions\BulkAction::make('updateStatus') ->form([ Select::make('status') @@ -366,9 +369,92 @@ public static function table(Table $table): Table ->color('info') ->deselectRecordsAfterCompletion(), - Tables\Actions\BulkActionGroup::make([ + BulkActionGroup::make([ + BulkAction::make('Create individual invoices') + ->icon(IconEnum::INVOICE->value) + ->action(function (Collection $records): void { + [$invoiced, $toInvoice] = $records->partition(fn ($record) => $record->invoice); + + $toInvoice->each(function ($record) { + $invoice = Invoice::create([ + 'customer_id' => $record->customer->id, + 'date' => today(), + 'status' => InvoiceStatus::UNPAID->value, + ]); + + $invoice->orders()->save($record); + $invoice->calculateTotals(); + $record->update(['status' => OrderStatus::INVOICED->value]); + }); + + if ($invoiced->isNotEmpty()) { + Notification::make() + ->title("{$invoiced->count()} orders are already invoiced") + ->warning() + ->send(); + } + + if ($toInvoice->isNotEmpty()) { + Notification::make() + ->title("Successfully created {$toInvoice->count()} invoice(s)") + ->success() + ->send(); + } + }), + + BulkAction::make('Add all to new invoice') + ->icon(IconEnum::REPEAT->value) + ->action(function (Collection $records): void { + if ($records->pluck('customer_id')->unique()->count() !== 1) { + Notification::make() + ->title('Invalid order combination') + ->body('Make sure all orders are from the same customer') + ->danger() + ->send(); + + return; + } + + [$invoiced, $validOrders] = $records->partition(fn ($record) => $record->invoice); + + if ($validOrders->isNotEmpty()) { + $invoice = Invoice::create([ + 'customer_id' => $records->first()->customer_id, + 'date' => today(), + 'status' => InvoiceStatus::UNPAID->value, + ]); + + $invoice->orders()->saveMany($validOrders); + $invoice->calculateTotals(); // FIXME: Investigate why this is needed. + + Order::whereIn('id', $validOrders->pluck('id'))->update([ + 'status' => OrderStatus::INVOICED->value, + ]); + } + + if ($invoiced->isNotEmpty()) { + Notification::make() + ->title('Some orders are already invoiced') + ->body("{$invoiced->count()} orders are already invoiced and will not be added") + ->warning() + ->send(); + } + + if ($validOrders->isNotEmpty()) { + Notification::make() + ->title('Invoice created') + ->body("{$validOrders->count()} orders have been added to this invoice") + ->success() + ->send(); + } + }), + ]) + ->label('Invoicing') + ->hidden(fn () => ! auth()->user()->is_admin), + + BulkActionGroup::make([ Tables\Actions\DeleteBulkAction::make(), - ]), + ])->label('Other actions'), ]); } diff --git a/app/Filament/Admin/Resources/OrderResource/Pages/ListOrders.php b/app/Filament/Admin/Resources/OrderResource/Pages/ListOrders.php index c58bb77..8860b86 100644 --- a/app/Filament/Admin/Resources/OrderResource/Pages/ListOrders.php +++ b/app/Filament/Admin/Resources/OrderResource/Pages/ListOrders.php @@ -15,6 +15,25 @@ class ListOrders extends ListRecords { protected static string $resource = OrderResource::class; + private function excludeStatuses($query): mixed + { + return $query + ->whereNot('status', OrderStatus::READY_FOR_INVOICE) + ->whereNot('status', OrderStatus::INVOICED) + ->whereNot('status', OrderStatus::SHIPPED); + } + + private function getBadgeCount(callable $queryCallback): ?int + { + $count = Order::query()->when(true, $queryCallback) + ->whereNot('status', OrderStatus::READY_FOR_INVOICE) + ->whereNot('status', OrderStatus::INVOICED) + ->whereNot('status', OrderStatus::SHIPPED) + ->count(); + + return $count > 0 ? $count : null; + } + protected function getHeaderActions(): array { return [ @@ -26,67 +45,37 @@ protected function getHeaderActions(): array public function getTabs(): array { return [ + 'all' => Tab::make('All') + ->icon(IconEnum::TAB_ALL->value), + 'active' => Tab::make() - ->query(function ($query) { - return $query - ->whereNot('status', OrderStatus::INVOICED) - ->whereNot('status', ORderStatus::SHIPPED); - }) + ->query(fn ($query) => $this->excludeStatuses($query)) ->icon(OrderStatus::PRODUCTION->getIcon()) - ->badge(function () { - return Order::whereNot('status', OrderStatus::SHIPPED) - ->whereNot('status', OrderStatus::INVOICED) - ->count(); - }), + ->badge(fn () => $this->getBadgeCount(fn ($query) => $this->excludeStatuses($query))), 'unprinted' => Tab::make() - ->query(function ($query) { - return $query->where('printed', false); - }) + ->query(fn ($query) => $this->excludeStatuses($query)->where('printed', false)) ->icon(IconEnum::PRINT->value) - ->badge(function () { - $count = Order::where('printed', false)->count(); - - return $count > 0 ? $count : null; - }) + ->badge(fn () => $this->getBadgeCount(fn ($query) => $query->where('printed', false))) ->badgeColor('success'), 'overdue' => Tab::make() - ->query(function ($query) { - return $query->whereDate('due_date', '<=', today()) - ->whereNot('status', OrderStatus::INVOICED) - ->whereNot('status', ORderStatus::SHIPPED); - }) + ->query(fn ($query) => $this->excludeStatuses($query)->whereDate('due_date', '<=', today())) ->icon(IconEnum::TAB_OVERDUE->value) - ->badge(function () { - $count = Order::whereDate('due_date', '<=', today()) - ->whereNot('status', OrderStatus::INVOICED) - ->whereNot('status', ORderStatus::SHIPPED) - ->count(); - - return $count > 0 ? $count : null; - }) + ->badge(fn () => $this->getBadgeCount(fn ($query) => $query->whereDate('due_date', '<=', today()))) ->badgeColor('danger'), 'rush' => Tab::make() - ->query(function ($query) { - return $query->where('rush', true) - ->whereNot('status', OrderStatus::INVOICED) - ->whereNot('status', OrderStatus::SHIPPED); - }) + ->query(fn ($query) => $this->excludeStatuses($query)->where('rush', true)) ->icon(OrderAttributes::rush->getIcon()) - ->badge(function () { - $count = Order::where('rush', true) - ->whereNot('status', OrderStatus::INVOICED) - ->whereNot('status', OrderStatus::SHIPPED) - ->count(); - - return $count > 0 ? $count : null; - }) + ->badge(fn () => $this->getBadgeCount(fn ($query) => $query->where('rush', true))) ->badgeColor('warning'), - 'all' => Tab::make('All') - ->icon(IconEnum::TAB_ALL->value), + 'ready_for_invoice' => Tab::make() + ->query(fn ($query) => $query->where('status', OrderStatus::READY_FOR_INVOICE)) + ->icon(OrderStatus::READY_FOR_INVOICE->getIcon()) + ->badge(fn () => $this->getBadgeCount(fn ($query) => $query->where('status', OrderStatus::READY_FOR_INVOICE))) + ->badgeColor(OrderStatus::READY_FOR_INVOICE->getColor()), ]; } } diff --git a/app/Filament/Admin/Widgets/ActiveOrdersTable.php b/app/Filament/Admin/Widgets/ActiveOrdersTable.php index f5bed4d..eb86c61 100644 --- a/app/Filament/Admin/Widgets/ActiveOrdersTable.php +++ b/app/Filament/Admin/Widgets/ActiveOrdersTable.php @@ -12,6 +12,8 @@ class ActiveOrdersTable extends BaseWidget { protected static ?int $sort = 2; + protected string|int|array $columnSpan = 2; + public function table(Table $table): Table { return $table diff --git a/app/Filament/Admin/Widgets/RushOrdersTable.php b/app/Filament/Admin/Widgets/RushOrdersTable.php index 9ae3e62..c0b0ad3 100644 --- a/app/Filament/Admin/Widgets/RushOrdersTable.php +++ b/app/Filament/Admin/Widgets/RushOrdersTable.php @@ -10,6 +10,8 @@ class RushOrdersTable extends BaseWidget { + protected string|int|array $columnSpan = 2; + public function table(Table $table): Table { return $table diff --git a/app/Observers/OrderObserver.php b/app/Observers/OrderObserver.php index e371b76..cf58253 100644 --- a/app/Observers/OrderObserver.php +++ b/app/Observers/OrderObserver.php @@ -12,7 +12,7 @@ class OrderObserver */ public function created(Order $order): void { - if ($order->invoice()->exists()) { + if ($order->invoice != null) { $order->invoice->calculateTotals(); } } @@ -22,7 +22,7 @@ public function created(Order $order): void */ public function updated(Order $order): void { - if ($order->invoice()->exists()) { + if ($order->invoice != null) { $order->invoice->calculateTotals(); } } @@ -33,7 +33,9 @@ public function updated(Order $order): void public function saved(Order $order): void { if ($order->isDirty(['invoice_id']) && Invoice::where('id', $order->invoice_id)->exists()) { - $order->invoice->calculateTotals(); + if ($order->invoice != null) { + $order->invoice->calculateTotals(); + } } } @@ -42,7 +44,7 @@ public function saved(Order $order): void */ public function deleted(Order $order): void { - if ($order->invoice()->exists()) { + if ($order->invoice != null) { $order->invoice->calculateTotals(); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index d481c82..3cb54b3 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -29,9 +29,10 @@ public function panel(Panel $panel): Panel ->path('admin') ->login(UsernameLogin::class) ->colors([ - 'primary' => Color::Blue, - 'code' => Color::hex('#d63384'), - 'invoiced' => Color::hex('#900090'), + 'primary' => Color::Blue, + 'code' => Color::hex('#d63384'), + 'invoicing' => Color::hex('#DD00DD'), + 'invoiced' => Color::hex('#900090'), ]) ->discoverResources(in: app_path('Filament/Admin/Resources/'), for: 'App\\Filament\\Admin\\Resources') ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') diff --git a/database/factories/TaxRateFactory.php b/database/factories/TaxRateFactory.php index 1a697b0..251d3de 100644 --- a/database/factories/TaxRateFactory.php +++ b/database/factories/TaxRateFactory.php @@ -17,7 +17,8 @@ class TaxRateFactory extends Factory public function definition(): array { return [ - // + 'name' => strtoupper($this->faker->randomLetter()).'ST', + 'value' => random_int(1, 15), ]; } } diff --git a/tests/Unit/InvoiceTest.php b/tests/Unit/InvoiceTest.php index bc38b64..71775fa 100644 --- a/tests/Unit/InvoiceTest.php +++ b/tests/Unit/InvoiceTest.php @@ -29,9 +29,13 @@ $user = User::factory(['is_admin' => true])->create(); $this->actingAs($user); + TaxRate::factory(['name' => 'GST'])->create(); + TaxRate::factory(['name' => 'PST'])->create(); + TaxRate::factory(['name' => 'HST'])->create(); + $customer = Customer::factory()->create(); // Generates a customer - $pst_rate = TaxRate::where('name', 'PST')->value('value') ?? 0; $gst_rate = TaxRate::where('name', 'GST')->value('value') ?? 0; + $pst_rate = TaxRate::where('name', 'PST')->value('value') ?? 0; $hst_rate = TaxRate::where('name', 'HST')->value('value') ?? 0; $formData = [ @@ -39,9 +43,9 @@ 'date' => now()->toDateString(), 'due_date' => now()->addDays(7)->toDateString(), 'status' => InvoiceStatus::UNPAID->value, - 'has_gst' => true, - 'has_pst' => true, - 'has_hst' => false, + 'has_gst' => 1, + 'has_pst' => 1, + 'has_hst' => 0, ]; $this->livewire(CreateInvoice::class) @@ -50,7 +54,7 @@ ->assertHasNoErrors(); $this->assertDatabaseHas('invoices', [ - 'internal_id' => 'INV40001', + 'internal_id' => 'TN40001', 'customer_id' => $formData['customer_id'], 'status' => $formData['status'], 'has_gst' => $formData['has_gst'], @@ -61,7 +65,7 @@ 'hst_rate' => $hst_rate, ]); - $invoice = Invoice::where('internal_id', 'INV40001')->firstOrFail(); + $invoice = Invoice::where('internal_id', 'TN40001')->firstOrFail(); $this->assertEquals($invoice->orders->isEmpty(), true); }); diff --git a/tests/Unit/OrderTest.php b/tests/Unit/OrderTest.php index fe58347..ba5d4a7 100644 --- a/tests/Unit/OrderTest.php +++ b/tests/Unit/OrderTest.php @@ -21,6 +21,7 @@ uses(DatabaseMigrations::class); it('can render the list page', function () { + $this->actingAs(User::factory(['is_admin' => true])->create()); livewire(ListOrders::class)->assertSuccessful(); }); diff --git a/tests/Unit/QuoteTest.php b/tests/Unit/QuoteTest.php index 7e7a0c6..743ad8b 100644 --- a/tests/Unit/QuoteTest.php +++ b/tests/Unit/QuoteTest.php @@ -83,19 +83,19 @@ 'screenPrintEntries' => [ [ - 'logo' => 'logo name', - 'quantity' => 5, - 'width' => 1.5, - 'height' => 2.5, - 'color_amount' => 2, - 'setup_amount' => 2, - 'run_charge' => 10, - 'color_match' => true, - 'color_change' => true, - 'flash' => 5.20, - 'fleece' => 5.30, - 'poly_ink' => 5.40, - 'other_charges' => 5.50, + 'logo' => 'logo name', + 'quantity' => 5, + 'width' => 1.5, + 'height' => 2.5, + 'color_amount' => 2, + 'setup_amount' => 2, + 'run_charge' => 10, + 'color_match' => 5.10, + 'color_change' => 5.15, + 'flash' => 5.20, + 'fleece' => 5.30, + 'poly_ink' => 5.40, + 'artwork_fee' => 5.50, ], ], ];