Ver código fonte

wip Estimates

3.x
Andrew Wallo 9 meses atrás
pai
commit
efbe5d8f29
30 arquivos alterados com 1126 adições e 470 exclusões
  1. 27
    0
      app/Enums/Accounting/DocumentType.php
  2. 13
    1
      app/Enums/Accounting/EstimateStatus.php
  3. 1
    1
      app/Filament/Company/Pages/Reports.php
  4. 81
    18
      app/Filament/Company/Resources/Sales/EstimateResource.php
  5. 1
    7
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php
  6. 13
    23
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php
  7. 1
    11
      app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php
  8. 3
    3
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  9. 4
    14
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  10. 36
    0
      app/Filament/Infolists/Components/DocumentPreview.php
  11. 2
    2
      app/Filament/Infolists/Components/ReportEntry.php
  12. 232
    1
      app/Models/Accounting/Estimate.php
  13. 21
    7
      app/Models/Accounting/Invoice.php
  14. 5
    0
      app/Models/Company.php
  15. 27
    0
      app/Observers/EstimateObserver.php
  16. 0
    3
      app/Observers/InvoiceObserver.php
  17. 70
    0
      app/Policies/EstimatePolicy.php
  18. 204
    0
      app/View/Models/DocumentPreviewViewModel.php
  19. 39
    39
      composer.lock
  20. 17
    12
      database/factories/Accounting/EstimateFactory.php
  21. 15
    2
      database/factories/CompanyFactory.php
  22. 1
    0
      database/migrations/2024_11_27_223000_create_estimates_table.php
  23. 1
    0
      database/migrations/2024_11_27_223015_create_invoices_table.php
  24. 127
    109
      package-lock.json
  25. 3
    0
      pint.json
  26. 4
    1
      resources/data/lang/en.json
  27. 0
    169
      resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php
  28. 0
    47
      resources/views/filament/company/resources/sales/invoices/pages/view-invoice.blade.php
  29. 178
    0
      resources/views/filament/infolists/components/document-preview.blade.php
  30. 0
    0
      resources/views/filament/infolists/components/report-entry.blade.php

+ 27
- 0
app/Enums/Accounting/DocumentType.php Ver arquivo

@@ -42,4 +42,31 @@ enum DocumentType: string implements HasIcon, HasLabel
42 42
             self::Bill => 'purchaseDiscounts',
43 43
         };
44 44
     }
45
+
46
+    public function getLabels(): array
47
+    {
48
+        return match ($this) {
49
+            self::Invoice => [
50
+                'title' => 'Invoice',
51
+                'number' => 'Invoice Number',
52
+                'reference_number' => 'P.O/S.O Number',
53
+                'date' => 'Invoice Date',
54
+                'due_date' => 'Payment Due',
55
+            ],
56
+            self::Estimate => [
57
+                'title' => 'Estimate',
58
+                'number' => 'Estimate Number',
59
+                'reference_number' => 'Reference Number',
60
+                'date' => 'Estimate Date',
61
+                'due_date' => 'Expiration Date',
62
+            ],
63
+            self::Bill => [
64
+                'title' => 'Bill',
65
+                'number' => 'Bill Number',
66
+                'reference_number' => 'P.O/S.O Number',
67
+                'date' => 'Bill Date',
68
+                'due_date' => 'Payment Due',
69
+            ],
70
+        };
71
+    }
45 72
 }

+ 13
- 1
app/Enums/Accounting/EstimateStatus.php Ver arquivo

@@ -2,9 +2,10 @@
2 2
 
3 3
 namespace App\Enums\Accounting;
4 4
 
5
+use Filament\Support\Contracts\HasColor;
5 6
 use Filament\Support\Contracts\HasLabel;
6 7
 
7
-enum EstimateStatus: string implements HasLabel
8
+enum EstimateStatus: string implements HasColor, HasLabel
8 9
 {
9 10
     case Draft = 'draft';
10 11
     case Sent = 'sent';
@@ -19,4 +20,15 @@ enum EstimateStatus: string implements HasLabel
19 20
     {
20 21
         return $this->name;
21 22
     }
23
+
24
+    public function getColor(): string | array | null
25
+    {
26
+        return match ($this) {
27
+            self::Draft, self::Unsent => 'gray',
28
+            self::Sent, self::Viewed => 'primary',
29
+            self::Accepted, self::Converted => 'success',
30
+            self::Declined => 'danger',
31
+            self::Expired => 'warning',
32
+        };
33
+    }
22 34
 }

+ 1
- 1
app/Filament/Company/Pages/Reports.php Ver arquivo

@@ -8,7 +8,7 @@ use App\Filament\Company\Pages\Reports\BalanceSheet;
8 8
 use App\Filament\Company\Pages\Reports\CashFlowStatement;
9 9
 use App\Filament\Company\Pages\Reports\IncomeStatement;
10 10
 use App\Filament\Company\Pages\Reports\TrialBalance;
11
-use App\Infolists\Components\ReportEntry;
11
+use App\Filament\Infolists\Components\ReportEntry;
12 12
 use Filament\Infolists\Components\Section;
13 13
 use Filament\Infolists\Infolist;
14 14
 use Filament\Navigation\NavigationItem;

+ 81
- 18
app/Filament/Company/Resources/Sales/EstimateResource.php Ver arquivo

@@ -46,6 +46,7 @@ class EstimateResource extends Resource
46 46
             ->schema([
47 47
                 Forms\Components\Section::make('Estimate Header')
48 48
                     ->collapsible()
49
+                    ->collapsed()
49 50
                     ->schema([
50 51
                         Forms\Components\Split::make([
51 52
                             Forms\Components\Group::make([
@@ -114,26 +115,23 @@ class EstimateResource extends Resource
114 115
                                     ->label('Estimate Number')
115 116
                                     ->default(fn () => Estimate::getNextDocumentNumber()),
116 117
                                 Forms\Components\TextInput::make('reference_number')
117
-                                    ->label('P.O/S.O Number'),
118
+                                    ->label('Reference Number'),
118 119
                                 Forms\Components\DatePicker::make('date')
119 120
                                     ->label('Estimate Date')
120 121
                                     ->live()
121 122
                                     ->default(now())
122
-                                    ->disabled(function (?Estimate $record) {
123
-                                        return $record?->hasPayments();
124
-                                    })
125 123
                                     ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
126 124
                                         $date = $state;
127
-                                        $dueDate = $get('expiration_date');
125
+                                        $expirationDate = $get('expiration_date');
128 126
 
129
-                                        if ($date && $dueDate && $date > $dueDate) {
127
+                                        if ($date && $expirationDate && $date > $expirationDate) {
130 128
                                             $set('expiration_date', $date);
131 129
                                         }
132 130
                                     }),
133 131
                                 Forms\Components\DatePicker::make('expiration_date')
134
-                                    ->label('Payment Due')
132
+                                    ->label('Expiration Date')
135 133
                                     ->default(function () use ($company) {
136
-                                        return now()->addDays($company->defaultEstimate->payment_terms->getDays());
134
+                                        return now()->addDays($company->defaultInvoice->payment_terms->getDays());
137 135
                                     })
138 136
                                     ->minDate(static function (Forms\Get $get) {
139 137
                                         return $get('date') ?? now();
@@ -277,6 +275,7 @@ class EstimateResource extends Resource
277 275
                     ]),
278 276
                 Forms\Components\Section::make('Estimate Footer')
279 277
                     ->collapsible()
278
+                    ->collapsed()
280 279
                     ->schema([
281 280
                         Forms\Components\Textarea::make('footer')
282 281
                             ->columnSpanFull(),
@@ -298,7 +297,7 @@ class EstimateResource extends Resource
298 297
                     ->badge()
299 298
                     ->searchable(),
300 299
                 Tables\Columns\TextColumn::make('expiration_date')
301
-                    ->label('Due')
300
+                    ->label('Expiration Date')
302 301
                     ->asRelativeDay()
303 302
                     ->sortable(),
304 303
                 Tables\Columns\TextColumn::make('date')
@@ -338,9 +337,12 @@ class EstimateResource extends Resource
338 337
                     Tables\Actions\EditAction::make(),
339 338
                     Tables\Actions\ViewAction::make(),
340 339
                     Tables\Actions\DeleteAction::make(),
341
-                    //                    Estimate::getReplicateAction(Tables\Actions\ReplicateAction::class),
342
-                    //                    Estimate::getApproveDraftAction(Tables\Actions\Action::class),
343
-                    //                    Estimate::getMarkAsSentAction(Tables\Actions\Action::class),
340
+                    Estimate::getReplicateAction(Tables\Actions\ReplicateAction::class),
341
+                    Estimate::getApproveDraftAction(Tables\Actions\Action::class),
342
+                    Estimate::getMarkAsSentAction(Tables\Actions\Action::class),
343
+                    Estimate::getMarkAsAcceptedAction(Tables\Actions\Action::class),
344
+                    Estimate::getMarkAsDeclinedAction(Tables\Actions\Action::class),
345
+                    Estimate::getConvertToInvoiceAction(Tables\Actions\Action::class),
344 346
                 ]),
345 347
             ])
346 348
             ->bulkActions([
@@ -355,22 +357,25 @@ class EstimateResource extends Resource
355 357
                         ->databaseTransaction()
356 358
                         ->deselectRecordsAfterCompletion()
357 359
                         ->excludeAttributes([
360
+                            'estimate_number',
361
+                            'date',
362
+                            'expiration_date',
363
+                            'approved_at',
364
+                            'accepted_at',
365
+                            'declined_at',
366
+                            'last_sent_at',
367
+                            'last_viewed_at',
358 368
                             'status',
359
-                            'amount_paid',
360
-                            'amount_due',
361 369
                             'created_by',
362 370
                             'updated_by',
363 371
                             'created_at',
364 372
                             'updated_at',
365
-                            'estimate_number',
366
-                            'date',
367
-                            'expiration_date',
368 373
                         ])
369 374
                         ->beforeReplicaSaved(function (Estimate $replica) {
370 375
                             $replica->status = EstimateStatus::Draft;
371 376
                             $replica->estimate_number = Estimate::getNextDocumentNumber();
372 377
                             $replica->date = now();
373
-                            $replica->expiration_date = now()->addDays($replica->company->defaultEstimate->payment_terms->getDays());
378
+                            $replica->expiration_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
374 379
                         })
375 380
                         ->withReplicatedRelationships(['lineItems'])
376 381
                         ->withExcludedRelationshipAttributes('lineItems', [
@@ -435,6 +440,64 @@ class EstimateResource extends Resource
435 440
                                 ]);
436 441
                             });
437 442
 
443
+                            $action->success();
444
+                        }),
445
+                    Tables\Actions\BulkAction::make('markAsAccepted')
446
+                        ->label('Mark as Accepted')
447
+                        ->icon('heroicon-o-check-badge')
448
+                        ->databaseTransaction()
449
+                        ->successNotificationTitle('Estimates Accepted')
450
+                        ->failureNotificationTitle('Failed to Mark Estimates as Accepted')
451
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
452
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isAccepted());
453
+
454
+                            if ($doesntContainSent) {
455
+                                Notification::make()
456
+                                    ->title('Acceptance Failed')
457
+                                    ->body('Only sent estimates that haven\'t been accepted can be marked as accepted. Please adjust your selection and try again.')
458
+                                    ->persistent()
459
+                                    ->danger()
460
+                                    ->send();
461
+
462
+                                $action->cancel(true);
463
+                            }
464
+                        })
465
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
466
+                            $records->each(function (Estimate $record) {
467
+                                $record->markAsAccepted();
468
+                            });
469
+
470
+                            $action->success();
471
+                        }),
472
+                    Tables\Actions\BulkAction::make('markAsDeclined')
473
+                        ->label('Mark as Declined')
474
+                        ->icon('heroicon-o-x-circle')
475
+                        ->requiresConfirmation()
476
+                        ->databaseTransaction()
477
+                        ->color('danger')
478
+                        ->modalHeading('Mark Estimates as Declined')
479
+                        ->modalDescription('Are you sure you want to mark the selected estimates as declined? This action cannot be undone.')
480
+                        ->successNotificationTitle('Estimates Declined')
481
+                        ->failureNotificationTitle('Failed to Mark Estimates as Declined')
482
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
483
+                            $doesntContainSent = $records->contains(fn (Estimate $record) => $record->status !== EstimateStatus::Sent || $record->isDeclined());
484
+
485
+                            if ($doesntContainSent) {
486
+                                Notification::make()
487
+                                    ->title('Declination Failed')
488
+                                    ->body('Only sent estimates that haven\'t been declined can be marked as declined. Please adjust your selection and try again.')
489
+                                    ->persistent()
490
+                                    ->danger()
491
+                                    ->send();
492
+
493
+                                $action->cancel(true);
494
+                            }
495
+                        })
496
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
497
+                            $records->each(function (Estimate $record) {
498
+                                $record->markAsDeclined();
499
+                            });
500
+
438 501
                             $action->success();
