浏览代码

wip Estimates

3.x
Andrew Wallo 9 个月前
父节点
当前提交
efbe5d8f29
共有 30 个文件被更改,包括 1126 次插入470 次删除
  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 查看文件

42
             self::Bill => 'purchaseDiscounts',
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 查看文件

2
 
2
 
3
 namespace App\Enums\Accounting;
3
 namespace App\Enums\Accounting;
4
 
4
 
5
+use Filament\Support\Contracts\HasColor;
5
 use Filament\Support\Contracts\HasLabel;
6
 use Filament\Support\Contracts\HasLabel;
6
 
7
 
7
-enum EstimateStatus: string implements HasLabel
8
+enum EstimateStatus: string implements HasColor, HasLabel
8
 {
9
 {
9
     case Draft = 'draft';
10
     case Draft = 'draft';
10
     case Sent = 'sent';
11
     case Sent = 'sent';
19
     {
20
     {
20
         return $this->name;
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 查看文件

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

+ 81
- 18
app/Filament/Company/Resources/Sales/EstimateResource.php 查看文件

46
             ->schema([
46
             ->schema([
47
                 Forms\Components\Section::make('Estimate Header')
47
                 Forms\Components\Section::make('Estimate Header')
48
                     ->collapsible()
48
                     ->collapsible()
49
+                    ->collapsed()
49
                     ->schema([
50
                     ->schema([
50
                         Forms\Components\Split::make([
51
                         Forms\Components\Split::make([
51
                             Forms\Components\Group::make([
52
                             Forms\Components\Group::make([
114
                                     ->label('Estimate Number')
115
                                     ->label('Estimate Number')
115
                                     ->default(fn () => Estimate::getNextDocumentNumber()),
116
                                     ->default(fn () => Estimate::getNextDocumentNumber()),
116
                                 Forms\Components\TextInput::make('reference_number')
117
                                 Forms\Components\TextInput::make('reference_number')
117
-                                    ->label('P.O/S.O Number'),
118
+                                    ->label('Reference Number'),
118
                                 Forms\Components\DatePicker::make('date')
119
                                 Forms\Components\DatePicker::make('date')
119
                                     ->label('Estimate Date')
120
                                     ->label('Estimate Date')
120
                                     ->live()
121
                                     ->live()
121
                                     ->default(now())
122
                                     ->default(now())
122
-                                    ->disabled(function (?Estimate $record) {
123
-                                        return $record?->hasPayments();
124
-                                    })
125
                                     ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
123
                                     ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
126
                                         $date = $state;
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
                                             $set('expiration_date', $date);
128
                                             $set('expiration_date', $date);
131
                                         }
129
                                         }
132
                                     }),
130
                                     }),
133
                                 Forms\Components\DatePicker::make('expiration_date')
131
                                 Forms\Components\DatePicker::make('expiration_date')
134
-                                    ->label('Payment Due')
132
+                                    ->label('Expiration Date')
135
                                     ->default(function () use ($company) {
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
                                     ->minDate(static function (Forms\Get $get) {
136
                                     ->minDate(static function (Forms\Get $get) {
139
                                         return $get('date') ?? now();
137
                                         return $get('date') ?? now();
277
                     ]),
275
                     ]),
278
                 Forms\Components\Section::make('Estimate Footer')
276
                 Forms\Components\Section::make('Estimate Footer')
279
                     ->collapsible()
277
                     ->collapsible()
278
+                    ->collapsed()
280
                     ->schema([
279
                     ->schema([
281
                         Forms\Components\Textarea::make('footer')
280
                         Forms\Components\Textarea::make('footer')
282
                             ->columnSpanFull(),
281
                             ->columnSpanFull(),
298
                     ->badge()
297
                     ->badge()
299
                     ->searchable(),
298
                     ->searchable(),
300
                 Tables\Columns\TextColumn::make('expiration_date')
299
                 Tables\Columns\TextColumn::make('expiration_date')
301
-                    ->label('Due')
300
+                    ->label('Expiration Date')
302
                     ->asRelativeDay()
301
                     ->asRelativeDay()
303
                     ->sortable(),
302
                     ->sortable(),
304
                 Tables\Columns\TextColumn::make('date')
303
                 Tables\Columns\TextColumn::make('date')
338
                     Tables\Actions\EditAction::make(),
337
                     Tables\Actions\EditAction::make(),
339
                     Tables\Actions\ViewAction::make(),
338
                     Tables\Actions\ViewAction::make(),
340
                     Tables\Actions\DeleteAction::make(),
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
             ->bulkActions([
348
             ->bulkActions([
355
                         ->databaseTransaction()
357
                         ->databaseTransaction()
356
                         ->deselectRecordsAfterCompletion()
358
                         ->deselectRecordsAfterCompletion()
357
                         ->excludeAttributes([
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
                             'status',
368
                             'status',
359
-                            'amount_paid',
360
-                            'amount_due',
361
                             'created_by',
369
                             'created_by',
362
                             'updated_by',
370
                             'updated_by',
363
                             'created_at',
371
                             'created_at',
364
                             'updated_at',
372
                             'updated_at',
365
-                            'estimate_number',
366
-                            'date',
367
-                            'expiration_date',
368
                         ])
373
                         ])
369
                         ->beforeReplicaSaved(function (Estimate $replica) {
374
                         ->beforeReplicaSaved(function (Estimate $replica) {
370
                             $replica->status = EstimateStatus::Draft;
375
                             $replica->status = EstimateStatus::Draft;
371
                             $replica->estimate_number = Estimate::getNextDocumentNumber();
376
                             $replica->estimate_number = Estimate::getNextDocumentNumber();
372
                             $replica->date = now();
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
                         ->withReplicatedRelationships(['lineItems'])
380
                         ->withReplicatedRelationships(['lineItems'])
376
                         ->withExcludedRelationshipAttributes('lineItems', [
381
                         ->withExcludedRelationshipAttributes('lineItems', [
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
                             $action->success();
501
                             $action->success();
439
                         }),
502
                         }),
440
                 ]),
503
                 ]),

+ 1
- 7
app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php 查看文件

43
 
43
 
44
         $data = array_merge($data, $totals);
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 查看文件

2
 
2
 
3
 namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
3
 namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4
 
4
 
5
+use App\Enums\Accounting\DocumentType;
5
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\EstimateResource;
7
 use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Filament\Infolists\Components\DocumentPreview;
7
 use App\Models\Accounting\Estimate;
9
 use App\Models\Accounting\Estimate;
8
 use Filament\Actions;
10
 use Filament\Actions;
9
 use Filament\Infolists\Components\Grid;
11
 use Filament\Infolists\Components\Grid;
10
 use Filament\Infolists\Components\Section;
12
 use Filament\Infolists\Components\Section;
11
 use Filament\Infolists\Components\TextEntry;
13
 use Filament\Infolists\Components\TextEntry;
12
-use Filament\Infolists\Components\ViewEntry;
13
 use Filament\Infolists\Infolist;
14
 use Filament\Infolists\Infolist;
14
 use Filament\Resources\Pages\ViewRecord;
15
 use Filament\Resources\Pages\ViewRecord;
15
 use Filament\Support\Enums\FontWeight;
16
 use Filament\Support\Enums\FontWeight;
19
 
20
 
20
 class ViewEstimate extends ViewRecord
21
 class ViewEstimate extends ViewRecord
21
 {
22
 {
22
-    protected static string $view = 'filament.company.resources.sales.invoices.pages.view-invoice';
23
-
24
     protected static string $resource = EstimateResource::class;
23
     protected static string $resource = EstimateResource::class;
25
 
24
 
26
     protected $listeners = [
25
     protected $listeners = [
40
                 Actions\DeleteAction::make(),
39
                 Actions\DeleteAction::make(),
41
                 Estimate::getApproveDraftAction(),
40
                 Estimate::getApproveDraftAction(),
42
                 Estimate::getMarkAsSentAction(),
41
                 Estimate::getMarkAsSentAction(),
42
+                Estimate::getMarkAsAcceptedAction(),
43
+                Estimate::getMarkAsDeclinedAction(),
43
                 Estimate::getReplicateAction(),
44
                 Estimate::getReplicateAction(),
45
+                Estimate::getConvertToInvoiceAction(),
44
             ])
46
             ])
45
                 ->label('Actions')
47
                 ->label('Actions')
46
                 ->button()
48
                 ->button()
61
                     ->schema([
63
                     ->schema([
62
                         Grid::make(1)
64
                         Grid::make(1)
63
                             ->schema([
65
                             ->schema([
64
-                                TextEntry::make('invoice_number')
66
+                                TextEntry::make('estimate_number')
65
                                     ->label('Estimate #'),
67
                                     ->label('Estimate #'),
66
                                 TextEntry::make('status')
68
                                 TextEntry::make('status')
67
                                     ->badge(),
69
                                     ->badge(),
70
                                     ->color('primary')
72
                                     ->color('primary')
71
                                     ->weight(FontWeight::SemiBold)
73
                                     ->weight(FontWeight::SemiBold)
72
                                     ->url(static fn (Estimate $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
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
                                     ->asRelativeDay(),
77
                                     ->asRelativeDay(),
79
                                 TextEntry::make('approved_at')
78
                                 TextEntry::make('approved_at')
80
                                     ->label('Approved At')
79
                                     ->label('Approved At')
84
                                     ->label('Last Sent')
83
                                     ->label('Last Sent')
85
                                     ->placeholder('Never')
84
                                     ->placeholder('Never')
86
                                     ->date(),
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
                                     ->date(),
89
                                     ->date(),
91
                             ])->columnSpan(1),
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 查看文件

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

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

+ 4
- 14
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php 查看文件

2
 
2
 
3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
 
4
 
5
+use App\Enums\Accounting\DocumentType;
5
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\InvoiceResource;
7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Filament\Infolists\Components\DocumentPreview;
7
 use App\Models\Accounting\Invoice;
9
 use App\Models\Accounting\Invoice;
8
 use Filament\Actions;
10
 use Filament\Actions;
9
 use Filament\Infolists\Components\Grid;
11
 use Filament\Infolists\Components\Grid;
10
 use Filament\Infolists\Components\Section;
12
 use Filament\Infolists\Components\Section;
11
 use Filament\Infolists\Components\TextEntry;
13
 use Filament\Infolists\Components\TextEntry;
12
-use Filament\Infolists\Components\ViewEntry;
13
 use Filament\Infolists\Infolist;
14
 use Filament\Infolists\Infolist;
14
 use Filament\Resources\Pages\ViewRecord;
15
 use Filament\Resources\Pages\ViewRecord;
15
 use Filament\Support\Enums\FontWeight;
16
 use Filament\Support\Enums\FontWeight;
19
 
20
 
20
 class ViewInvoice extends ViewRecord
21
 class ViewInvoice extends ViewRecord
21
 {
22
 {
22
-    protected static string $view = 'filament.company.resources.sales.invoices.pages.view-invoice';
23
-
24
     protected static string $resource = InvoiceResource::class;
23
     protected static string $resource = InvoiceResource::class;
25
 
24
 
26
     protected $listeners = [
25
     protected $listeners = [
89
                                     ->placeholder('Not Paid')
88
                                     ->placeholder('Not Paid')
90
                                     ->date(),
89
                                     ->date(),
91
                             ])->columnSpan(1),
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 查看文件

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 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Infolists\Components;
3
+namespace App\Filament\Infolists\Components;
4
 
4
 
5
 use Filament\Infolists\Components\Entry;
5
 use Filament\Infolists\Components\Entry;
6
 use Filament\Support\Concerns\HasDescription;
6
 use Filament\Support\Concerns\HasDescription;
15
     use HasIcon;
15
     use HasIcon;
16
     use HasIconColor;
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 查看文件

10
 use App\Enums\Accounting\AdjustmentComputation;
10
 use App\Enums\Accounting\AdjustmentComputation;
11
 use App\Enums\Accounting\DocumentDiscountMethod;
11
 use App\Enums\Accounting\DocumentDiscountMethod;
12
 use App\Enums\Accounting\EstimateStatus;
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
 use App\Models\Common\Client;
16
 use App\Models\Common\Client;
14
 use App\Models\Setting\Currency;
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
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
22
 use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
 use Illuminate\Database\Eloquent\Builder;
24
 use Illuminate\Database\Eloquent\Builder;
17
 use Illuminate\Database\Eloquent\Casts\Attribute;
25
 use Illuminate\Database\Eloquent\Casts\Attribute;
18
 use Illuminate\Database\Eloquent\Factories\HasFactory;
26
 use Illuminate\Database\Eloquent\Factories\HasFactory;
19
 use Illuminate\Database\Eloquent\Model;
27
 use Illuminate\Database\Eloquent\Model;
20
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
28
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
29
+use Illuminate\Database\Eloquent\Relations\HasOne;
21
 use Illuminate\Database\Eloquent\Relations\MorphMany;
30
 use Illuminate\Database\Eloquent\Relations\MorphMany;
22
 use Illuminate\Support\Carbon;
31
 use Illuminate\Support\Carbon;
23
 
32
 
24
 #[CollectedBy(DocumentCollection::class)]
33
 #[CollectedBy(DocumentCollection::class)]
34
+#[ObservedBy(EstimateObserver::class)]
25
 class Estimate extends Model
35
 class Estimate extends Model
26
 {
36
 {
27
     use Blamable;
37
     use Blamable;
40
         'expiration_date',
50
         'expiration_date',
41
         'approved_at',
51
         'approved_at',
42
         'accepted_at',
52
         'accepted_at',
53
+        'converted_at',
43
         'declined_at',
54
         'declined_at',
44
         'last_sent_at',
55
         'last_sent_at',
45
         'last_viewed_at',
56
         'last_viewed_at',
86
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
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
     public function lineItems(): MorphMany
105
     public function lineItems(): MorphMany
90
     {
106
     {
91
         return $this->morphMany(DocumentLineItem::class, 'documentable');
107
         return $this->morphMany(DocumentLineItem::class, 'documentable');
179
     public function approveDraft(?Carbon $approvedAt = null): void
195
     public function approveDraft(?Carbon $approvedAt = null): void
180
     {
196
     {
181
         if (! $this->isDraft()) {
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
         $approvedAt ??= now();
201
         $approvedAt ??= now();
189
             'status' => EstimateStatus::Unsent,
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 查看文件

15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
16
 use App\Models\Banking\BankAccount;
16
 use App\Models\Banking\BankAccount;
17
 use App\Models\Common\Client;
17
 use App\Models\Common\Client;
18
+use App\Models\Company;
18
 use App\Models\Setting\Currency;
19
 use App\Models\Setting\Currency;
19
 use App\Observers\InvoiceObserver;
20
 use App\Observers\InvoiceObserver;
20
 use App\Utilities\Currency\CurrencyAccessor;
21
 use App\Utilities\Currency\CurrencyAccessor;
33
 use Illuminate\Database\Eloquent\Relations\MorphOne;
34
 use Illuminate\Database\Eloquent\Relations\MorphOne;
34
 use Illuminate\Support\Carbon;
35
 use Illuminate\Support\Carbon;
35
 
36
 
36
-#[ObservedBy(InvoiceObserver::class)]
37
 #[CollectedBy(DocumentCollection::class)]
37
 #[CollectedBy(DocumentCollection::class)]
38
+#[ObservedBy(InvoiceObserver::class)]
38
 class Invoice extends Model
39
 class Invoice extends Model
39
 {
40
 {
40
     use Blamable;
41
     use Blamable;
46
     protected $fillable = [
47
     protected $fillable = [
47
         'company_id',
48
         'company_id',
48
         'client_id',
49
         'client_id',
50
+        'estimate_id',
49
         'logo',
51
         'logo',
50
         'header',
52
         'header',
51
         'subheader',
53
         'subheader',
100
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
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
     public function lineItems(): MorphMany
110
     public function lineItems(): MorphMany
104
     {
111
     {
105
         return $this->morphMany(DocumentLineItem::class, 'documentable');
112
         return $this->morphMany(DocumentLineItem::class, 'documentable');
182
         return $this->payments->isNotEmpty();
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
         if (! $company) {
196
         if (! $company) {
190
             throw new \RuntimeException('No current company is set for the user.');
197
             throw new \RuntimeException('No current company is set for the user.');
412
             })
419
             })
413
             ->successNotificationTitle('Invoice Sent')
420
             ->successNotificationTitle('Invoice Sent')
414
             ->action(function (self $record, MountableAction $action) {
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
                 $action->success();
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
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
438
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
425
     {
439
     {
426
         return $action::make()
440
         return $action::make()

+ 5
- 0
app/Models/Company.php 查看文件

142
         return $this->hasMany(Department::class, 'company_id');
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
     public function invoices(): HasMany
150
     public function invoices(): HasMany
146
     {
151
     {
147
         return $this->hasMany(Accounting\Invoice::class, 'company_id');
152
         return $this->hasMany(Accounting\Invoice::class, 'company_id');

+ 27
- 0
app/Observers/EstimateObserver.php 查看文件

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 查看文件

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

+ 70
- 0
app/Policies/EstimatePolicy.php 查看文件

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 查看文件

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 查看文件

497
         },
497
         },
498
         {
498
         {
499
             "name": "aws/aws-sdk-php",
499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.2",
500
+            "version": "3.336.4",
501
             "source": {
501
             "source": {
502
                 "type": "git",
502
                 "type": "git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4"
504
+                "reference": "5bc056d14a8bad0e9ec5a6ce03e46d8e4329b7fc"
505
             },
505
             },
506
             "dist": {
506
             "dist": {
507
                 "type": "zip",
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
                 "shasum": ""
510
                 "shasum": ""
511
             },
511
             },
512
             "require": {
512
             "require": {
589
             "support": {
589
             "support": {
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
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
             "name": "aws/aws-sdk-php-laravel",
597
             "name": "aws/aws-sdk-php-laravel",
1595
         },
1595
         },
1596
         {
1596
         {
1597
             "name": "egulias/email-validator",
1597
             "name": "egulias/email-validator",
1598
-            "version": "4.0.2",
1598
+            "version": "4.0.3",
1599
             "source": {
1599
             "source": {
1600
                 "type": "git",
1600
                 "type": "git",
1601
                 "url": "https://github.com/egulias/EmailValidator.git",
1601
                 "url": "https://github.com/egulias/EmailValidator.git",
1602
-                "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e"
1602
+                "reference": "b115554301161fa21467629f1e1391c1936de517"
1603
             },
1603
             },
1604
             "dist": {
1604
             "dist": {
1605
                 "type": "zip",
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
                 "shasum": ""
1608
                 "shasum": ""
1609
             },
1609
             },
1610
             "require": {
1610
             "require": {
1650
             ],
1650
             ],
1651
             "support": {
1651
             "support": {
1652
                 "issues": "https://github.com/egulias/EmailValidator/issues",
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
             "funding": [
1655
             "funding": [
1656
                 {
1656
                 {
1658
                     "type": "github"
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
             "name": "filament/actions",
1664
             "name": "filament/actions",
4666
         },
4666
         },
4667
         {
4667
         {
4668
             "name": "nesbot/carbon",
4668
             "name": "nesbot/carbon",
4669
-            "version": "3.8.3",
4669
+            "version": "3.8.4",
4670
             "source": {
4670
             "source": {
4671
                 "type": "git",
4671
                 "type": "git",
4672
                 "url": "https://github.com/briannesbitt/Carbon.git",
4672
                 "url": "https://github.com/briannesbitt/Carbon.git",
4673
-                "reference": "f01cfa96468f4c38325f507ab81a4f1d2cd93cfe"
4673
+                "reference": "129700ed449b1f02d70272d2ac802357c8c30c58"
4674
             },
4674
             },
4675
             "dist": {
4675
             "dist": {
4676
                 "type": "zip",
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
                 "shasum": ""
4679
                 "shasum": ""
4680
             },
4680
             },
4681
             "require": {
4681
             "require": {
4768
                     "type": "tidelift"
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
             "name": "nette/schema",
4774
             "name": "nette/schema",
6303
         },
6303
         },
6304
         {
6304
         {
6305
             "name": "spatie/color",
6305
             "name": "spatie/color",
6306
-            "version": "1.6.2",
6306
+            "version": "1.6.3",
6307
             "source": {
6307
             "source": {
6308
                 "type": "git",
6308
                 "type": "git",
6309
                 "url": "https://github.com/spatie/color.git",
6309
                 "url": "https://github.com/spatie/color.git",
6310
-                "reference": "b4fac074a9e5999dcca12cbfab0f7c73e2684d6d"
6310
+                "reference": "45c4354ffa7e65f05c502b009834ecac7928daa3"
6311
             },
6311
             },
6312
             "dist": {
6312
             "dist": {
6313
                 "type": "zip",
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
                 "shasum": ""
6316
                 "shasum": ""
6317
             },
6317
             },
6318
             "require": {
6318
             "require": {
6350
             ],
6350
             ],
6351
             "support": {
6351
             "support": {
6352
                 "issues": "https://github.com/spatie/color/issues",
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
             "funding": [
6355
             "funding": [
6356
                 {
6356
                 {
6358
                     "type": "github"
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
             "name": "spatie/invade",
6364
             "name": "spatie/invade",
6839
             },
6839
             },
6840
             "type": "library",
6840
             "type": "library",
6841
             "extra": {
6841
             "extra": {
6842
+                "thanks": {
6843
+                    "url": "https://github.com/symfony/contracts",
6844
+                    "name": "symfony/contracts"
6845
+                },
6842
                 "branch-alias": {
6846
                 "branch-alias": {
6843
                     "dev-main": "3.5-dev"
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
             "autoload": {
6850
             "autoload": {
7062
             },
7062
             },
7063
             "type": "library",
7063
             "type": "library",
7064
             "extra": {
7064
             "extra": {
7065
+                "thanks": {
7066
+                    "url": "https://github.com/symfony/contracts",
7067
+                    "name": "symfony/contracts"
7068
+                },
7065
                 "branch-alias": {
7069
                 "branch-alias": {
7066
                     "dev-main": "3.5-dev"
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
             "autoload": {
7073
             "autoload": {
8492
             },
8492
             },
8493
             "type": "library",
8493
             "type": "library",
8494
             "extra": {
8494
             "extra": {
8495
+                "thanks": {
8496
+                    "url": "https://github.com/symfony/contracts",
8497
+                    "name": "symfony/contracts"
8498
+                },
8495
                 "branch-alias": {
8499
                 "branch-alias": {
8496
                     "dev-main": "3.5-dev"
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
             "autoload": {
8503
             "autoload": {
8752
             },
8752
             },
8753
             "type": "library",
8753
             "type": "library",
8754
             "extra": {
8754
             "extra": {
8755
+                "thanks": {
8756
+                    "url": "https://github.com/symfony/contracts",
8757
+                    "name": "symfony/contracts"
8758
+                },
8755
                 "branch-alias": {
8759
                 "branch-alias": {
8756
                     "dev-main": "3.5-dev"
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
             "autoload": {
8763
             "autoload": {

+ 17
- 12
database/factories/Accounting/EstimateFactory.php 查看文件

62
 
62
 
63
             $approvedAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
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
             $acceptedAt = Carbon::parse($estimate->approved_at)
76
             $acceptedAt = Carbon::parse($estimate->approved_at)
80
                 ->addDays($this->faker->numberBetween(1, 7));
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
 
122
 
115
             $sentAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
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 查看文件

170
             $draftCount = (int) floor($count * 0.2);     // 20% drafts
170
             $draftCount = (int) floor($count * 0.2);     // 20% drafts
171
             $approvedCount = (int) floor($count * 0.3);   // 30% approved
171
             $approvedCount = (int) floor($count * 0.3);   // 30% approved
172
             $acceptedCount = (int) floor($count * 0.2);  // 20% accepted
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
             // Create draft estimates
177
             // Create draft estimates
177
             Estimate::factory()
178
             Estimate::factory()
216
                     'updated_by' => $company->user_id,
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
             // Create expired estimates (approved but past expiration date)
232
             // Create expired estimates (approved but past expiration date)
220
             Estimate::factory()
233
             Estimate::factory()
221
                 ->count($expiredCount)
234
                 ->count($expiredCount)

database/migrations/2024_12_22_140044_create_estimates_table.php → database/migrations/2024_11_27_223000_create_estimates_table.php 查看文件

24
             $table->date('expiration_date')->nullable();
24
             $table->date('expiration_date')->nullable();
25
             $table->timestamp('approved_at')->nullable();
25
             $table->timestamp('approved_at')->nullable();
26
             $table->timestamp('accepted_at')->nullable();
26
             $table->timestamp('accepted_at')->nullable();
27
+            $table->timestamp('converted_at')->nullable();
27
             $table->timestamp('declined_at')->nullable();
28
             $table->timestamp('declined_at')->nullable();
28
             $table->timestamp('last_sent_at')->nullable();
29
             $table->timestamp('last_sent_at')->nullable();
29
             $table->timestamp('last_viewed_at')->nullable();
30
             $table->timestamp('last_viewed_at')->nullable();

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php 查看文件

15
             $table->id();
15
             $table->id();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->foreignId('estimate_id')->nullable()->constrained('estimates')->nullOnDelete();
18
             $table->string('logo')->nullable();
19
             $table->string('logo')->nullable();
19
             $table->string('header')->nullable();
20
             $table->string('header')->nullable();
20
             $table->string('subheader')->nullable();
21
             $table->string('subheader')->nullable();

+ 127
- 109
package-lock.json 查看文件

30
             }
30
             }
31
         },
31
         },
32
         "node_modules/@esbuild/aix-ppc64": {
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
             "cpu": [
36
             "cpu": [
37
                 "ppc64"
37
                 "ppc64"
38
             ],
38
             ],
47
             }
47
             }
48
         },
48
         },
49
         "node_modules/@esbuild/android-arm": {
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
             "cpu": [
53
             "cpu": [
54
                 "arm"
54
                 "arm"
55
             ],
55
             ],
64
             }
64
             }
65
         },
65
         },
66
         "node_modules/@esbuild/android-arm64": {
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
             "cpu": [
70
             "cpu": [
71
                 "arm64"
71
                 "arm64"
72
             ],
72
             ],
81
             }
81
             }
82
         },
82
         },
83
         "node_modules/@esbuild/android-x64": {
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
             "cpu": [
87
             "cpu": [
88
                 "x64"
88
                 "x64"
89
             ],
89
             ],
98
             }
98
             }
99
         },
99
         },
100
         "node_modules/@esbuild/darwin-arm64": {
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
             "cpu": [
104
             "cpu": [
105
                 "arm64"
105
                 "arm64"
106
             ],
106
             ],
115
             }
115
             }
116
         },
116
         },
117
         "node_modules/@esbuild/darwin-x64": {
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
             "cpu": [
121
             "cpu": [
122
                 "x64"
122
                 "x64"
123
             ],
123
             ],
132
             }
132
             }
133
         },
133
         },
134
         "node_modules/@esbuild/freebsd-arm64": {
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
             "cpu": [
138
             "cpu": [
139
                 "arm64"
139
                 "arm64"
140
             ],
140
             ],
149
             }
149
             }
150
         },
150
         },
151
         "node_modules/@esbuild/freebsd-x64": {
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
             "cpu": [
155
             "cpu": [
156
                 "x64"
156
                 "x64"
157
             ],
157
             ],
166
             }
166
             }
167
         },
167
         },
168
         "node_modules/@esbuild/linux-arm": {
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
             "cpu": [
172
             "cpu": [
173
                 "arm"
173
                 "arm"
174
             ],
174
             ],
183
             }
183
             }
184
         },
184
         },
185
         "node_modules/@esbuild/linux-arm64": {
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
             "cpu": [
189
             "cpu": [
190
                 "arm64"
190
                 "arm64"
191
             ],
191
             ],
200
             }
200
             }
201
         },
201
         },
202
         "node_modules/@esbuild/linux-ia32": {
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
             "cpu": [
206
             "cpu": [
207
                 "ia32"
207
                 "ia32"
208
             ],
208
             ],
217
             }
217
             }
218
         },
218
         },
219
         "node_modules/@esbuild/linux-loong64": {
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
             "cpu": [
223
             "cpu": [
224
                 "loong64"
224
                 "loong64"
225
             ],
225
             ],
234
             }
234
             }
235
         },
235
         },
236
         "node_modules/@esbuild/linux-mips64el": {
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
             "cpu": [
240
             "cpu": [
241
                 "mips64el"
241
                 "mips64el"
242
             ],
242
             ],
251
             }
251
             }
252
         },
252
         },
253
         "node_modules/@esbuild/linux-ppc64": {
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
             "cpu": [
257
             "cpu": [
258
                 "ppc64"
258
                 "ppc64"
259
             ],
259
             ],
268
             }
268
             }
269
         },
269
         },
270
         "node_modules/@esbuild/linux-riscv64": {
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
             "cpu": [
274
             "cpu": [
275
                 "riscv64"
275
                 "riscv64"
276
             ],
276
             ],
285
             }
285
             }
286
         },
286
         },
287
         "node_modules/@esbuild/linux-s390x": {
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
             "cpu": [
291
             "cpu": [
292
                 "s390x"
292
                 "s390x"
293
             ],
293
             ],
302
             }
302
             }
303
         },
303
         },
304
         "node_modules/@esbuild/linux-x64": {
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
             "cpu": [
308
             "cpu": [
309
                 "x64"
309
                 "x64"
310
             ],
310
             ],
318
                 "node": ">=18"
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
         "node_modules/@esbuild/netbsd-x64": {
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
             "cpu": [
342
             "cpu": [
326
                 "x64"
343
                 "x64"
327
             ],
344
             ],
336
             }
353
             }
337
         },
354
         },
338
         "node_modules/@esbuild/openbsd-arm64": {
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
             "cpu": [
359
             "cpu": [
343
                 "arm64"
360
                 "arm64"
344
             ],
361
             ],
353
             }
370
             }
354
         },
371
         },
355
         "node_modules/@esbuild/openbsd-x64": {
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
             "cpu": [
376
             "cpu": [
360
                 "x64"
377
                 "x64"
361
             ],
378
             ],
370
             }
387
             }
371
         },
388
         },
372
         "node_modules/@esbuild/sunos-x64": {
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
             "cpu": [
393
             "cpu": [
377
                 "x64"
394
                 "x64"
378
             ],
395
             ],
387
             }
404
             }
388
         },
405
         },
389
         "node_modules/@esbuild/win32-arm64": {
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
             "cpu": [
410
             "cpu": [
394
                 "arm64"
411
                 "arm64"
395
             ],
412
             ],
404
             }
421
             }
405
         },
422
         },
406
         "node_modules/@esbuild/win32-ia32": {
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
             "cpu": [
427
             "cpu": [
411
                 "ia32"
428
                 "ia32"
412
             ],
429
             ],
421
             }
438
             }
422
         },
439
         },
423
         "node_modules/@esbuild/win32-x64": {
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
             "cpu": [
444
             "cpu": [
428
                 "x64"
445
                 "x64"
429
             ],
446
             ],
1218
             "license": "MIT"
1235
             "license": "MIT"
1219
         },
1236
         },
1220
         "node_modules/electron-to-chromium": {
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
             "dev": true,
1241
             "dev": true,
1225
             "license": "ISC"
1242
             "license": "ISC"
1226
         },
1243
         },
1232
             "license": "MIT"
1249
             "license": "MIT"
1233
         },
1250
         },
1234
         "node_modules/esbuild": {
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
             "dev": true,
1255
             "dev": true,
1239
             "hasInstallScript": true,
1256
             "hasInstallScript": true,
1240
             "license": "MIT",
1257
             "license": "MIT",
1245
                 "node": ">=18"
1262
                 "node": ">=18"
1246
             },
1263
             },
1247
             "optionalDependencies": {
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
         "node_modules/escalade": {
1292
         "node_modules/escalade": {
1312
             }
1330
             }
1313
         },
1331
         },
1314
         "node_modules/fastq": {
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
             "dev": true,
1336
             "dev": true,
1319
             "license": "ISC",
1337
             "license": "ISC",
1320
             "dependencies": {
1338
             "dependencies": {
2606
             "license": "MIT"
2624
             "license": "MIT"
2607
         },
2625
         },
2608
         "node_modules/vite": {
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
             "dev": true,
2630
             "dev": true,
2613
             "license": "MIT",
2631
             "license": "MIT",
2614
             "dependencies": {
2632
             "dependencies": {
2615
-                "esbuild": "0.24.0",
2633
+                "esbuild": "^0.24.2",
2616
                 "postcss": "^8.4.49",
2634
                 "postcss": "^8.4.49",
2617
                 "rollup": "^4.23.0"
2635
                 "rollup": "^4.23.0"
2618
             },
2636
             },

+ 3
- 0
pint.json 查看文件

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

+ 4
- 1
resources/data/lang/en.json 查看文件

213
     "Footer": "Footer",
213
     "Footer": "Footer",
214
     "Invoice Footer": "Invoice Footer",
214
     "Invoice Footer": "Invoice Footer",
215
     "Bill Details": "Bill Details",
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 查看文件

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 查看文件

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 查看文件

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 查看文件


正在加载...
取消
保存