439 502
                         }),
440 503
                 ]),

+ 1
- 7
app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php Ver arquivo

@@ -43,12 +43,6 @@ class EditEstimate extends EditRecord
43 43
 
44 44
         $data = array_merge($data, $totals);
45 45
 
46
-        $record = parent::handleRecordUpdate($record, $data);
47
-
48
-        if ($record->approved_at && $record->approvalTransaction) {
49
-            $record->updateApprovalTransaction();
50
-        }
51
-
52
-        return $record;
46
+        return parent::handleRecordUpdate($record, $data);
53 47
     }
54 48
 }

+ 13
- 23
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php Ver arquivo

@@ -2,14 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4 4
 
5
+use App\Enums\Accounting\DocumentType;
5 6
 use App\Filament\Company\Resources\Sales\ClientResource;
6 7
 use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Filament\Infolists\Components\DocumentPreview;
7 9
 use App\Models\Accounting\Estimate;
8 10
 use Filament\Actions;
9 11
 use Filament\Infolists\Components\Grid;
10 12
 use Filament\Infolists\Components\Section;
11 13
 use Filament\Infolists\Components\TextEntry;
12
-use Filament\Infolists\Components\ViewEntry;
13 14
 use Filament\Infolists\Infolist;
14 15
 use Filament\Resources\Pages\ViewRecord;
15 16
 use Filament\Support\Enums\FontWeight;
@@ -19,8 +20,6 @@ use Filament\Support\Enums\MaxWidth;
19 20
 
20 21
 class ViewEstimate extends ViewRecord
21 22
 {
22
-    protected static string $view = 'filament.company.resources.sales.invoices.pages.view-invoice';
23
-
24 23
     protected static string $resource = EstimateResource::class;
25 24
 
26 25
     protected $listeners = [
@@ -40,7 +39,10 @@ class ViewEstimate extends ViewRecord
40 39
                 Actions\DeleteAction::make(),
41 40
                 Estimate::getApproveDraftAction(),
42 41
                 Estimate::getMarkAsSentAction(),
42
+                Estimate::getMarkAsAcceptedAction(),
43
+                Estimate::getMarkAsDeclinedAction(),
43 44
                 Estimate::getReplicateAction(),
45
+                Estimate::getConvertToInvoiceAction(),
44 46
             ])
45 47
                 ->label('Actions')
46 48
                 ->button()
@@ -61,7 +63,7 @@ class ViewEstimate extends ViewRecord
61 63
                     ->schema([
62 64
                         Grid::make(1)
63 65
                             ->schema([
64
-                                TextEntry::make('invoice_number')
66
+                                TextEntry::make('estimate_number')
65 67
                                     ->label('Estimate #'),
66 68
                                 TextEntry::make('status')
67 69
                                     ->badge(),
@@ -70,11 +72,8 @@ class ViewEstimate extends ViewRecord
70 72
                                     ->color('primary')
71 73
                                     ->weight(FontWeight::SemiBold)
72 74
                                     ->url(static fn (Estimate $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
73
-                                TextEntry::make('amount_due')
74
-                                    ->label('Amount Due')
75
-                                    ->currency(static fn (Estimate $record) => $record->currency_code),
76
-                                TextEntry::make('due_date')
77
-                                    ->label('Due')
75
+                                TextEntry::make('expiration_date')
76
+                                    ->label('Expiration Date')
78 77
                                     ->asRelativeDay(),
79 78
                                 TextEntry::make('approved_at')
80 79
                                     ->label('Approved At')
@@ -84,22 +83,13 @@ class ViewEstimate extends ViewRecord
84 83
                                     ->label('Last Sent')
85 84
                                     ->placeholder('Never')
86 85
                                     ->date(),
87
-                                TextEntry::make('paid_at')
88
-                                    ->label('Paid At')
89
-                                    ->placeholder('Not Paid')
86
+                                TextEntry::make('accepted_at')
87
+                                    ->label('Accepted At')
88
+                                    ->placeholder('Not Accepted')
90 89
                                     ->date(),
91 90
                             ])->columnSpan(1),
92
-                        Grid::make()
93
-                            ->schema([
94
-                                ViewEntry::make('invoice-view')
95
-                                    ->label('View Estimate')
96
-                                    ->columnSpan(3)
97
-                                    ->view('filament.company.resources.sales.invoices.components.invoice-view')
98
-                                    ->viewData([
99
-                                        'invoice' => $this->record,
100
-                                    ]),
101
-                            ])
102
-                            ->columnSpan(3),
91
+                        DocumentPreview::make()
92
+                            ->type(DocumentType::Estimate),
103 93
                     ]),
104 94
             ]);
105 95
     }

+ 1
- 11
app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php Ver arquivo

@@ -21,32 +21,27 @@ class EstimateOverview extends EnhancedStatsOverviewWidget
21 21
 
22 22
     protected function getStats(): array
23 23
     {
24
-        // Active estimates: Draft, Unsent, Sent
25 24
         $activeEstimates = $this->getPageTableQuery()->active();
26 25
 
27 26
         $totalActiveCount = $activeEstimates->count();
28 27
         $totalActiveAmount = $activeEstimates->get()->sumMoneyInDefaultCurrency('total');
29 28
 
30
-        // Accepted estimates
31 29
         $acceptedEstimates = $this->getPageTableQuery()
32 30
             ->where('status', EstimateStatus::Accepted);
33 31
 
34 32
         $totalAcceptedCount = $acceptedEstimates->count();
35 33
         $totalAcceptedAmount = $acceptedEstimates->get()->sumMoneyInDefaultCurrency('total');
36 34
 
37
-        // Converted estimates
38 35
         $convertedEstimates = $this->getPageTableQuery()
39 36
             ->where('status', EstimateStatus::Converted);
40 37
 
41 38
         $totalConvertedCount = $convertedEstimates->count();
42 39
         $totalEstimatesCount = $this->getPageTableQuery()->count();
43 40
 
44
-        // Use Number::percentage for formatted conversion rate
45 41
         $percentConverted = $totalEstimatesCount > 0
46 42
             ? Number::percentage(($totalConvertedCount / $totalEstimatesCount) * 100, maxPrecision: 1)
47 43
             : Number::percentage(0, maxPrecision: 1);
48 44
 
49
-        // Average estimate total
50 45
         $totalEstimateAmount = $this->getPageTableQuery()
51 46
             ->get()
52 47
             ->sumMoneyInDefaultCurrency('total');
@@ -56,25 +51,20 @@ class EstimateOverview extends EnhancedStatsOverviewWidget
56 51
             : 0;
57 52
 
58 53
         return [
59
-            // Stat 1: Total Active Estimates
60 54
             EnhancedStatsOverviewWidget\EnhancedStat::make('Active Estimates', CurrencyConverter::formatCentsToMoney($totalActiveAmount))
61 55
                 ->suffix(CurrencyAccessor::getDefaultCurrency())
62 56
                 ->description($totalActiveCount . ' active estimates'),
63 57
 
64
-            // Stat 2: Total Accepted Estimates
65 58
             EnhancedStatsOverviewWidget\EnhancedStat::make('Accepted Estimates', CurrencyConverter::formatCentsToMoney($totalAcceptedAmount))
66 59
                 ->suffix(CurrencyAccessor::getDefaultCurrency())
67 60
                 ->description($totalAcceptedCount . ' accepted'),
68 61
 
69
-            // Stat 3: Percent Converted
70 62
             EnhancedStatsOverviewWidget\EnhancedStat::make('Converted Estimates', $percentConverted)
71 63
                 ->suffix('converted')
72 64
                 ->description($totalConvertedCount . ' converted'),
73 65
 
74
-            // Stat 4: Average Estimate Total
75 66
             EnhancedStatsOverviewWidget\EnhancedStat::make('Average Estimate Total', CurrencyConverter::formatCentsToMoney($averageEstimateTotal))
76
-                ->suffix(CurrencyAccessor::getDefaultCurrency())
77
-                ->description('Avg. value per estimate'),
67
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
78 68
         ];
79 69
     }
80 70
 }

+ 3
- 3
app/Filament/Company/Resources/Sales/InvoiceResource.php Ver arquivo

@@ -53,6 +53,7 @@ class InvoiceResource extends Resource
53 53
             ->schema([
54 54
                 Forms\Components\Section::make('Invoice Header')
55 55
                     ->collapsible()
56
+                    ->collapsed()
56 57
                     ->schema([
57 58
                         Forms\Components\Split::make([
58 59
                             Forms\Components\Group::make([
@@ -285,6 +286,7 @@ class InvoiceResource extends Resource
285 286
                     ]),
286 287
                 Forms\Components\Section::make('Invoice Footer')
287 288
                     ->collapsible()
289
+                    ->collapsed()
288 290
                     ->schema([
289 291
                         Forms\Components\Textarea::make('footer')
290 292
                             ->columnSpanFull(),
@@ -531,9 +533,7 @@ class InvoiceResource extends Resource
531 533
                         })
532 534
                         ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
533 535
                             $records->each(function (Invoice $record) {
534
-                                $record->updateQuietly([
535
-                                    'status' => InvoiceStatus::Sent,
536
-                                ]);
536
+                                $record->markAsSent();
537 537
                             });
538 538
 
539 539
                             $action->success();

+ 4
- 14
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php Ver arquivo

@@ -2,14 +2,15 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5
+use App\Enums\Accounting\DocumentType;
5 6
 use App\Filament\Company\Resources\Sales\ClientResource;
6 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Filament\Infolists\Components\DocumentPreview;
7 9
 use App\Models\Accounting\Invoice;
8 10
 use Filament\Actions;
9 11
 use Filament\Infolists\Components\Grid;
10 12
 use Filament\Infolists\Components\Section;
11 13
 use Filament\Infolists\Components\TextEntry;
12
-use Filament\Infolists\Components\ViewEntry;
13 14
 use Filament\Infolists\Infolist;
14 15
 use Filament\Resources\Pages\ViewRecord;
15 16
 use Filament\Support\Enums\FontWeight;
@@ -19,8 +20,6 @@ use Filament\Support\Enums\MaxWidth;
19 20
 
20 21
 class ViewInvoice extends ViewRecord
21 22
 {
22
-    protected static string $view = 'filament.company.resources.sales.invoices.pages.view-invoice';
23
-
24 23
     protected static string $resource = InvoiceResource::class;
25 24
 
26 25
     protected $listeners = [
@@ -89,17 +88,8 @@ class ViewInvoice extends ViewRecord
89 88
                                     ->placeholder('Not Paid')
90 89
                                     ->date(),
91 90
                             ])->columnSpan(1),
92
-                        Grid::make()
93
-                            ->schema([
94
-                                ViewEntry::make('invoice-view')
95
-                                    ->label('View Invoice')
96
-                                    ->columnSpan(3)
97
-                                    ->view('filament.company.resources.sales.invoices.components.invoice-view')
98
-                                    ->viewData([
99
-                                        'invoice' => $this->record,
100
-                                    ]),
101
-                            ])
102
-                            ->columnSpan(3),
91
+                        DocumentPreview::make()
92
+                            ->type(DocumentType::Invoice),
103 93
                     ]),
104 94
             ]);
105 95
     }

+ 36
- 0
app/Filament/Infolists/Components/DocumentPreview.php Ver arquivo

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+namespace App\Filament\Infolists\Components;
4
+
5
+use App\Enums\Accounting\DocumentType;
6
+use Filament\Infolists\Components\Grid;
7
+
8
+class DocumentPreview extends Grid
9
+{
10
+    protected string $view = 'filament.infolists.components.document-preview';
11
+
12
+    protected DocumentType $documentType = DocumentType::Invoice;
13
+
14
+    protected function setUp(): void
15
+    {
16
+        parent::setUp();
17
+
18
+        $this->columnSpan(3);
19
+    }
20
+
21
+    public function type(DocumentType | string $type): static
22
+    {
23
+        if (is_string($type)) {
24
+            $type = DocumentType::from($type);
25
+        }
26
+
27
+        $this->documentType = $type;
28
+
29
+        return $this;
30
+    }
31
+
32
+    public function getType(): DocumentType
33
+    {
34
+        return $this->documentType;
35
+    }
36
+}

app/Infolists/Components/ReportEntry.php → app/Filament/Infolists/Components/ReportEntry.php Ver arquivo

@@ -1,6 +1,6 @@
1 1
 <?php
2 2
 
3
-namespace App\Infolists\Components;
3
+namespace App\Filament\Infolists\Components;
4 4
 
5 5
 use Filament\Infolists\Components\Entry;
6 6
 use Filament\Support\Concerns\HasDescription;
@@ -15,5 +15,5 @@ class ReportEntry extends Entry
15 15
     use HasIcon;
16 16
     use HasIconColor;
17 17
 
18
-    protected string $view = 'infolists.components.report-entry';
18
+    protected string $view = 'filament.infolists.components.report-entry';
19 19
 }

+ 232
- 1
app/Models/Accounting/Estimate.php Ver arquivo

@@ -10,18 +10,28 @@ use App\Concerns\CompanyOwned;
10 10
 use App\Enums\Accounting\AdjustmentComputation;
11 11
 use App\Enums\Accounting\DocumentDiscountMethod;
12 12
 use App\Enums\Accounting\EstimateStatus;
13
+use App\Enums\Accounting\InvoiceStatus;
14
+use App\Filament\Company\Resources\Sales\EstimateResource;
15
+use App\Filament\Company\Resources\Sales\InvoiceResource;
13 16
 use App\Models\Common\Client;
14 17
 use App\Models\Setting\Currency;
18
+use App\Observers\EstimateObserver;
19
+use Filament\Actions\Action;
20
+use Filament\Actions\MountableAction;
21
+use Filament\Actions\ReplicateAction;
15 22
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16 24
 use Illuminate\Database\Eloquent\Builder;
17 25
 use Illuminate\Database\Eloquent\Casts\Attribute;
18 26
 use Illuminate\Database\Eloquent\Factories\HasFactory;
19 27
 use Illuminate\Database\Eloquent\Model;
20 28
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
29
+use Illuminate\Database\Eloquent\Relations\HasOne;
21 30
 use Illuminate\Database\Eloquent\Relations\MorphMany;
22 31
 use Illuminate\Support\Carbon;
23 32
 
24 33
 #[CollectedBy(DocumentCollection::class)]
34
+#[ObservedBy(EstimateObserver::class)]
25 35
 class Estimate extends Model
26 36
 {
27 37
     use Blamable;
@@ -40,6 +50,7 @@ class Estimate extends Model
40 50
         'expiration_date',
41 51
         'approved_at',
42 52
         'accepted_at',
53
+        'converted_at',
43 54
         'declined_at',
44 55
         'last_sent_at',
45 56
         'last_viewed_at',
@@ -86,6 +97,11 @@ class Estimate extends Model
86 97
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
87 98
     }
88 99
 
100
+    public function invoice(): HasOne
101
+    {
102
+        return $this->hasOne(Invoice::class);
103
+    }
104
+
89 105
     public function lineItems(): MorphMany
90 106
     {
91 107
         return $this->morphMany(DocumentLineItem::class, 'documentable');
@@ -179,7 +195,7 @@ class Estimate extends Model
179 195
     public function approveDraft(?Carbon $approvedAt = null): void
180 196
     {
181 197
         if (! $this->isDraft()) {
182
-            throw new \RuntimeException('Invoice is not in draft status.');
198
+            throw new \RuntimeException('Estimate is not in draft status.');
183 199
         }
184 200
 
185 201
         $approvedAt ??= now();
@@ -189,4 +205,219 @@ class Estimate extends Model
189 205
             'status' => EstimateStatus::Unsent,
190 206
         ]);
191 207
     }
208
+
209
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
210
+    {
211
+        return $action::make('approveDraft')
212
+            ->label('Approve')
213
+            ->icon('heroicon-o-check-circle')
214
+            ->visible(function (self $record) {
215
+                return $record->isDraft();
216
+            })
217
+            ->databaseTransaction()
218
+            ->successNotificationTitle('Estimate Approved')
219
+            ->action(function (self $record, MountableAction $action) {
220
+                $record->approveDraft();
221
+
222
+                $action->success();
223
+            });
224
+    }
225
+
226
+    public static function getMarkAsSentAction(string $action = Action::class): MountableAction
227
+    {
228
+        return $action::make('markAsSent')
229
+            ->label('Mark as Sent')
230
+            ->icon('heroicon-o-paper-airplane')
231
+            ->visible(static function (self $record) {
232
+                return ! $record->isSent();
233
+            })
234
+            ->successNotificationTitle('Estimate Sent')
235
+            ->action(function (self $record, MountableAction $action) {
236
+                $record->markAsSent();
237
+
238
+                $action->success();
239
+            });
240
+    }
241
+
242
+    public function markAsSent(?Carbon $sentAt = null): void
243
+    {
244
+        $sentAt ??= now();
245
+
246
+        $this->update([
247
+            'status' => EstimateStatus::Sent,
248
+            'last_sent_at' => $sentAt,
249
+        ]);
250
+    }
251
+
252
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
253
+    {
254
+        return $action::make()
255
+            ->excludeAttributes([
256
+                'estimate_number',
257
+                'date',
258
+                'expiration_date',
259
+                'approved_at',
260
+                'accepted_at',
261
+                'declined_at',
262
+                'last_sent_at',
263
+                'last_viewed_at',
264
+                'status',
265
+                'created_by',
266
+                'updated_by',
267
+                'created_at',
268
+                'updated_at',
269
+            ])
270
+            ->modal(false)
271
+            ->beforeReplicaSaved(function (self $original, self $replica) {
272
+                $replica->status = EstimateStatus::Draft;
273
+                $replica->estimate_number = self::getNextDocumentNumber();
274
+                $replica->date = now();
275
+                $replica->expiration_date = now()->addDays($original->company->defaultInvoice->payment_terms->getDays());
276
+            })
277
+            ->databaseTransaction()
278
+            ->after(function (self $original, self $replica) {
279
+                $original->replicateLineItems($replica);
280
+            })
281
+            ->successRedirectUrl(static function (self $replica) {
282
+                return EstimateResource::getUrl('edit', ['record' => $replica]);
283
+            });
284
+    }
285
+
286
+    public static function getMarkAsAcceptedAction(string $action = Action::class): MountableAction
287
+    {
288
+        return $action::make('markAsAccepted')
289
+            ->label('Mark as Accepted')
290
+            ->icon('heroicon-o-check-badge')
291
+            ->visible(static function (self $record) {
292
+                return $record->isSent() && ! $record->isAccepted();
293
+            })
294
+            ->databaseTransaction()
295
+            ->successNotificationTitle('Estimate Accepted')
296
+            ->action(function (self $record, MountableAction $action) {
297
+                $record->markAsAccepted();
298
+
299
+                $action->success();
300
+            });
301
+    }
302
+
303
+    public function markAsAccepted(?Carbon $acceptedAt = null): void
304
+    {
305
+        $acceptedAt ??= now();
306
+
307
+        $this->update([
308
+            'status' => EstimateStatus::Accepted,
309
+            'accepted_at' => $acceptedAt,
310
+        ]);
311
+    }
312
+
313
+    public static function getMarkAsDeclinedAction(string $action = Action::class): MountableAction
314
+    {
315
+        return $action::make('markAsDeclined')
316
+            ->label('Mark as Declined')
317
+            ->icon('heroicon-o-x-circle')
318
+            ->visible(static function (self $record) {
319
+                return $record->isSent() && ! $record->isDeclined();
320
+            })
321
+            ->color('danger')
322
+            ->requiresConfirmation()
323
+            ->databaseTransaction()
324
+            ->successNotificationTitle('Estimate Declined')
325
+            ->action(function (self $record, MountableAction $action) {
326
+                $record->markAsDeclined();
327
+
328
+                $action->success();
329
+            });
330
+    }
331
+
332
+    public function markAsDeclined(?Carbon $declinedAt = null): void
333
+    {
334
+        $declinedAt ??= now();
335
+
336
+        $this->update([
337
+            'status' => EstimateStatus::Declined,
338
+            'declined_at' => $declinedAt,
339
+        ]);
340
+    }
341
+
342
+    public static function getConvertToInvoiceAction(string $action = Action::class): MountableAction
343
+    {
344
+        return $action::make('convertToInvoice')
345
+            ->label('Convert to Invoice')
346
+            ->icon('heroicon-o-arrow-right-on-rectangle')
347
+            ->visible(static function (self $record) {
348
+                return $record->status === EstimateStatus::Accepted && ! $record->invoice;
349
+            })
350
+            ->databaseTransaction()
351
+            ->successNotificationTitle('Estimate Converted to Invoice')
352
+            ->action(function (self $record, MountableAction $action) {
353
+                $record->convertToInvoice();
354
+
355
+                $action->success();
356
+            })
357
+            ->successRedirectUrl(static function (self $record) {
358
+                return InvoiceResource::getUrl('edit', ['record' => $record->refresh()->invoice]);
359
+            });
360
+    }
361
+
362
+    public function convertToInvoice(?Carbon $convertedAt = null): void
363
+    {
364
+        if ($this->invoice) {
365
+            throw new \RuntimeException('Estimate has already been converted to an invoice.');
366
+        }
367
+
368
+        $invoice = $this->invoice()->create([
369
+            'company_id' => $this->company_id,
370
+            'client_id' => $this->client_id,
371
+            'logo' => $this->logo,
372
+            'header' => $this->company->defaultInvoice->header,
373
+            'subheader' => $this->company->defaultInvoice->subheader,
374
+            'invoice_number' => Invoice::getNextDocumentNumber($this->company),
375
+            'date' => now(),
376
+            'due_date' => now()->addDays($this->company->defaultInvoice->payment_terms->getDays()),
377
+            'status' => InvoiceStatus::Draft,
378
+            'currency_code' => $this->currency_code,
379
+            'discount_method' => $this->discount_method,
380
+            'discount_computation' => $this->discount_computation,
381
+            'discount_rate' => $this->discount_rate,
382
+            'subtotal' => $this->subtotal,
383
+            'tax_total' => $this->tax_total,
384
+            'discount_total' => $this->discount_total,
385
+            'total' => $this->total,
386
+            'terms' => $this->terms,
387
+            'footer' => $this->footer,
388
+            'created_by' => auth()->id(),
389
+            'updated_by' => auth()->id(),
390
+        ]);
391
+
392
+        $this->replicateLineItems($invoice);
393
+
394
+        $convertedAt ??= now();
395
+
396
+        $this->update([
397
+            'status' => EstimateStatus::Converted,
398
+            'converted_at' => $convertedAt,
399
+        ]);
400
+    }
401
+
402
+    public function replicateLineItems(Model $target): void
403
+    {
404
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
405
+            $replica = $lineItem->replicate([
406
+                'documentable_id',
407
+                'documentable_type',
408
+                'subtotal',
409
+                'total',
410
+                'created_by',
411
+                'updated_by',
412
+                'created_at',
413
+                'updated_at',
414
+            ]);
415
+
416
+            $replica->documentable_id = $target->id;
417
+            $replica->documentable_type = $target->getMorphClass();
418
+            $replica->save();
419
+
420
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
421
+        });
422
+    }
192 423
 }

+ 21
- 7
app/Models/Accounting/Invoice.php Ver arquivo

@@ -15,6 +15,7 @@ use App\Enums\Accounting\TransactionType;
15 15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
16 16
 use App\Models\Banking\BankAccount;
17 17
 use App\Models\Common\Client;
18
+use App\Models\Company;
18 19
 use App\Models\Setting\Currency;
19 20
 use App\Observers\InvoiceObserver;
20 21
 use App\Utilities\Currency\CurrencyAccessor;
@@ -33,8 +34,8 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
33 34
 use Illuminate\Database\Eloquent\Relations\MorphOne;
34 35
 use Illuminate\Support\Carbon;
35 36
 
36
-#[ObservedBy(InvoiceObserver::class)]
37 37
 #[CollectedBy(DocumentCollection::class)]
38
+#[ObservedBy(InvoiceObserver::class)]
38 39
 class Invoice extends Model
39 40
 {
40 41
     use Blamable;
@@ -46,6 +47,7 @@ class Invoice extends Model
46 47
     protected $fillable = [
47 48
         'company_id',
48 49
         'client_id',
50
+        'estimate_id',
49 51
         'logo',
50 52
         'header',
51 53
         'subheader',
@@ -100,6 +102,11 @@ class Invoice extends Model
100 102
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
101 103
     }
102 104
 
105
+    public function estimate(): BelongsTo
106
+    {
107
+        return $this->belongsTo(Estimate::class);
108
+    }
109
+
103 110
     public function lineItems(): MorphMany
104 111
     {
105 112
         return $this->morphMany(DocumentLineItem::class, 'documentable');
@@ -182,9 +189,9 @@ class Invoice extends Model
182 189
         return $this->payments->isNotEmpty();
183 190
     }
184 191
 
185
-    public static function getNextDocumentNumber(): string
192
+    public static function getNextDocumentNumber(?Company $company = null): string
186 193
     {
187
-        $company = auth()->user()->currentCompany;
194
+        $company ??= auth()->user()?->currentCompany;
188 195
 
189 196
         if (! $company) {
190 197
             throw new \RuntimeException('No current company is set for the user.');
@@ -412,15 +419,22 @@ class Invoice extends Model
412 419
             })
413 420
             ->successNotificationTitle('Invoice Sent')
414 421
             ->action(function (self $record, MountableAction $action) {
415
-                $record->update([
416
-                    'status' => InvoiceStatus::Sent,
417
-                    'last_sent_at' => now(),
418
-                ]);
422
+                $record->markAsSent();
419 423
 
420 424
                 $action->success();
421 425
             });
422 426
     }
423 427
 
428
+    public function markAsSent(?Carbon $sentAt = null): void
429
+    {
430
+        $sentAt ??= now();
431
+
432
+        $this->update([
433
+            'status' => InvoiceStatus::Sent,
434
+            'last_sent_at' => $sentAt,
435
+        ]);
436
+    }
437
+
424 438
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
425 439
     {
426 440
         return $action::make()

+ 5
- 0
app/Models/Company.php Ver arquivo

@@ -142,6 +142,11 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
142 142
         return $this->hasMany(Department::class, 'company_id');
143 143
     }
144 144
 
145
+    public function estimates(): HasMany
146
+    {
147
+        return $this->hasMany(Accounting\Estimate::class, 'company_id');
148
+    }
149
+
145 150
     public function invoices(): HasMany
146 151
     {
147 152
         return $this->hasMany(Accounting\Invoice::class, 'company_id');

+ 27
- 0
app/Observers/EstimateObserver.php Ver arquivo

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Observers;
4
+
5
+use App\Enums\Accounting\EstimateStatus;
6
+use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\Estimate;
8
+use Illuminate\Support\Facades\DB;
9
+
10
+class EstimateObserver
11
+{
12
+    public function saving(Estimate $estimate): void
13
+    {
14
+        if ($estimate->approved_at && $estimate->is_currently_expired) {
15
+            $estimate->status = EstimateStatus::Expired;
16
+        }
17
+    }
18
+
19
+    public function deleted(Estimate $estimate): void
20
+    {
21
+        DB::transaction(function () use ($estimate) {
22
+            $estimate->lineItems()->each(function (DocumentLineItem $lineItem) {
23
+                $lineItem->delete();
24
+            });
25
+        });
26
+    }
27
+}

+ 0
- 3
app/Observers/InvoiceObserver.php Ver arquivo

@@ -17,9 +17,6 @@ class InvoiceObserver
17 17
         }
18 18
     }
19 19
 
20
-    /**
21
-     * Handle the Invoice "deleted" event.
22
-     */
23 20
     public function deleted(Invoice $invoice): void
24 21
     {
25 22
         DB::transaction(function () use ($invoice) {

+ 70
- 0
app/Policies/EstimatePolicy.php Ver arquivo

@@ -0,0 +1,70 @@
1
+<?php
2
+
3
+namespace App\Policies;
4
+
5
+use App\Enums\Accounting\EstimateStatus;
6
+use App\Models\Accounting\Estimate;
7
+use App\Models\User;
8
+
9
+class EstimatePolicy
10
+{
11
+    /**
12
+     * Determine whether the user can view any models.
13
+     */
14
+    public function viewAny(User $user): bool
15
+    {
16
+        return true;
17
+    }
18
+
19
+    /**
20
+     * Determine whether the user can view the model.
21
+     */
22
+    public function view(User $user, Estimate $estimate): bool
23
+    {
24
+        return $user->belongsToCompany($estimate->company);
25
+    }
26
+
27
+    /**
28
+     * Determine whether the user can create models.
29
+     */
30
+    public function create(User $user): bool
31
+    {
32
+        return true;
33
+    }
34
+
35
+    /**
36
+     * Determine whether the user can update the model.
37
+     */
38
+    public function update(User $user, Estimate $estimate): bool
39
+    {
40
+        if ($estimate->status === EstimateStatus::Converted) {
41
+            return false;
42
+        }
43
+
44
+        return $user->belongsToCompany($estimate->company);
45
+    }
46
+
47
+    /**
48
+     * Determine whether the user can delete the model.
49
+     */
50
+    public function delete(User $user, Estimate $estimate): bool
51
+    {
52
+        return $user->belongsToCompany($estimate->company);
53
+    }
54
+
55
+    /**
56
+     * Determine whether the user can restore the model.
57
+     */
58
+    public function restore(User $user, Estimate $estimate): bool
59
+    {
60
+        return $user->belongsToCompany($estimate->company);
61
+    }
62
+
63
+    /**
64
+     * Determine whether the user can permanently delete the model.
65
+     */
66
+    public function forceDelete(User $user, Estimate $estimate): bool
67
+    {
68
+        return $user->belongsToCompany($estimate->company);
69
+    }
70
+}

+ 204
- 0
app/View/Models/DocumentPreviewViewModel.php Ver arquivo

@@ -0,0 +1,204 @@
1
+<?php
2
+
3
+namespace App\View\Models;
4
+
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
7
+use App\Enums\Accounting\DocumentType;
8
+use App\Models\Accounting\Adjustment;
9
+use App\Models\Accounting\DocumentLineItem;
10
+use App\Models\Common\Client;
11
+use App\Models\Company;
12
+use App\Models\Setting\DocumentDefault;
13
+use App\Utilities\Currency\CurrencyAccessor;
14
+use App\Utilities\Currency\CurrencyConverter;
15
+use App\Utilities\RateCalculator;
16
+use Illuminate\Database\Eloquent\Model;
17
+use Illuminate\Support\Number;
18
+
19
+class DocumentPreviewViewModel
20
+{
21
+    public function __construct(
22
+        public Model $document,
23
+        public DocumentType $documentType = DocumentType::Invoice,
24
+    ) {}
25
+
26
+    public function buildViewData(): array
27
+    {
28
+        return [
29
+            'company' => $this->getCompanyDetails(),
30
+            'client' => $this->getClientDetails(),
31
+            'metadata' => $this->getDocumentMetadata(),
32
+            'lineItems' => $this->getLineItems(),
33
+            'totals' => $this->getTotals(),
34
+            'header' => $this->document->header,
35
+            'footer' => $this->document->footer,
36
+            'terms' => $this->document->terms,
37
+            'logo' => $this->document->logo,
38
+            'style' => $this->getStyle(),
39
+            'labels' => $this->documentType->getLabels(),
40
+        ];
41
+    }
42
+
43
+    private function getCompanyDetails(): array
44
+    {
45
+        /** @var Company $company */
46
+        $company = $this->document->company;
47
+        $profile = $company->profile;
48
+
49
+        return [
50
+            'name' => $company->name,
51
+            'address' => $profile->address ?? '',
52
+            'city' => $profile->city?->name ?? '',
53
+            'state' => $profile->state?->name ?? '',
54
+            'zip_code' => $profile->zip_code ?? '',
55
+            'country' => $profile->state?->country->name ?? '',
56
+        ];
57
+    }
58
+
59
+    private function getClientDetails(): array
60
+    {
61
+        /** @var Client $client */
62
+        $client = $this->document->client;
63
+        $address = $client->billingAddress ?? null;
64
+
65
+        return [
66
+            'name' => $client->name,
67
+            'address_line_1' => $address->address_line_1 ?? '',
68
+            'address_line_2' => $address->address_line_2 ?? '',
69
+            'city' => $address->city ?? '',
70
+            'state' => $address->state ?? '',
71
+            'postal_code' => $address->postal_code ?? '',
72
+            'country' => $address->country ?? '',
73
+        ];
74
+    }
75
+
76
+    private function getDocumentMetadata(): array
77
+    {
78
+        return [
79
+            'number' => $this->document->invoice_number ?? $this->document->estimate_number,
80
+            'reference_number' => $this->document->order_number ?? $this->document->reference_number,
81
+            'date' => $this->document->date?->toDefaultDateFormat(),
82
+            'due_date' => $this->document->due_date?->toDefaultDateFormat() ?? $this->document->expiration_date?->toDefaultDateFormat(),
83
+            'currency_code' => $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
84
+        ];
85
+    }
86
+
87
+    private function getLineItems(): array
88
+    {
89
+        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
90
+
91
+        return $this->document->lineItems->map(fn (DocumentLineItem $item) => [
92
+            'name' => $item->offering->name ?? '',
93
+            'description' => $item->description ?? '',
94
+            'quantity' => $item->quantity,
95
+            'unit_price' => CurrencyConverter::formatToMoney($item->unit_price, $currencyCode),
96
+            'subtotal' => CurrencyConverter::formatToMoney($item->subtotal, $currencyCode),
97
+        ])->toArray();
98
+    }
99
+
100
+    private function getTotals(): array
101
+    {
102
+        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
103
+
104
+        return [
105
+            'subtotal' => CurrencyConverter::formatToMoney($this->document->subtotal, $currencyCode),
106
+            'discount' => CurrencyConverter::formatToMoney($this->document->discount_total, $currencyCode),
107
+            'tax' => CurrencyConverter::formatToMoney($this->document->tax_total, $currencyCode),
108
+            'total' => CurrencyConverter::formatToMoney($this->document->total, $currencyCode),
109
+            'amount_due' => $this->document->amount_due ? CurrencyConverter::formatToMoney($this->document->amount_due, $currencyCode) : null,
110
+        ];
111
+    }
112
+
113
+    private function getStyle(): array
114
+    {
115
+        /** @var DocumentDefault $settings */
116
+        $settings = $this->document->company->defaultInvoice;
117
+
118
+        return [
119
+            'accent_color' => $settings->accent_color ?? '#000000',
120
+            'show_logo' => $settings->show_logo ?? false,
121
+        ];
122
+    }
123
+
124
+    private function calculateLineSubtotalInCents(array $item, string $currencyCode): int
125
+    {
126
+        $quantity = max((float) ($item['quantity'] ?? 0), 0);
127
+        $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
128
+
129
+        $subtotal = $quantity * $unitPrice;
130
+
131
+        return CurrencyConverter::convertToCents($subtotal, $currencyCode);
132
+    }
133
+
134
+    private function calculateAdjustmentsTotalInCents($lineItems, string $key, string $currencyCode): int
135
+    {
136
+        return $lineItems->reduce(function ($carry, $item) use ($key, $currencyCode) {
137
+            $quantity = max((float) ($item['quantity'] ?? 0), 0);
138
+            $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
139
+            $adjustmentIds = $item[$key] ?? [];
140
+            $lineTotal = $quantity * $unitPrice;
141
+
142
+            $lineTotalInCents = CurrencyConverter::convertToCents($lineTotal, $currencyCode);
143
+
144
+            $adjustmentTotal = Adjustment::whereIn('id', $adjustmentIds)
145
+                ->get()
146
+                ->sum(function (Adjustment $adjustment) use ($lineTotalInCents) {
147
+                    if ($adjustment->computation->isPercentage()) {
148
+                        return RateCalculator::calculatePercentage($lineTotalInCents, $adjustment->getRawOriginal('rate'));
149
+                    } else {
150
+                        return $adjustment->getRawOriginal('rate');
151
+                    }
152
+                });
153
+
154
+            return $carry + $adjustmentTotal;
155
+        }, 0);
156
+    }
157
+
158
+    private function calculateDiscountTotalInCents($lineItems, int $subtotalInCents, string $currencyCode): int
159
+    {
160
+        $discountMethod = DocumentDiscountMethod::parse($this->data['discount_method']) ?? DocumentDiscountMethod::PerLineItem;
161
+
162
+        if ($discountMethod->isPerLineItem()) {
163
+            return $this->calculateAdjustmentsTotalInCents($lineItems, $this->documentType->getDiscountKey(), $currencyCode);
164
+        }
165
+
166
+        $discountComputation = AdjustmentComputation::parse($this->data['discount_computation']) ?? AdjustmentComputation::Percentage;
167
+        $discountRate = blank($this->data['discount_rate']) ? '0' : $this->data['discount_rate'];
168
+
169
+        if ($discountComputation->isPercentage()) {
170
+            $scaledDiscountRate = RateCalculator::parseLocalizedRate($discountRate);
171
+
172
+            return RateCalculator::calculatePercentage($subtotalInCents, $scaledDiscountRate);
173
+        }
174
+
175
+        if (! CurrencyConverter::isValidAmount($discountRate)) {
176
+            $discountRate = '0';
177
+        }
178
+
179
+        return CurrencyConverter::convertToCents($discountRate, $currencyCode);
180
+    }
181
+
182
+    private function buildConversionMessage(int $grandTotalInCents, string $currencyCode, string $defaultCurrencyCode): ?string
183
+    {
184
+        if ($currencyCode === $defaultCurrencyCode) {
185
+            return null;
186
+        }
187
+
188
+        $rate = currency($currencyCode)->getRate();
189
+        $indirectRate = 1 / $rate;
190
+
191
+        $convertedTotalInCents = CurrencyConverter::convertBalance($grandTotalInCents, $currencyCode, $defaultCurrencyCode);
192
+
193
+        $formattedRate = Number::format($indirectRate, maxPrecision: 10);
194
+
195
+        return sprintf(
196
+            'Currency conversion: %s (%s) at an exchange rate of 1 %s = %s %s',
197
+            CurrencyConverter::formatCentsToMoney($convertedTotalInCents, $defaultCurrencyCode),
198
+            $defaultCurrencyCode,
199
+            $currencyCode,
200
+            $formattedRate,
201
+            $defaultCurrencyCode
202
+        );
203
+    }
204
+}

+ 39
- 39
composer.lock Ver arquivo

@@ -497,16 +497,16 @@
497 497
         },
498 498
         {
499 499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.2",
500
+            "version": "3.336.4",
501 501
             "source": {
502 502
                 "type": "git",
503 503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4"
504
+                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc"
505 505
             },
506 506
             "dist": {
507 507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/954bfdfc048840ca34afe2a2e1cbcff6681989c4",
509
-                "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
509
+                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc",
510 510
                 "shasum": ""
511 511
             },
512 512
             "require": {
@@ -589,9 +589,9 @@
589 589
             "support": {
590 590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591 591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
592
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.2"
592
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.4"
593 593
             },
594
-            "time": "2024-12-20T19:05:10+00:00"
594
+            "time": "2024-12-26T19:13:21+00:00"
595 595
         },
596 596
         {
597 597
             "name": "aws/aws-sdk-php-laravel",
@@ -1595,16 +1595,16 @@
1595 1595
         },
1596 1596
         {
1597 1597
             "name": "egulias/email-validator",
1598
-            "version": "4.0.2",
1598
+            "version": "4.0.3",
1599 1599
             "source": {
1600 1600
                 "type": "git",
1601 1601
                 "url": "https://github.com/egulias/EmailValidator.git",
1602
-                "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e"
1602
+                "reference": "b115554301161fa21467629f1e1391c1936de517"
1603 1603
             },
1604 1604
             "dist": {
1605 1605
                 "type": "zip",
1606
-                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e",
1607
-                "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e",
1606
+                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517",
1607
+                "reference": "b115554301161fa21467629f1e1391c1936de517",
1608 1608
                 "shasum": ""
1609 1609
             },
1610 1610
             "require": {
@@ -1650,7 +1650,7 @@
1650 1650
             ],
1651 1651
             "support": {
1652 1652
                 "issues": "https://github.com/egulias/EmailValidator/issues",
1653
-                "source": "https://github.com/egulias/EmailValidator/tree/4.0.2"
1653
+                "source": "https://github.com/egulias/EmailValidator/tree/4.0.3"
1654 1654
             },
1655 1655
             "funding": [
1656 1656
                 {
@@ -1658,7 +1658,7 @@
1658 1658
                     "type": "github"
1659 1659
                 }
1660 1660
             ],
1661
-            "time": "2023-10-06T06:47:41+00:00"
1661
+            "time": "2024-12-27T00:36:43+00:00"
1662 1662
         },
1663 1663
         {
1664 1664
             "name": "filament/actions",
@@ -4666,16 +4666,16 @@
4666 4666
         },
4667 4667
         {
4668 4668
             "name": "nesbot/carbon",
4669
-            "version": "3.8.3",
4669
+            "version": "3.8.4",
4670 4670
             "source": {
4671 4671
                 "type": "git",
4672 4672
                 "url": "https://github.com/briannesbitt/Carbon.git",
4673
-                "reference": "f01cfa96468f4c38325f507ab81a4f1d2cd93cfe"
4673
+                "reference": "129700ed449b1f02d70272d2ac802357c8c30c58"
4674 4674
             },
4675 4675
             "dist": {
4676 4676
                 "type": "zip",
4677
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/f01cfa96468f4c38325f507ab81a4f1d2cd93cfe",
4678
-                "reference": "f01cfa96468f4c38325f507ab81a4f1d2cd93cfe",
4677
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58",
4678
+                "reference": "129700ed449b1f02d70272d2ac802357c8c30c58",
4679 4679
                 "shasum": ""
4680 4680
             },
4681 4681
             "require": {
@@ -4768,7 +4768,7 @@
4768 4768
                     "type": "tidelift"
4769 4769
                 }
4770 4770
             ],
4771
-            "time": "2024-12-21T18:03:19+00:00"
4771
+            "time": "2024-12-27T09:25:35+00:00"
4772 4772
         },
4773 4773
         {
4774 4774
             "name": "nette/schema",
@@ -6303,16 +6303,16 @@
6303 6303
         },
6304 6304
         {
6305 6305
             "name": "spatie/color",
6306
-            "version": "1.6.2",
6306
+            "version": "1.6.3",
6307 6307
             "source": {
6308 6308
                 "type": "git",
6309 6309
                 "url": "https://github.com/spatie/color.git",
6310
-                "reference": "b4fac074a9e5999dcca12cbfab0f7c73e2684d6d"
6310
+                "reference": "45c4354ffa7e65f05c502b009834ecac7928daa3"
6311 6311
             },
6312 6312
             "dist": {
6313 6313
                 "type": "zip",
6314
-                "url": "https://api.github.com/repos/spatie/color/zipball/b4fac074a9e5999dcca12cbfab0f7c73e2684d6d",
6315
-                "reference": "b4fac074a9e5999dcca12cbfab0f7c73e2684d6d",
6314
+                "url": "https://api.github.com/repos/spatie/color/zipball/45c4354ffa7e65f05c502b009834ecac7928daa3",
6315
+                "reference": "45c4354ffa7e65f05c502b009834ecac7928daa3",
6316 6316
                 "shasum": ""
6317 6317
             },
6318 6318
             "require": {
@@ -6350,7 +6350,7 @@
6350 6350
             ],
6351 6351
             "support": {
6352 6352
                 "issues": "https://github.com/spatie/color/issues",
6353
-                "source": "https://github.com/spatie/color/tree/1.6.2"
6353
+                "source": "https://github.com/spatie/color/tree/1.6.3"
6354 6354
             },
6355 6355
             "funding": [
6356 6356
                 {
@@ -6358,7 +6358,7 @@
6358 6358
                     "type": "github"
6359 6359
                 }
6360 6360
             ],
6361
-            "time": "2024-12-09T16:20:38+00:00"
6361
+            "time": "2024-12-23T11:00:34+00:00"
6362 6362
         },
6363 6363
         {
6364 6364
             "name": "spatie/invade",
@@ -6839,12 +6839,12 @@
6839 6839
             },
6840 6840
             "type": "library",
6841 6841
             "extra": {
6842
+                "thanks": {
6843
+                    "url": "https://github.com/symfony/contracts",
6844
+                    "name": "symfony/contracts"
6845
+                },
6842 6846
                 "branch-alias": {
6843 6847
                     "dev-main": "3.5-dev"
6844
-                },
6845
-                "thanks": {
6846
-                    "name": "symfony/contracts",
6847
-                    "url": "https://github.com/symfony/contracts"
6848 6848
                 }
6849 6849
             },
6850 6850
             "autoload": {
@@ -7062,12 +7062,12 @@
7062 7062
             },
7063 7063
             "type": "library",
7064 7064
             "extra": {
7065
+                "thanks": {
7066
+                    "url": "https://github.com/symfony/contracts",
7067
+                    "name": "symfony/contracts"
7068
+                },
7065 7069
                 "branch-alias": {
7066 7070
                     "dev-main": "3.5-dev"
7067
-                },
7068
-                "thanks": {
7069
-                    "name": "symfony/contracts",
7070
-                    "url": "https://github.com/symfony/contracts"
7071 7071
                 }
7072 7072
             },
7073 7073
             "autoload": {
@@ -8492,12 +8492,12 @@
8492 8492
             },
8493 8493
             "type": "library",
8494 8494
             "extra": {
8495
+                "thanks": {
8496
+                    "url": "https://github.com/symfony/contracts",
8497
+                    "name": "symfony/contracts"
8498
+                },
8495 8499
                 "branch-alias": {
8496 8500
                     "dev-main": "3.5-dev"
8497
-                },
8498
-                "thanks": {
8499
-                    "name": "symfony/contracts",
8500
-                    "url": "https://github.com/symfony/contracts"
8501 8501
                 }
8502 8502
             },
8503 8503
             "autoload": {
@@ -8752,12 +8752,12 @@
8752 8752
             },
8753 8753
             "type": "library",
8754 8754
             "extra": {
8755
+                "thanks": {
8756
+                    "url": "https://github.com/symfony/contracts",
8757
+                    "name": "symfony/contracts"
8758
+                },
8755 8759
                 "branch-alias": {
8756 8760
                     "dev-main": "3.5-dev"
8757
-                },
8758
-                "thanks": {
8759
-                    "name": "symfony/contracts",
8760
-                    "url": "https://github.com/symfony/contracts"
8761 8761
                 }
8762 8762
             },
8763 8763
             "autoload": {

+ 17
- 12
database/factories/Accounting/EstimateFactory.php Ver arquivo

@@ -62,10 +62,7 @@ class EstimateFactory extends Factory
62 62
 
63 63
             $approvedAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
64 64
 
65
-            $estimate->update([
66
-                'status' => EstimateStatus::Unsent,
67
-                'approved_at' => $approvedAt,
68
-            ]);
65
+            $estimate->approveDraft($approvedAt);
69 66
         });
70 67
     }
71 68
 
@@ -79,10 +76,21 @@ class EstimateFactory extends Factory
79 76
             $acceptedAt = Carbon::parse($estimate->approved_at)
80 77
                 ->addDays($this->faker->numberBetween(1, 7));
81 78
 
82
-            $estimate->update([
83
-                'status' => EstimateStatus::Accepted,
84
-                'accepted_at' => $acceptedAt,
85
-            ]);
79
+            $estimate->markAsAccepted($acceptedAt);
80
+        });
81
+    }
82
+
83
+    public function converted(): static
84
+    {
85
+        return $this->afterCreating(function (Estimate $estimate) {
86
+            if (! $estimate->isAccepted()) {
87
+                $this->accepted()->create();
88
+            }
89
+
90
+            $convertedAt = Carbon::parse($estimate->accepted_at)
91
+                ->addDays($this->faker->numberBetween(1, 7));
92
+
93
+            $estimate->convertToInvoice($convertedAt);
86 94
         });
87 95
     }
88 96
 
@@ -114,10 +122,7 @@ class EstimateFactory extends Factory
114 122
 
115 123
             $sentAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
116 124
 
117
-            $estimate->update([
118
-                'status' => EstimateStatus::Sent,
119
-                'last_sent_at' => $sentAt,
120
-            ]);
125
+            $estimate->markAsSent($sentAt);
121 126
         });
122 127
     }
123 128
 

+ 15
- 2
database/factories/CompanyFactory.php Ver arquivo

@@ -170,8 +170,9 @@ class CompanyFactory extends Factory
170 170
             $draftCount = (int) floor($count * 0.2);     // 20% drafts
171 171
             $approvedCount = (int) floor($count * 0.3);   // 30% approved
172 172
             $acceptedCount = (int) floor($count * 0.2);  // 20% accepted
173
-            $declinedCount = (int) floor($count * 0.2);  // 20% declined
174
-            $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount); // remaining as expired
173
+            $declinedCount = (int) floor($count * 0.1);  // 10% declined
174
+            $convertedCount = (int) floor($count * 0.1); // 10% converted to invoices
175
+            $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount + $convertedCount); // remaining 10%
175 176
 
176 177
             // Create draft estimates
177 178
             Estimate::factory()
@@ -216,6 +217,18 @@ class CompanyFactory extends Factory
216 217
                     'updated_by' => $company->user_id,
217 218
                 ]);
218 219
 
220
+            // Create converted estimates
221
+            Estimate::factory()
222
+                ->count($convertedCount)
223
+                ->withLineItems()
224
+                ->accepted()
225
+                ->converted()
226
+                ->create([
227
+                    'company_id' => $company->id,
228
+                    'created_by' => $company->user_id,
229
+                    'updated_by' => $company->user_id,
230
+                ]);
231
+
219 232
             // Create expired estimates (approved but past expiration date)
220 233
             Estimate::factory()
221 234
                 ->count($expiredCount)

database/migrations/2024_12_22_140044_create_estimates_table.php → database/migrations/2024_11_27_223000_create_estimates_table.php Ver arquivo

@@ -24,6 +24,7 @@ return new class extends Migration
24 24
             $table->date('expiration_date')->nullable();
25 25
             $table->timestamp('approved_at')->nullable();
26 26
             $table->timestamp('accepted_at')->nullable();
27
+            $table->timestamp('converted_at')->nullable();
27 28
             $table->timestamp('declined_at')->nullable();
28 29
             $table->timestamp('last_sent_at')->nullable();
29 30
             $table->timestamp('last_viewed_at')->nullable();

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php Ver arquivo

@@ -15,6 +15,7 @@ return new class extends Migration
15 15
             $table->id();
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->foreignId('estimate_id')->nullable()->constrained('estimates')->nullOnDelete();
18 19
             $table->string('logo')->nullable();
19 20
             $table->string('header')->nullable();
20 21
             $table->string('subheader')->nullable();

+ 127
- 109
package-lock.json Ver arquivo

@@ -30,9 +30,9 @@
30 30
             }
31 31
         },
32 32
         "node_modules/@esbuild/aix-ppc64": {
33
-            "version": "0.24.0",
34
-            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
35
-            "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==",
33
+            "version": "0.24.2",
34
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
35
+            "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
36 36
             "cpu": [
37 37
                 "ppc64"
38 38
             ],
@@ -47,9 +47,9 @@
47 47
             }
48 48
         },
49 49
         "node_modules/@esbuild/android-arm": {
50
-            "version": "0.24.0",
51
-            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz",
52
-            "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==",
50
+            "version": "0.24.2",
51
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
52
+            "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
53 53
             "cpu": [
54 54
                 "arm"
55 55
             ],
@@ -64,9 +64,9 @@
64 64
             }
65 65
         },
66 66
         "node_modules/@esbuild/android-arm64": {
67
-            "version": "0.24.0",
68
-            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz",
69
-            "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==",
67
+            "version": "0.24.2",
68
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
69
+            "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
70 70
             "cpu": [
71 71
                 "arm64"
72 72
             ],
@@ -81,9 +81,9 @@
81 81
             }
82 82
         },
83 83
         "node_modules/@esbuild/android-x64": {
84
-            "version": "0.24.0",
85
-            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz",
86
-            "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==",
84
+            "version": "0.24.2",
85
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
86
+            "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
87 87
             "cpu": [
88 88
                 "x64"
89 89
             ],
@@ -98,9 +98,9 @@
98 98
             }
99 99
         },
100 100
         "node_modules/@esbuild/darwin-arm64": {
101
-            "version": "0.24.0",
102
-            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz",
103
-            "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==",
101
+            "version": "0.24.2",
102
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
103
+            "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
104 104
             "cpu": [
105 105
                 "arm64"
106 106
             ],
@@ -115,9 +115,9 @@
115 115
             }
116 116
         },
117 117
         "node_modules/@esbuild/darwin-x64": {
118
-            "version": "0.24.0",
119
-            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz",
120
-            "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==",
118
+            "version": "0.24.2",
119
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
120
+            "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
121 121
             "cpu": [
122 122
                 "x64"
123 123
             ],
@@ -132,9 +132,9 @@
132 132
             }
133 133
         },
134 134
         "node_modules/@esbuild/freebsd-arm64": {
135
-            "version": "0.24.0",
136
-            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz",
137
-            "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==",
135
+            "version": "0.24.2",
136
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
137
+            "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
138 138
             "cpu": [
139 139
                 "arm64"
140 140
             ],
@@ -149,9 +149,9 @@
149 149
             }
150 150
         },
151 151
         "node_modules/@esbuild/freebsd-x64": {
152
-            "version": "0.24.0",
153
-            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz",
154
-            "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==",
152
+            "version": "0.24.2",
153
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
154
+            "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
155 155
             "cpu": [
156 156
                 "x64"
157 157
             ],
@@ -166,9 +166,9 @@
166 166
             }
167 167
         },
168 168
         "node_modules/@esbuild/linux-arm": {
169
-            "version": "0.24.0",
170
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz",
171
-            "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==",
169
+            "version": "0.24.2",
170
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
171
+            "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
172 172
             "cpu": [
173 173
                 "arm"
174 174
             ],
@@ -183,9 +183,9 @@
183 183
             }
184 184
         },
185 185
         "node_modules/@esbuild/linux-arm64": {
186
-            "version": "0.24.0",
187
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz",
188
-            "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==",
186
+            "version": "0.24.2",
187
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
188
+            "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
189 189
             "cpu": [
190 190
                 "arm64"
191 191
             ],
@@ -200,9 +200,9 @@
200 200
             }
201 201
         },
202 202
         "node_modules/@esbuild/linux-ia32": {
203
-            "version": "0.24.0",
204
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz",
205
-            "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==",
203
+            "version": "0.24.2",
204
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
205
+            "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
206 206
             "cpu": [
207 207
                 "ia32"
208 208
             ],
@@ -217,9 +217,9 @@
217 217
             }
218 218
         },
219 219
         "node_modules/@esbuild/linux-loong64": {
220
-            "version": "0.24.0",
221
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz",
222
-            "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==",
220
+            "version": "0.24.2",
221
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
222
+            "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
223 223
             "cpu": [
224 224
                 "loong64"
225 225
             ],
@@ -234,9 +234,9 @@
234 234
             }
235 235
         },
236 236
         "node_modules/@esbuild/linux-mips64el": {
237
-            "version": "0.24.0",
238
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz",
239
-            "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==",
237
+            "version": "0.24.2",
238
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
239
+            "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
240 240
             "cpu": [
241 241
                 "mips64el"
242 242
             ],
@@ -251,9 +251,9 @@
251 251
             }
252 252
         },
253 253
         "node_modules/@esbuild/linux-ppc64": {
254
-            "version": "0.24.0",
255
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz",
256
-            "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==",
254
+            "version": "0.24.2",
255
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
256
+            "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
257 257
             "cpu": [
258 258
                 "ppc64"
259 259
             ],
@@ -268,9 +268,9 @@
268 268
             }
269 269
         },
270 270
         "node_modules/@esbuild/linux-riscv64": {
271
-            "version": "0.24.0",
272
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz",
273
-            "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==",
271
+            "version": "0.24.2",
272
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
273
+            "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
274 274
             "cpu": [
275 275
                 "riscv64"
276 276
             ],
@@ -285,9 +285,9 @@
285 285
             }
286 286
         },
287 287
         "node_modules/@esbuild/linux-s390x": {
288
-            "version": "0.24.0",
289
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz",
290
-            "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==",
288
+            "version": "0.24.2",
289
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
290
+            "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
291 291
             "cpu": [
292 292
                 "s390x"
293 293
             ],
@@ -302,9 +302,9 @@
302 302
             }
303 303
         },
304 304
         "node_modules/@esbuild/linux-x64": {
305
-            "version": "0.24.0",
306
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz",
307
-            "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==",
305
+            "version": "0.24.2",
306
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
307
+            "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
308 308
             "cpu": [
309 309
                 "x64"
310 310
             ],
@@ -318,10 +318,27 @@
318 318
                 "node": ">=18"
319 319
             }
320 320
         },
321
+        "node_modules/@esbuild/netbsd-arm64": {
322
+            "version": "0.24.2",
323
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
324
+            "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
325
+            "cpu": [
326
+                "arm64"
327
+            ],
328
+            "dev": true,
329
+            "license": "MIT",
330
+            "optional": true,
331
+            "os": [
332
+                "netbsd"
333
+            ],
334
+            "engines": {
335
+                "node": ">=18"
336
+            }
337
+        },
321 338
         "node_modules/@esbuild/netbsd-x64": {
322
-            "version": "0.24.0",
323
-            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz",
324
-            "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==",
339
+            "version": "0.24.2",
340
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
341
+            "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
325 342
             "cpu": [
326 343
                 "x64"
327 344
             ],
@@ -336,9 +353,9 @@
336 353
             }
337 354
         },
338 355
         "node_modules/@esbuild/openbsd-arm64": {
339
-            "version": "0.24.0",
340
-            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz",
341
-            "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==",
356
+            "version": "0.24.2",
357
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
358
+            "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
342 359
             "cpu": [
343 360
                 "arm64"
344 361
             ],
@@ -353,9 +370,9 @@
353 370
             }
354 371
         },
355 372
         "node_modules/@esbuild/openbsd-x64": {
356
-            "version": "0.24.0",
357
-            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz",
358
-            "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==",
373
+            "version": "0.24.2",
374
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
375
+            "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
359 376
             "cpu": [
360 377
                 "x64"
361 378
             ],
@@ -370,9 +387,9 @@
370 387
             }
371 388
         },
372 389
         "node_modules/@esbuild/sunos-x64": {
373
-            "version": "0.24.0",
374
-            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz",
375
-            "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==",
390
+            "version": "0.24.2",
391
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
392
+            "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
376 393
             "cpu": [
377 394
                 "x64"
378 395
             ],
@@ -387,9 +404,9 @@
387 404
             }
388 405
         },
389 406
         "node_modules/@esbuild/win32-arm64": {
390
-            "version": "0.24.0",
391
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz",
392
-            "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==",
407
+            "version": "0.24.2",
408
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
409
+            "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
393 410
             "cpu": [
394 411
                 "arm64"
395 412
             ],
@@ -404,9 +421,9 @@
404 421
             }
405 422
         },
406 423
         "node_modules/@esbuild/win32-ia32": {
407
-            "version": "0.24.0",
408
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz",
409
-            "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==",
424
+            "version": "0.24.2",
425
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
426
+            "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
410 427
             "cpu": [
411 428
                 "ia32"
412 429
             ],
@@ -421,9 +438,9 @@
421 438
             }
422 439
         },
423 440
         "node_modules/@esbuild/win32-x64": {
424
-            "version": "0.24.0",
425
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz",
426
-            "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==",
441
+            "version": "0.24.2",
442
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
443
+            "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
427 444
             "cpu": [
428 445
                 "x64"
429 446
             ],
@@ -1218,9 +1235,9 @@
1218 1235
             "license": "MIT"
1219 1236
         },
1220 1237
         "node_modules/electron-to-chromium": {
1221
-            "version": "1.5.75",
1222
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz",
1223
-            "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
1238
+            "version": "1.5.76",
1239
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
1240
+            "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==",
1224 1241
             "dev": true,
1225 1242
             "license": "ISC"
1226 1243
         },
@@ -1232,9 +1249,9 @@
1232 1249
             "license": "MIT"
1233 1250
         },
1234 1251
         "node_modules/esbuild": {
1235
-            "version": "0.24.0",
1236
-            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
1237
-            "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==",
1252
+            "version": "0.24.2",
1253
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
1254
+            "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
1238 1255
             "dev": true,
1239 1256
             "hasInstallScript": true,
1240 1257
             "license": "MIT",
@@ -1245,30 +1262,31 @@
1245 1262
                 "node": ">=18"
1246 1263
             },
1247 1264
             "optionalDependencies": {
1248
-                "@esbuild/aix-ppc64": "0.24.0",
1249
-                "@esbuild/android-arm": "0.24.0",
1250
-                "@esbuild/android-arm64": "0.24.0",
1251
-                "@esbuild/android-x64": "0.24.0",
1252
-                "@esbuild/darwin-arm64": "0.24.0",
1253
-                "@esbuild/darwin-x64": "0.24.0",
1254
-                "@esbuild/freebsd-arm64": "0.24.0",
1255
-                "@esbuild/freebsd-x64": "0.24.0",
1256
-                "@esbuild/linux-arm": "0.24.0",
1257
-                "@esbuild/linux-arm64": "0.24.0",
1258
-                "@esbuild/linux-ia32": "0.24.0",
1259
-                "@esbuild/linux-loong64": "0.24.0",
1260
-                "@esbuild/linux-mips64el": "0.24.0",
1261
-                "@esbuild/linux-ppc64": "0.24.0",
1262
-                "@esbuild/linux-riscv64": "0.24.0",
1263
-                "@esbuild/linux-s390x": "0.24.0",
1264
-                "@esbuild/linux-x64": "0.24.0",
1265
-                "@esbuild/netbsd-x64": "0.24.0",
1266
-                "@esbuild/openbsd-arm64": "0.24.0",
1267
-                "@esbuild/openbsd-x64": "0.24.0",
1268
-                "@esbuild/sunos-x64": "0.24.0",
1269
-                "@esbuild/win32-arm64": "0.24.0",
1270
-                "@esbuild/win32-ia32": "0.24.0",
1271
-                "@esbuild/win32-x64": "0.24.0"
1265
+                "@esbuild/aix-ppc64": "0.24.2",
1266
+                "@esbuild/android-arm": "0.24.2",
1267
+                "@esbuild/android-arm64": "0.24.2",
1268
+                "@esbuild/android-x64": "0.24.2",
1269
+                "@esbuild/darwin-arm64": "0.24.2",
1270
+                "@esbuild/darwin-x64": "0.24.2",
1271
+                "@esbuild/freebsd-arm64": "0.24.2",
1272
+                "@esbuild/freebsd-x64": "0.24.2",
1273
+                "@esbuild/linux-arm": "0.24.2",
1274
+                "@esbuild/linux-arm64": "0.24.2",
1275
+                "@esbuild/linux-ia32": "0.24.2",
1276
+                "@esbuild/linux-loong64": "0.24.2",
1277
+                "@esbuild/linux-mips64el": "0.24.2",
1278
+                "@esbuild/linux-ppc64": "0.24.2",
1279
+                "@esbuild/linux-riscv64": "0.24.2",
1280
+                "@esbuild/linux-s390x": "0.24.2",
1281
+                "@esbuild/linux-x64": "0.24.2",
1282
+                "@esbuild/netbsd-arm64": "0.24.2",
1283
+                "@esbuild/netbsd-x64": "0.24.2",
1284
+                "@esbuild/openbsd-arm64": "0.24.2",
1285
+                "@esbuild/openbsd-x64": "0.24.2",
1286
+                "@esbuild/sunos-x64": "0.24.2",
1287
+                "@esbuild/win32-arm64": "0.24.2",
1288
+                "@esbuild/win32-ia32": "0.24.2",
1289
+                "@esbuild/win32-x64": "0.24.2"
1272 1290
             }
1273 1291
         },
1274 1292
         "node_modules/escalade": {
@@ -1312,9 +1330,9 @@
1312 1330
             }
1313 1331
         },
1314 1332
         "node_modules/fastq": {
1315
-            "version": "1.17.1",
1316
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
1317
-            "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
1333
+            "version": "1.18.0",
1334
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
1335
+            "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
1318 1336
             "dev": true,
1319 1337
             "license": "ISC",
1320 1338
             "dependencies": {
@@ -2606,13 +2624,13 @@
2606 2624
             "license": "MIT"
2607 2625
         },
2608 2626
         "node_modules/vite": {
2609
-            "version": "6.0.5",
2610
-            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",
2611
-            "integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==",
2627
+            "version": "6.0.6",
2628
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz",
2629
+            "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==",
2612 2630
             "dev": true,
2613 2631
             "license": "MIT",
2614 2632
             "dependencies": {
2615
-                "esbuild": "0.24.0",
2633
+                "esbuild": "^0.24.2",
2616 2634
                 "postcss": "^8.4.49",
2617 2635
                 "rollup": "^4.23.0"
2618 2636
             },

+ 3
- 0
pint.json Ver arquivo

@@ -9,6 +9,9 @@
9 9
         "nullable_type_declaration_for_default_null_value": {
10 10
             "use_nullable_type_declaration": true
11 11
         },
12
+        "ordered_attributes": {
13
+            "sort_algorithm": "alpha"
14
+        },
12 15
         "single_trait_insert_per_statement": true,
13 16
         "types_spaces": {
14 17
             "space": "single"

+ 4
- 1
resources/data/lang/en.json Ver arquivo

@@ -213,5 +213,8 @@
213 213
     "Footer": "Footer",
214 214
     "Invoice Footer": "Invoice Footer",
215 215
     "Bill Details": "Bill Details",
216
-    "Create": "Create"
216
+    "Create": "Create",
217
+    "Estimate Header": "Estimate Header",
218
+    "Estimate Details": "Estimate Details",
219
+    "Estimate Footer": "Estimate Footer"
217 220
 }

+ 0
- 169
resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php Ver arquivo

@@ -1,169 +0,0 @@
1
-@php
2
-    /** @var \App\Models\Accounting\Invoice $invoice */
3
-    $invoiceSettings = $invoice->company->defaultInvoice;
4
-
5
-    $company = $invoice->company;
6
-
7
-    use App\Utilities\Currency\CurrencyConverter;
8
-@endphp
9
-
10
-<x-company.invoice.container class="modern-template-container">
11
-    <!-- Colored Header with Logo -->
12
-    <x-company.invoice.header class="bg-gray-800 h-24">
13
-        <!-- Logo -->
14
-        <div class="w-2/3">
15
-            @if($invoice->logo && $invoiceSettings->show_logo)
16
-                <x-company.invoice.logo class="ml-8" :src="$invoice->logo"/>
17
-            @endif
18
-        </div>
19
-
20
-        <!-- Ribbon Container -->
21
-        <div class="w-1/3 absolute right-0 top-0 p-3 h-32 flex flex-col justify-end rounded-bl-sm"
22
-             style="background: {{ $invoiceSettings->accent_color }};">
23
-            @if($invoice->header)
24
-                <h1 class="text-4xl font-bold text-white text-center uppercase">{{ $invoice->header }}</h1>
25
-            @endif
26
-        </div>
27
-    </x-company.invoice.header>
28
-
29
-    <!-- Company Details -->
30
-    <x-company.invoice.metadata class="modern-template-metadata space-y-8">
31
-        <div class="text-sm">
32
-            <h2 class="text-lg font-semibold">{{ $company->name }}</h2>
33
-            @if($company->profile->address && $company->profile->city?->name && $company->profile->state?->name && $company->profile?->zip_code)
34
-                <p>{{ $company->profile->address }}</p>
35
-                <p>{{ $company->profile->city->name }}
36
-                    , {{ $company->profile->state->name }} {{ $company->profile->zip_code }}</p>
37
-                <p>{{ $company->profile->state->country->name }}</p>
38
-            @endif
39
-        </div>
40
-
41
-        <div class="flex justify-between items-end">
42
-            <!-- Billing Details -->
43
-            <div class="text-sm tracking-tight">
44
-                <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
45
-                <p class="text-base font-bold"
46
-                   style="color: {{ $invoiceSettings->accent_color }}">{{ $invoice->client->name }}</p>
47
-
48
-                @if($invoice->client->billingAddress)
49
-                    @php
50
-                        $address = $invoice->client->billingAddress;
51
-                    @endphp
52
-                    @if($address->address_line_1)
53
-                        <p>{{ $address->address_line_1 }}</p>
54
-                    @endif
55
-                    @if($address->address_line_2)
56
-                        <p>{{ $address->address_line_2 }}</p>
57
-                    @endif
58
-                    <p>
59
-                        {{ $address->city }}{{ $address->state ? ', ' . $address->state : '' }}
60
-                        {{ $address->postal_code }}
61
-                    </p>
62
-                    @if($address->country)
63
-                        <p>{{ $address->country }}</p>
64
-                    @endif
65
-                @endif
66
-            </div>
67
-
68
-            <div class="text-sm tracking-tight">
69
-                <table class="min-w-full">
70
-                    <tbody>
71
-                    <tr>
72
-                        <td class="font-semibold text-right pr-2">Invoice Number:</td>
73
-                        <td class="text-left pl-2">{{ $invoice->invoice_number }}</td>
74
-                    </tr>
75
-                    @if($invoice->order_number)
76
-                        <tr>
77
-                            <td class="font-semibold text-right pr-2">P.O/S.O Number:</td>
78
-                            <td class="text-left pl-2">{{ $invoice->order_number }}</td>
79
-                        </tr>
80
-                    @endif
81
-                    <tr>
82
-                        <td class="font-semibold text-right pr-2">Invoice Date:</td>
83
-                        <td class="text-left pl-2">{{ $invoice->date->toDefaultDateFormat() }}</td>
84
-                    </tr>
85
-                    <tr>
86
-                        <td class="font-semibold text-right pr-2">Payment Due:</td>
87
-                        <td class="text-left pl-2">{{ $invoice->due_date->toDefaultDateFormat() }}</td>
88
-                    </tr>
89
-                    </tbody>
90
-                </table>
91
-            </div>
92
-        </div>
93
-    </x-company.invoice.metadata>
94
-
95
-    <!-- Line Items Table -->
96
-    <x-company.invoice.line-items class="modern-template-line-items">
97
-        <table class="w-full text-left table-fixed">
98
-            <thead class="text-sm leading-relaxed">
99
-            <tr class="text-gray-600 dark:text-gray-400">
100
-                <th class="text-left pl-6 w-[45%] py-4">Items</th>
101
-                <th class="text-center w-[15%] py-4">Quantity</th>
102
-                <th class="text-right w-[20%] py-4">Price</th>
103
-                <th class="text-right pr-6 w-[20%] py-4">Amount</th>
104
-            </tr>
105
-            </thead>
106
-            <tbody class="text-sm tracking-tight border-y-2">
107
-            @foreach($invoice->lineItems as $index => $item)
108
-                <tr @class(['bg-gray-100 dark:bg-gray-800' => $index % 2 === 0])>
109
-                    <td class="text-left pl-6 font-semibold py-3">
110
-                        {{ $item->offering->name }}
111
-                        @if($item->description)
112
-                            <div class="text-gray-600 font-normal line-clamp-2 mt-1">{{ $item->description }}</div>
113
-                        @endif
114
-                    </td>
115
-                    <td class="text-center py-3">{{ $item->quantity }}</td>
116
-                    <td class="text-right py-3">{{ CurrencyConverter::formatToMoney($item->unit_price, $invoice->currency_code) }}</td>
117
-                    <td class="text-right pr-6 py-3">{{ CurrencyConverter::formatToMoney($item->subtotal, $invoice->currency_code) }}</td>
118
-                </tr>
119
-            @endforeach
120
-            </tbody>
121
-            <tfoot class="text-sm tracking-tight">
122
-            <tr>
123
-                <td class="pl-6 py-2" colspan="2"></td>
124
-                <td class="text-right font-semibold py-2">Subtotal:</td>
125
-                <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->subtotal, $invoice->currency_code) }}</td>
126
-            </tr>
127
-            @if($invoice->discount_total)
128
-                <tr class="text-success-800 dark:text-success-600">
129
-                    <td class="pl-6 py-2" colspan="2"></td>
130
-                    <td class="text-right py-2">Discount:</td>
131
-                    <td class="text-right pr-6 py-2">
132
-                        ({{ CurrencyConverter::formatToMoney($invoice->discount_total, $invoice->currency_code) }})
133
-                    </td>
134
-                </tr>
135
-            @endif
136
-            @if($invoice->tax_total)
137
-                <tr>
138
-                    <td class="pl-6 py-2" colspan="2"></td>
139
-                    <td class="text-right py-2">Tax:</td>
140
-                    <td class="text-right pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->tax_total, $invoice->currency_code) }}</td>
141
-                </tr>
142
-            @endif
143
-            <tr>
144
-                <td class="pl-6 py-2" colspan="2"></td>
145
-                <td class="text-right font-semibold border-t py-2">Total:</td>
146
-                <td class="text-right border-t pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->total, $invoice->currency_code) }}</td>
147
-            </tr>
148
-            <tr>
149
-                <td class="pl-6 py-2" colspan="2"></td>
150
-                <td class="text-right font-semibold border-t-4 border-double py-2">Amount Due
151
-                    ({{ $invoice->currency_code }}):
152
-                </td>
153
-                <td class="text-right border-t-4 border-double pr-6 py-2">{{ CurrencyConverter::formatToMoney($invoice->amount_due, $invoice->currency_code) }}</td>
154
-            </tr>
155
-            </tfoot>
156
-        </table>
157
-    </x-company.invoice.line-items>
158
-
159
-    <!-- Footer Notes -->
160
-    <x-company.invoice.footer class="modern-template-footer tracking-tight">
161
-        <h4 class="font-semibold px-6 text-sm" style="color: {{ $invoiceSettings->accent_color }}">Terms &
162
-            Conditions</h4>
163
-        <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
164
-        <div class="flex justify-between space-x-4 px-6 text-sm">
165
-            <p class="w-1/2 break-words line-clamp-4">{{ $invoice->terms }}</p>
166
-            <p class="w-1/2 break-words line-clamp-4">{{ $invoice->footer }}</p>
167
-        </div>
168
-    </x-company.invoice.footer>
169
-</x-company.invoice.container>

+ 0
- 47
resources/views/filament/company/resources/sales/invoices/pages/view-invoice.blade.php Ver arquivo

@@ -1,47 +0,0 @@
1
-<x-filament-panels::page
2
-    @class([
3
-        'fi-resource-view-record-page',
4
-        'fi-resource-' . str_replace('/', '-', $this->getResource()::getSlug()),
5
-        'fi-resource-record-' . $record->getKey(),
6
-    ])
7
->
8
-    @php
9
-        $relationManagers = $this->getRelationManagers();
10
-        $hasCombinedRelationManagerTabsWithContent = $this->hasCombinedRelationManagerTabsWithContent();
11
-    @endphp
12
-
13
-    @if ((! $hasCombinedRelationManagerTabsWithContent) || (! count($relationManagers)))
14
-        @if ($this->hasInfolist())
15
-            {{ $this->infolist }}
16
-        @else
17
-            <div
18
-                wire:key="{{ $this->getId() }}.forms.{{ $this->getFormStatePath() }}"
19
-            >
20
-                {{ $this->form }}
21
-            </div>
22
-        @endif
23
-    @endif
24
-
25
-    @if (count($relationManagers))
26
-        <x-filament-panels::resources.relation-managers
27
-            :active-locale="isset($activeLocale) ? $activeLocale : null"
28
-            :active-manager="$this->activeRelationManager ?? ($hasCombinedRelationManagerTabsWithContent ? null : array_key_first($relationManagers))"
29
-            :content-tab-label="$this->getContentTabLabel()"
30
-            :content-tab-icon="$this->getContentTabIcon()"
31
-            :content-tab-position="$this->getContentTabPosition()"
32
-            :managers="$relationManagers"
33
-            :owner-record="$record"
34
-            :page-class="static::class"
35
-        >
36
-            @if ($hasCombinedRelationManagerTabsWithContent)
37
-                <x-slot name="content">
38
-                    @if ($this->hasInfolist())
39
-                        {{ $this->infolist }}
40
-                    @else
41
-                        {{ $this->form }}
42
-                    @endif
43
-                </x-slot>
44
-            @endif
45
-        </x-filament-panels::resources.relation-managers>
46
-    @endif
47
-</x-filament-panels::page>

+ 178
- 0
resources/views/filament/infolists/components/document-preview.blade.php Ver arquivo

@@ -0,0 +1,178 @@
1
+@php
2
+    use App\View\Models\DocumentPreviewViewModel;
3
+    use App\Enums\Accounting\DocumentType;
4
+
5
+    $type = $getType();
6
+    $viewModel = new DocumentPreviewViewModel($getRecord(), $type);
7
+    extract($viewModel->buildViewData(), EXTR_SKIP);
8
+@endphp
9
+
10
+<div {{ $attributes }}>
11
+    <x-company.invoice.container class="modern-template-container">
12
+        <!-- Colored Header with Logo -->
13
+        <x-company.invoice.header class="bg-gray-800 h-24">
14
+            <!-- Logo -->
15
+            <div class="w-2/3">
16
+                @if($logo && $style['show_logo'])
17
+                    <x-company.invoice.logo class="ml-8" :src="$logo"/>
18
+                @endif
19
+            </div>
20
+
21
+            <!-- Ribbon Container -->
22
+            <div class="w-1/3 absolute right-0 top-0 p-3 h-32 flex flex-col justify-end rounded-bl-sm"
23
+                 style="background: {{ $style['accent_color'] }};">
24
+                @if($header)
25
+                    <h1 class="text-4xl font-bold text-white text-center uppercase">{{ $header }}</h1>
26
+                @endif
27
+            </div>
28
+        </x-company.invoice.header>
29
+
30
+        <!-- Company Details -->
31
+        <x-company.invoice.metadata class="modern-template-metadata space-y-8">
32
+            <div class="text-sm">
33
+                <h2 class="text-lg font-semibold">{{ $company['name'] }}</h2>
34
+                @if($company['address'] && $company['city'] && $company['state'] && $company['zip_code'])
35
+                    <p>{{ $company['address'] }}</p>
36
+                    <p>{{ $company['city'] }}
37
+                        , {{ $company['state'] }} {{ $company['zip_code'] }}</p>
38
+                    <p>{{ $company['country'] }}</p>
39
+                @endif
40
+            </div>
41
+
42
+            <div class="flex justify-between items-end">
43
+                <!-- Billing Details -->
44
+                <div class="text-sm tracking-tight">
45
+                    <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
46
+                    <p class="text-base font-bold"
47
+                       style="color: {{ $style['accent_color'] }}">{{ $client['name'] }}</p>
48
+
49
+                    @if($client['address_line_1'])
50
+                        <p>{{ $client['address_line_1'] }}</p>
51
+
52
+                        @if($client['address_line_2'])
53
+                            <p>{{ $client['address_line_2'] }}</p>
54
+                        @endif
55
+                        <p>
56
+                            {{ $client['city'] }}{{ $client['state'] ? ', ' . $client['state'] : '' }}
57
+                            {{ $client['postal_code'] }}
58
+                        </p>
59
+                        @if($client['country'])
60
+                            <p>{{ $client['country'] }}</p>
61
+                        @endif
62
+                    @endif
63
+                </div>
64
+
65
+                <div class="text-sm tracking-tight">
66
+                    <table class="min-w-full">
67
+                        <tbody>
68
+                        <tr>
69
+                            <td class="font-semibold text-right pr-2">{{ $labels['number'] }}:</td>
70
+                            <td class="text-left pl-2">{{ $metadata['number'] }}</td>
71
+                        </tr>
72
+                        @if($metadata['reference_number'])
73
+                            <tr>
74
+                                <td class="font-semibold text-right pr-2">{{ $labels['reference_number'] }}:</td>
75
+                                <td class="text-left pl-2">{{ $metadata['reference_number'] }}</td>
76
+                            </tr>
77
+                        @endif
78
+                        <tr>
79
+                            <td class="font-semibold text-right pr-2">{{ $labels['date'] }}:</td>
80
+                            <td class="text-left pl-2">{{ $metadata['date'] }}</td>
81
+                        </tr>
82
+                        <tr>
83
+                            <td class="font-semibold text-right pr-2">{{ $labels['due_date'] }}:</td>
84
+                            <td class="text-left pl-2">{{ $metadata['due_date'] }}</td>
85
+                        </tr>
86
+                        </tbody>
87
+                    </table>
88
+                </div>
89
+            </div>
90
+        </x-company.invoice.metadata>
91
+
92
+        <!-- Line Items Table -->
93
+        <x-company.invoice.line-items class="modern-template-line-items">
94
+            <table class="w-full text-left table-fixed">
95
+                <thead class="text-sm leading-relaxed">
96
+                <tr class="text-gray-600 dark:text-gray-400">
97
+                    <th class="text-left pl-6 w-[45%] py-4">Items</th>
98
+                    <th class="text-center w-[15%] py-4">Quantity</th>
99
+                    <th class="text-right w-[20%] py-4">Price</th>
100
+                    <th class="text-right pr-6 w-[20%] py-4">Amount</th>
101
+                </tr>
102
+                </thead>
103
+                <tbody class="text-sm tracking-tight border-y-2">
104
+                @foreach($lineItems as $index => $item)
105
+                    <tr @class(['bg-gray-100 dark:bg-gray-800' => $index % 2 === 0])>
106
+                        <td class="text-left pl-6 font-semibold py-3">
107
+                            {{ $item['name'] }}
108
+                            @if($item['description'])
109
+                                <div class="text-gray-600 font-normal line-clamp-2 mt-1">{{ $item['description'] }}</div>
110
+                            @endif
111
+                        </td>
112
+                        <td class="text-center py-3">{{ $item['quantity'] }}</td>
113
+                        <td class="text-right py-3">{{ $item['unit_price'] }}</td>
114
+                        <td class="text-right pr-6 py-3">{{ $item['subtotal'] }}</td>
115
+                    </tr>
116
+                @endforeach
117
+                </tbody>
118
+                <tfoot class="text-sm tracking-tight">
119
+                <tr>
120
+                    <td class="pl-6 py-2" colspan="2"></td>
121
+                    <td class="text-right font-semibold py-2">Subtotal:</td>
122
+                    <td class="text-right pr-6 py-2">{{ $totals['subtotal'] }}</td>
123
+                </tr>
124
+                @if($totals['discount'])
125
+                    <tr class="text-success-800 dark:text-success-600">
126
+                        <td class="pl-6 py-2" colspan="2"></td>
127
+                        <td class="text-right py-2">Discount:</td>
128
+                        <td class="text-right pr-6 py-2">
129
+                            ({{ $totals['discount'] }})
130
+                        </td>
131
+                    </tr>
132
+                @endif
133
+                @if($totals['tax'])
134
+                    <tr>
135
+                        <td class="pl-6 py-2" colspan="2"></td>
136
+                        <td class="text-right py-2">Tax:</td>
137
+                        <td class="text-right pr-6 py-2">{{ $totals['tax'] }}</td>
138
+                    </tr>
139
+                @endif
140
+                <tr>
141
+                    <td class="pl-6 py-2" colspan="2"></td>
142
+                    <td class="text-right font-semibold border-t py-2">Total:</td>
143
+                    <td class="text-right border-t pr-6 py-2">{{ $totals['total'] }}</td>
144
+                </tr>
145
+                @if($totals['amount_due'])
146
+                    <tr>
147
+                        <td class="pl-6 py-2" colspan="2"></td>
148
+                        <td class="text-right font-semibold border-t-4 border-double py-2">Amount Due
149
+                            ({{ $metadata['currency_code'] }}):
150
+                        </td>
151
+                        <td class="text-right border-t-4 border-double pr-6 py-2">{{ $totals['amount_due'] }}</td>
152
+                    </tr>
153
+                @else
154
+                    <tr>
155
+                        <td class="pl-6 py-2" colspan="2"></td>
156
+                        <td class="text-right font-semibold border-t-4 border-double py-2">Grand Total
157
+                            ({{ $metadata['currency_code'] }}):
158
+                        </td>
159
+                        <td class="text-right border-t-4 border-double pr-6 py-2">{{ $totals['total'] }}</td>
160
+                    </tr>
161
+                @endif
162
+                </tfoot>
163
+            </table>
164
+        </x-company.invoice.line-items>
165
+
166
+        <!-- Footer Notes -->
167
+        <x-company.invoice.footer class="modern-template-footer tracking-tight">
168
+            <h4 class="font-semibold px-6 text-sm" style="color: {{ $style['accent_color'] }}">
169
+                Terms & Conditions
170
+            </h4>
171
+            <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
172
+            <div class="flex justify-between space-x-4 px-6 text-sm">
173
+                <p class="w-1/2 break-words line-clamp-4">{{ $terms }}</p>
174
+                <p class="w-1/2 break-words line-clamp-4">{{ $footer }}</p>
175
+            </div>
176
+        </x-company.invoice.footer>
177
+    </x-company.invoice.container>
178
+</div>

resources/views/infolists/components/report-entry.blade.php → resources/views/filament/infolists/components/report-entry.blade.php Ver arquivo


Carregando…
Cancelar
Salvar