Browse Source

Merge pull request #90 from andrewdwallo/development-3.x

Development 3.x
3.x
Andrew Wallo 9 months ago
parent
commit
3a754e13cf
No account linked to committer's email address
41 changed files with 2949 additions and 728 deletions
  1. 31
    4
      app/Enums/Accounting/DocumentType.php
  2. 34
    0
      app/Enums/Accounting/EstimateStatus.php
  3. 2
    1
      app/Enums/Accounting/InvoiceStatus.php
  4. 1
    1
      app/Filament/Company/Pages/Reports.php
  5. 2
    2
      app/Filament/Company/Resources/Purchases/BillResource.php
  6. 529
    0
      app/Filament/Company/Resources/Sales/EstimateResource.php
  7. 38
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/CreateEstimate.php
  8. 48
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php
  9. 62
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/ListEstimates.php
  10. 96
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php
  11. 70
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php
  12. 13
    9
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  13. 5
    15
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  14. 36
    0
      app/Filament/Infolists/Components/DocumentPreview.php
  15. 2
    2
      app/Filament/Infolists/Components/ReportEntry.php
  16. 39
    20
      app/Models/Accounting/Bill.php
  17. 480
    0
      app/Models/Accounting/Estimate.php
  18. 98
    32
      app/Models/Accounting/Invoice.php
  19. 5
    0
      app/Models/Company.php
  20. 27
    0
      app/Observers/EstimateObserver.php
  21. 0
    3
      app/Observers/InvoiceObserver.php
  22. 69
    0
      app/Policies/EstimatePolicy.php
  23. 2
    0
      app/Providers/FilamentCompaniesServiceProvider.php
  24. 118
    0
      app/View/Models/DocumentPreviewViewModel.php
  25. 55
    53
      composer.lock
  26. 94
    43
      database/factories/Accounting/BillFactory.php
  27. 99
    56
      database/factories/Accounting/DocumentLineItemFactory.php
  28. 211
    0
      database/factories/Accounting/EstimateFactory.php
  29. 125
    43
      database/factories/Accounting/InvoiceFactory.php
  30. 94
    27
      database/factories/CompanyFactory.php
  31. 55
    0
      database/migrations/2024_11_27_223000_create_estimates_table.php
  32. 3
    1
      database/migrations/2024_11_27_223015_create_invoices_table.php
  33. 1
    0
      database/seeders/DatabaseSeeder.php
  34. 218
    197
      package-lock.json
  35. 3
    0
      pint.json
  36. 4
    1
      resources/data/lang/en.json
  37. 2
    2
      resources/views/components/company/tables/reports/account-transactions.blade.php
  38. 0
    169
      resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php
  39. 0
    47
      resources/views/filament/company/resources/sales/invoices/pages/view-invoice.blade.php
  40. 178
    0
      resources/views/filament/infolists/components/document-preview.blade.php
  41. 0
    0
      resources/views/filament/infolists/components/report-entry.blade.php

+ 31
- 4
app/Enums/Accounting/DocumentType.php View File

@@ -9,8 +9,7 @@ enum DocumentType: string implements HasIcon, HasLabel
9 9
 {
10 10
     case Invoice = 'invoice';
11 11
     case Bill = 'bill';
12
-    // TODO: Add estimate
13
-    // case Estimate = 'estimate';
12
+    case Estimate = 'estimate';
14 13
 
15 14
     public const DEFAULT = self::Invoice->value;
16 15
 
@@ -24,13 +23,14 @@ enum DocumentType: string implements HasIcon, HasLabel
24 23
         return match ($this->value) {
25 24
             self::Invoice->value => 'heroicon-o-document-duplicate',
26 25
             self::Bill->value => 'heroicon-o-clipboard-document-list',
26
+            self::Estimate->value => 'heroicon-o-document-text',
27 27
         };
28 28
     }
29 29
 
30 30
     public function getTaxKey(): string
31 31
     {
32 32
         return match ($this) {
33
-            self::Invoice => 'salesTaxes',
33
+            self::Invoice, self::Estimate => 'salesTaxes',
34 34
             self::Bill => 'purchaseTaxes',
35 35
         };
36 36
     }
@@ -38,8 +38,35 @@ enum DocumentType: string implements HasIcon, HasLabel
38 38
     public function getDiscountKey(): string
39 39
     {
40 40
         return match ($this) {
41
-            self::Invoice => 'salesDiscounts',
41
+            self::Invoice, self::Estimate => 'salesDiscounts',
42 42
             self::Bill => 'purchaseDiscounts',
43 43
         };
44 44
     }
45
+
46
+    public function getLabels(): array
47
+    {
48
+        return match ($this) {
49
+            self::Invoice => [
50
+                'title' => 'Invoice',
51
+                'number' => 'Invoice Number',
52
+                'reference_number' => 'P.O/S.O Number',
53
+                'date' => 'Invoice Date',
54
+                'due_date' => 'Payment Due',
55
+            ],
56
+            self::Estimate => [
57
+                'title' => 'Estimate',
58
+                'number' => 'Estimate Number',
59
+                'reference_number' => 'Reference Number',
60
+                'date' => 'Estimate Date',
61
+                'due_date' => 'Expiration Date',
62
+            ],
63
+            self::Bill => [
64
+                'title' => 'Bill',
65
+                'number' => 'Bill Number',
66
+                'reference_number' => 'P.O/S.O Number',
67
+                'date' => 'Bill Date',
68
+                'due_date' => 'Payment Due',
69
+            ],
70
+        };
71
+    }
45 72
 }

+ 34
- 0
app/Enums/Accounting/EstimateStatus.php View File

@@ -0,0 +1,34 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum EstimateStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Sent = 'sent';
12
+    case Viewed = 'viewed';
13
+    case Unsent = 'unsent';
14
+    case Accepted = 'accepted';
15
+    case Declined = 'declined';
16
+    case Expired = 'expired';
17
+    case Converted = 'converted';
18
+
19
+    public function getLabel(): ?string
20
+    {
21
+        return $this->name;
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
+    }
34
+}

+ 2
- 1
app/Enums/Accounting/InvoiceStatus.php View File

@@ -10,6 +10,7 @@ enum InvoiceStatus: string implements HasColor, HasLabel
10 10
     case Draft = 'draft';
11 11
     case Unsent = 'unsent';
12 12
     case Sent = 'sent';
13
+    case Viewed = 'viewed';
13 14
 
14 15
     case Partial = 'partial';
15 16
 
@@ -30,7 +31,7 @@ enum InvoiceStatus: string implements HasColor, HasLabel
30 31
     {
31 32
         return match ($this) {
32 33
             self::Draft, self::Unsent, self::Void => 'gray',
33
-            self::Sent => 'primary',
34
+            self::Sent, self::Viewed => 'primary',
34 35
             self::Partial => 'warning',
35 36
             self::Paid, self::Overpaid => 'success',
36 37
             self::Overdue => 'danger',

+ 1
- 1
app/Filament/Company/Pages/Reports.php View File

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

+ 2
- 2
app/Filament/Company/Resources/Purchases/BillResource.php View File

@@ -426,9 +426,9 @@ class BillResource extends Resource
426 426
                         ->failureNotificationTitle('Failed to Record Payments')
427 427
                         ->deselectRecordsAfterCompletion()
428 428
                         ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
429
-                            $cantRecordPayments = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
429
+                            $isInvalid = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
430 430
 
431
-                            if ($cantRecordPayments) {
431
+                            if ($isInvalid) {
432 432
                                 Notification::make()
433 433
                                     ->title('Payment Recording Failed')
434 434
                                     ->body('Bills that are either paid, voided, or are in a foreign currency cannot be processed through bulk payments. Please adjust your selection and try again.')

+ 529
- 0
app/Filament/Company/Resources/Sales/EstimateResource.php View File

@@ -0,0 +1,529 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Enums\Accounting\DocumentDiscountMethod;
6
+use App\Enums\Accounting\DocumentType;
7
+use App\Enums\Accounting\EstimateStatus;
8
+use App\Filament\Company\Resources\Sales\EstimateResource\Pages;
9
+use App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
10
+use App\Filament\Forms\Components\CreateCurrencySelect;
11
+use App\Filament\Forms\Components\DocumentTotals;
12
+use App\Filament\Tables\Actions\ReplicateBulkAction;
13
+use App\Filament\Tables\Filters\DateRangeFilter;
14
+use App\Models\Accounting\Adjustment;
15
+use App\Models\Accounting\Estimate;
16
+use App\Models\Common\Client;
17
+use App\Models\Common\Offering;
18
+use App\Utilities\Currency\CurrencyAccessor;
19
+use App\Utilities\Currency\CurrencyConverter;
20
+use App\Utilities\RateCalculator;
21
+use Awcodes\TableRepeater\Components\TableRepeater;
22
+use Awcodes\TableRepeater\Header;
23
+use Filament\Forms;
24
+use Filament\Forms\Components\FileUpload;
25
+use Filament\Forms\Form;
26
+use Filament\Notifications\Notification;
27
+use Filament\Resources\Resource;
28
+use Filament\Support\Enums\MaxWidth;
29
+use Filament\Tables;
30
+use Filament\Tables\Table;
31
+use Illuminate\Database\Eloquent\Collection;
32
+use Illuminate\Support\Facades\Auth;
33
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
34
+
35
+class EstimateResource extends Resource
36
+{
37
+    protected static ?string $model = Estimate::class;
38
+
39
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
40
+
41
+    public static function form(Form $form): Form
42
+    {
43
+        $company = Auth::user()->currentCompany;
44
+
45
+        return $form
46
+            ->schema([
47
+                Forms\Components\Section::make('Estimate Header')
48
+                    ->collapsible()
49
+                    ->collapsed()
50
+                    ->schema([
51
+                        Forms\Components\Split::make([
52
+                            Forms\Components\Group::make([
53
+                                FileUpload::make('logo')
54
+                                    ->openable()
55
+                                    ->maxSize(1024)
56
+                                    ->localizeLabel()
57
+                                    ->visibility('public')
58
+                                    ->disk('public')
59
+                                    ->directory('logos/document')
60
+                                    ->imageResizeMode('contain')
61
+                                    ->imageCropAspectRatio('3:2')
62
+                                    ->panelAspectRatio('3:2')
63
+                                    ->maxWidth(MaxWidth::ExtraSmall)
64
+                                    ->panelLayout('integrated')
65
+                                    ->removeUploadedFileButtonPosition('center bottom')
66
+                                    ->uploadButtonPosition('center bottom')
67
+                                    ->uploadProgressIndicatorPosition('center bottom')
68
+                                    ->getUploadedFileNameForStorageUsing(
69
+                                        static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
70
+                                            ->prepend(Auth::user()->currentCompany->id . '_'),
71
+                                    )
72
+                                    ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
73
+                            ]),
74
+                            Forms\Components\Group::make([
75
+                                Forms\Components\TextInput::make('header')
76
+                                    ->default('Estimate'),
77
+                                Forms\Components\TextInput::make('subheader'),
78
+                                Forms\Components\View::make('filament.forms.components.company-info')
79
+                                    ->viewData([
80
+                                        'company_name' => $company->name,
81
+                                        'company_address' => $company->profile->address,
82
+                                        'company_city' => $company->profile->city?->name,
83
+                                        'company_state' => $company->profile->state?->name,
84
+                                        'company_zip' => $company->profile->zip_code,
85
+                                        'company_country' => $company->profile->state?->country->name,
86
+                                    ]),
87
+                            ])->grow(true),
88
+                        ])->from('md'),
89
+                    ]),
90
+                Forms\Components\Section::make('Estimate Details')
91
+                    ->schema([
92
+                        Forms\Components\Split::make([
93
+                            Forms\Components\Group::make([
94
+                                Forms\Components\Select::make('client_id')
95
+                                    ->relationship('client', 'name')
96
+                                    ->preload()
97
+                                    ->searchable()
98
+                                    ->required()
99
+                                    ->live()
100
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
101
+                                        if (! $state) {
102
+                                            return;
103
+                                        }
104
+
105
+                                        $currencyCode = Client::find($state)?->currency_code;
106
+
107
+                                        if ($currencyCode) {
108
+                                            $set('currency_code', $currencyCode);
109
+                                        }
110
+                                    }),
111
+                                CreateCurrencySelect::make('currency_code'),
112
+                            ]),
113
+                            Forms\Components\Group::make([
114
+                                Forms\Components\TextInput::make('estimate_number')
115
+                                    ->label('Estimate Number')
116
+                                    ->default(fn () => Estimate::getNextDocumentNumber()),
117
+                                Forms\Components\TextInput::make('reference_number')
118
+                                    ->label('Reference Number'),
119
+                                Forms\Components\DatePicker::make('date')
120
+                                    ->label('Estimate Date')
121
+                                    ->live()
122
+                                    ->default(now())
123
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
124
+                                        $date = $state;
125
+                                        $expirationDate = $get('expiration_date');
126
+
127
+                                        if ($date && $expirationDate && $date > $expirationDate) {
128
+                                            $set('expiration_date', $date);
129
+                                        }
130
+                                    }),
131
+                                Forms\Components\DatePicker::make('expiration_date')
132
+                                    ->label('Expiration Date')
133
+                                    ->default(function () use ($company) {
134
+                                        return now()->addDays($company->defaultInvoice->payment_terms->getDays());
135
+                                    })
136
+                                    ->minDate(static function (Forms\Get $get) {
137
+                                        return $get('date') ?? now();
138
+                                    }),
139
+                                Forms\Components\Select::make('discount_method')
140
+                                    ->label('Discount Method')
141
+                                    ->options(DocumentDiscountMethod::class)
142
+                                    ->selectablePlaceholder(false)
143
+                                    ->default(DocumentDiscountMethod::PerLineItem)
144
+                                    ->afterStateUpdated(function ($state, Forms\Set $set) {
145
+                                        $discountMethod = DocumentDiscountMethod::parse($state);
146
+
147
+                                        if ($discountMethod->isPerDocument()) {
148
+                                            $set('lineItems.*.salesDiscounts', []);
149
+                                        }
150
+                                    })
151
+                                    ->live(),
152
+                            ])->grow(true),
153
+                        ])->from('md'),
154
+                        TableRepeater::make('lineItems')
155
+                            ->relationship()
156
+                            ->saveRelationshipsUsing(null)
157
+                            ->dehydrated(true)
158
+                            ->headers(function (Forms\Get $get) {
159
+                                $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
160
+
161
+                                $headers = [
162
+                                    Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
163
+                                    Header::make('Description')->width($hasDiscounts ? '25%' : '30%'),  // Increase when no discounts
164
+                                    Header::make('Quantity')->width('10%'),
165
+                                    Header::make('Price')->width('10%'),
166
+                                    Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'),       // Increase when no discounts
167
+                                ];
168
+
169
+                                if ($hasDiscounts) {
170
+                                    $headers[] = Header::make('Discounts')->width('15%');
171
+                                }
172
+
173
+                                $headers[] = Header::make('Amount')->width('10%')->align('right');
174
+
175
+                                return $headers;
176
+                            })
177
+                            ->schema([
178
+                                Forms\Components\Select::make('offering_id')
179
+                                    ->relationship('sellableOffering', 'name')
180
+                                    ->preload()
181
+                                    ->searchable()
182
+                                    ->required()
183
+                                    ->live()
184
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
185
+                                        $offeringId = $state;
186
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
187
+
188
+                                        if ($offeringRecord) {
189
+                                            $set('description', $offeringRecord->description);
190
+                                            $set('unit_price', $offeringRecord->price);
191
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
192
+
193
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
194
+                                            if ($discountMethod->isPerLineItem()) {
195
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
196
+                                            }
197
+                                        }
198
+                                    }),
199
+                                Forms\Components\TextInput::make('description'),
200
+                                Forms\Components\TextInput::make('quantity')
201
+                                    ->required()
202
+                                    ->numeric()
203
+                                    ->live()
204
+                                    ->default(1),
205
+                                Forms\Components\TextInput::make('unit_price')
206
+                                    ->hiddenLabel()
207
+                                    ->numeric()
208
+                                    ->live()
209
+                                    ->default(0),
210
+                                Forms\Components\Select::make('salesTaxes')
211
+                                    ->relationship('salesTaxes', 'name')
212
+                                    ->saveRelationshipsUsing(null)
213
+                                    ->dehydrated(true)
214
+                                    ->preload()
215
+                                    ->multiple()
216
+                                    ->live()
217
+                                    ->searchable(),
218
+                                Forms\Components\Select::make('salesDiscounts')
219
+                                    ->relationship('salesDiscounts', 'name')
220
+                                    ->saveRelationshipsUsing(null)
221
+                                    ->dehydrated(true)
222
+                                    ->preload()
223
+                                    ->multiple()
224
+                                    ->live()
225
+                                    ->hidden(function (Forms\Get $get) {
226
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
227
+
228
+                                        return $discountMethod->isPerDocument();
229
+                                    })
230
+                                    ->searchable(),
231
+                                Forms\Components\Placeholder::make('total')
232
+                                    ->hiddenLabel()
233
+                                    ->extraAttributes(['class' => 'text-left sm:text-right'])
234
+                                    ->content(function (Forms\Get $get) {
235
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
236
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
237
+                                        $salesTaxes = $get('salesTaxes') ?? [];
238
+                                        $salesDiscounts = $get('salesDiscounts') ?? [];
239
+                                        $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
240
+
241
+                                        $subtotal = $quantity * $unitPrice;
242
+
243
+                                        $subtotalInCents = CurrencyConverter::convertToCents($subtotal, $currencyCode);
244
+
245
+                                        $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
246
+                                            ->get()
247
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
248
+                                                if ($adjustment->computation->isPercentage()) {
249
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
250
+                                                } else {
251
+                                                    return $adjustment->getRawOriginal('rate');
252
+                                                }
253
+                                            });
254
+
255
+                                        $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
256
+                                            ->get()
257
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
258
+                                                if ($adjustment->computation->isPercentage()) {
259
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
260
+                                                } else {
261
+                                                    return $adjustment->getRawOriginal('rate');
262
+                                                }
263
+                                            });
264
+
265
+                                        // Final total
266
+                                        $totalInCents = $subtotalInCents + ($taxAmountInCents - $discountAmountInCents);
267
+
268
+                                        return CurrencyConverter::formatCentsToMoney($totalInCents, $currencyCode);
269
+                                    }),
270
+                            ]),
271
+                        DocumentTotals::make()
272
+                            ->type(DocumentType::Estimate),
273
+                        Forms\Components\Textarea::make('terms')
274
+                            ->columnSpanFull(),
275
+                    ]),
276
+                Forms\Components\Section::make('Estimate Footer')
277
+                    ->collapsible()
278
+                    ->collapsed()
279
+                    ->schema([
280
+                        Forms\Components\Textarea::make('footer')
281
+                            ->columnSpanFull(),
282
+                    ]),
283
+            ]);
284
+    }
285
+
286
+    public static function table(Table $table): Table
287
+    {
288
+        return $table
289
+            ->defaultSort('expiration_date')
290
+            ->columns([
291
+                Tables\Columns\TextColumn::make('id')
292
+                    ->label('ID')
293
+                    ->sortable()
294
+                    ->toggleable(isToggledHiddenByDefault: true)
295
+                    ->searchable(),
296
+                Tables\Columns\TextColumn::make('status')
297
+                    ->badge()
298
+                    ->searchable(),
299
+                Tables\Columns\TextColumn::make('expiration_date')
300
+                    ->label('Expiration Date')
301
+                    ->asRelativeDay()
302
+                    ->sortable(),
303
+                Tables\Columns\TextColumn::make('date')
304
+                    ->date()
305
+                    ->sortable(),
306
+                Tables\Columns\TextColumn::make('estimate_number')
307
+                    ->label('Number')
308
+                    ->searchable()
309
+                    ->sortable(),
310
+                Tables\Columns\TextColumn::make('client.name')
311
+                    ->sortable()
312
+                    ->searchable(),
313
+                Tables\Columns\TextColumn::make('total')
314
+                    ->currencyWithConversion(static fn (Estimate $record) => $record->currency_code)
315
+                    ->sortable()
316
+                    ->toggleable(),
317
+            ])
318
+            ->filters([
319
+                Tables\Filters\SelectFilter::make('client')
320
+                    ->relationship('client', 'name')
321
+                    ->searchable()
322
+                    ->preload(),
323
+                Tables\Filters\SelectFilter::make('status')
324
+                    ->options(EstimateStatus::class)
325
+                    ->native(false),
326
+                DateRangeFilter::make('date')
327
+                    ->fromLabel('From Date')
328
+                    ->untilLabel('To Date')
329
+                    ->indicatorLabel('Date'),
330
+                DateRangeFilter::make('expiration_date')
331
+                    ->fromLabel('From Expiration Date')
332
+                    ->untilLabel('To Expiration Date')
333
+                    ->indicatorLabel('Due'),
334
+            ])
335
+            ->actions([
336
+                Tables\Actions\ActionGroup::make([
337
+                    Tables\Actions\EditAction::make(),
338
+                    Tables\Actions\ViewAction::make(),
339
+                    Tables\Actions\DeleteAction::make(),
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),
346
+                ]),
347
+            ])
348
+            ->bulkActions([
349
+                Tables\Actions\BulkActionGroup::make([
350
+                    Tables\Actions\DeleteBulkAction::make(),
351
+                    ReplicateBulkAction::make()
352
+                        ->label('Replicate')
353
+                        ->modalWidth(MaxWidth::Large)
354
+                        ->modalDescription('Replicating estimates will also replicate their line items. Are you sure you want to proceed?')
355
+                        ->successNotificationTitle('Estimates Replicated Successfully')
356
+                        ->failureNotificationTitle('Failed to Replicate Estimates')
357
+                        ->databaseTransaction()
358
+                        ->deselectRecordsAfterCompletion()
359
+                        ->excludeAttributes([
360
+                            'estimate_number',
361
+                            'date',
362
+                            'expiration_date',
363
+                            'approved_at',
364
+                            'accepted_at',
365
+                            'converted_at',
366
+                            'declined_at',
367
+                            'last_sent_at',
368
+                            'last_viewed_at',
369
+                            'status',
370
+                            'created_by',
371
+                            'updated_by',
372
+                            'created_at',
373
+                            'updated_at',
374
+                        ])
375
+                        ->beforeReplicaSaved(function (Estimate $replica) {
376
+                            $replica->status = EstimateStatus::Draft;
377
+                            $replica->estimate_number = Estimate::getNextDocumentNumber();
378
+                            $replica->date = now();
379
+                            $replica->expiration_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
380
+                        })
381
+                        ->withReplicatedRelationships(['lineItems'])
382
+                        ->withExcludedRelationshipAttributes('lineItems', [
383
+                            'subtotal',
384
+                            'total',
385
+                            'created_by',
386
+                            'updated_by',
387
+                            'created_at',
388
+                            'updated_at',
389
+                        ]),
390
+                    Tables\Actions\BulkAction::make('approveDrafts')
391
+                        ->label('Approve')
392
+                        ->icon('heroicon-o-check-circle')
393
+                        ->databaseTransaction()
394
+                        ->successNotificationTitle('Estimates Approved')
395
+                        ->failureNotificationTitle('Failed to Approve Estimates')
396
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
397
+                            $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeApproved());
398
+
399
+                            if ($isInvalid) {
400
+                                Notification::make()
401
+                                    ->title('Approval Failed')
402
+                                    ->body('Only draft estimates can be approved. Please adjust your selection and try again.')
403
+                                    ->persistent()
404
+                                    ->danger()
405
+                                    ->send();
406
+
407
+                                $action->cancel(true);
408
+                            }
409
+                        })
410
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
411
+                            $records->each(function (Estimate $record) {
412
+                                $record->approveDraft();
413
+                            });
414
+
415
+                            $action->success();
416
+                        }),
417
+                    Tables\Actions\BulkAction::make('markAsSent')
418
+                        ->label('Mark as Sent')
419
+                        ->icon('heroicon-o-paper-airplane')
420
+                        ->databaseTransaction()
421
+                        ->successNotificationTitle('Estimates Sent')
422
+                        ->failureNotificationTitle('Failed to Mark Estimates as Sent')
423
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
424
+                            $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsSent());
425
+
426
+                            if ($isInvalid) {
427
+                                Notification::make()
428
+                                    ->title('Sending Failed')
429
+                                    ->body('Only unsent estimates can be marked as sent. Please adjust your selection and try again.')
430
+                                    ->persistent()
431
+                                    ->danger()
432
+                                    ->send();
433
+
434
+                                $action->cancel(true);
435
+                            }
436
+                        })
437
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
438
+                            $records->each(function (Estimate $record) {
439
+                                $record->markAsSent();
440
+                            });
441
+
442
+                            $action->success();
443
+                        }),
444
+                    Tables\Actions\BulkAction::make('markAsAccepted')
445
+                        ->label('Mark as Accepted')
446
+                        ->icon('heroicon-o-check-badge')
447
+                        ->databaseTransaction()
448
+                        ->successNotificationTitle('Estimates Accepted')
449
+                        ->failureNotificationTitle('Failed to Mark Estimates as Accepted')
450
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
451
+                            $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsAccepted());
452
+
453
+                            if ($isInvalid) {
454
+                                Notification::make()
455
+                                    ->title('Acceptance Failed')
456
+                                    ->body('Only sent estimates that haven\'t been accepted can be marked as accepted. Please adjust your selection and try again.')
457
+                                    ->persistent()
458
+                                    ->danger()
459
+                                    ->send();
460
+
461
+                                $action->cancel(true);
462
+                            }
463
+                        })
464
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
465
+                            $records->each(function (Estimate $record) {
466
+                                $record->markAsAccepted();
467
+                            });
468
+
469
+                            $action->success();
470
+                        }),
471
+                    Tables\Actions\BulkAction::make('markAsDeclined')
472
+                        ->label('Mark as Declined')
473
+                        ->icon('heroicon-o-x-circle')
474
+                        ->requiresConfirmation()
475
+                        ->databaseTransaction()
476
+                        ->color('danger')
477
+                        ->modalHeading('Mark Estimates as Declined')
478
+                        ->modalDescription('Are you sure you want to mark the selected estimates as declined? This action cannot be undone.')
479
+                        ->successNotificationTitle('Estimates Declined')
480
+                        ->failureNotificationTitle('Failed to Mark Estimates as Declined')
481
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
482
+                            $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsDeclined());
483
+
484
+                            if ($isInvalid) {
485
+                                Notification::make()
486
+                                    ->title('Declination Failed')
487
+                                    ->body('Only sent estimates that haven\'t been declined can be marked as declined. Please adjust your selection and try again.')
488
+                                    ->persistent()
489
+                                    ->danger()
490
+                                    ->send();
491
+
492
+                                $action->cancel(true);
493
+                            }
494
+                        })
495
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
496
+                            $records->each(function (Estimate $record) {
497
+                                $record->markAsDeclined();
498
+                            });
499
+
500
+                            $action->success();
501
+                        }),
502
+                ]),
503
+            ]);
504
+    }
505
+
506
+    public static function getRelations(): array
507
+    {
508
+        return [
509
+            //
510
+        ];
511
+    }
512
+
513
+    public static function getPages(): array
514
+    {
515
+        return [
516
+            'index' => Pages\ListEstimates::route('/'),
517
+            'create' => Pages\CreateEstimate::route('/create'),
518
+            'view' => Pages\ViewEstimate::route('/{record}'),
519
+            'edit' => Pages\EditEstimate::route('/{record}/edit'),
520
+        ];
521
+    }
522
+
523
+    public static function getWidgets(): array
524
+    {
525
+        return [
526
+            Widgets\EstimateOverview::class,
527
+        ];
528
+    }
529
+}

+ 38
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/CreateEstimate.php View File

@@ -0,0 +1,38 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Concerns\RedirectToListPage;
7
+use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Models\Accounting\Estimate;
9
+use Filament\Resources\Pages\CreateRecord;
10
+use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
12
+
13
+class CreateEstimate extends CreateRecord
14
+{
15
+    use ManagesLineItems;
16
+    use RedirectToListPage;
17
+
18
+    protected static string $resource = EstimateResource::class;
19
+
20
+    public function getMaxContentWidth(): MaxWidth | string | null
21
+    {
22
+        return MaxWidth::Full;
23
+    }
24
+
25
+    protected function handleRecordCreation(array $data): Model
26
+    {
27
+        /** @var Estimate $record */
28
+        $record = parent::handleRecordCreation($data);
29
+
30
+        $this->handleLineItems($record, collect($data['lineItems'] ?? []));
31
+
32
+        $totals = $this->updateDocumentTotals($record, $data);
33
+
34
+        $record->updateQuietly($totals);
35
+
36
+        return $record;
37
+    }
38
+}

+ 48
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/EditEstimate.php View File

@@ -0,0 +1,48 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Concerns\RedirectToListPage;
7
+use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Models\Accounting\Estimate;
9
+use Filament\Actions;
10
+use Filament\Resources\Pages\EditRecord;
11
+use Filament\Support\Enums\MaxWidth;
12
+use Illuminate\Database\Eloquent\Model;
13
+
14
+class EditEstimate extends EditRecord
15
+{
16
+    use ManagesLineItems;
17
+    use RedirectToListPage;
18
+
19
+    protected static string $resource = EstimateResource::class;
20
+
21
+    protected function getHeaderActions(): array
22
+    {
23
+        return [
24
+            Actions\DeleteAction::make(),
25
+        ];
26
+    }
27
+
28
+    public function getMaxContentWidth(): MaxWidth | string | null
29
+    {
30
+        return MaxWidth::Full;
31
+    }
32
+
33
+    protected function handleRecordUpdate(Model $record, array $data): Model
34
+    {
35
+        /** @var Estimate $record */
36
+        $lineItems = collect($data['lineItems'] ?? []);
37
+
38
+        $this->deleteRemovedLineItems($record, $lineItems);
39
+
40
+        $this->handleLineItems($record, $lineItems);
41
+
42
+        $totals = $this->updateDocumentTotals($record, $data);
43
+
44
+        $data = array_merge($data, $totals);
45
+
46
+        return parent::handleRecordUpdate($record, $data);
47
+    }
48
+}

+ 62
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ListEstimates.php View File

@@ -0,0 +1,62 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4
+
5
+use App\Enums\Accounting\EstimateStatus;
6
+use App\Filament\Company\Resources\Sales\EstimateResource;
7
+use App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
8
+use App\Models\Accounting\Estimate;
9
+use Filament\Actions;
10
+use Filament\Pages\Concerns\ExposesTableToWidgets;
11
+use Filament\Resources\Components\Tab;
12
+use Filament\Resources\Pages\ListRecords;
13
+use Filament\Support\Enums\MaxWidth;
14
+use Illuminate\Database\Eloquent\Builder;
15
+
16
+class ListEstimates extends ListRecords
17
+{
18
+    use ExposesTableToWidgets;
19
+
20
+    protected static string $resource = EstimateResource::class;
21
+
22
+    protected function getHeaderActions(): array
23
+    {
24
+        return [
25
+            Actions\CreateAction::make(),
26
+        ];
27
+    }
28
+
29
+    protected function getHeaderWidgets(): array
30
+    {
31
+        return [
32
+            Widgets\EstimateOverview::make(),
33
+        ];
34
+    }
35
+
36
+    public function getMaxContentWidth(): MaxWidth | string | null
37
+    {
38
+        return 'max-w-8xl';
39
+    }
40
+
41
+    public function getTabs(): array
42
+    {
43
+        return [
44
+            'all' => Tab::make()
45
+                ->label('All'),
46
+
47
+            'active' => Tab::make()
48
+                ->label('Active')
49
+                ->modifyQueryUsing(function (Builder $query) {
50
+                    $query->active();
51
+                })
52
+                ->badge(Estimate::active()->count()),
53
+
54
+            'draft' => Tab::make()
55
+                ->label('Draft')
56
+                ->modifyQueryUsing(function (Builder $query) {
57
+                    $query->where('status', EstimateStatus::Draft);
58
+                })
59
+                ->badge(Estimate::where('status', EstimateStatus::Draft)->count()),
60
+        ];
61
+    }
62
+}

+ 96
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php View File

@@ -0,0 +1,96 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\EstimateResource\Pages;
4
+
5
+use App\Enums\Accounting\DocumentType;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use App\Filament\Company\Resources\Sales\EstimateResource;
8
+use App\Filament\Infolists\Components\DocumentPreview;
9
+use App\Models\Accounting\Estimate;
10
+use Filament\Actions;
11
+use Filament\Infolists\Components\Grid;
12
+use Filament\Infolists\Components\Section;
13
+use Filament\Infolists\Components\TextEntry;
14
+use Filament\Infolists\Infolist;
15
+use Filament\Resources\Pages\ViewRecord;
16
+use Filament\Support\Enums\FontWeight;
17
+use Filament\Support\Enums\IconPosition;
18
+use Filament\Support\Enums\IconSize;
19
+use Filament\Support\Enums\MaxWidth;
20
+
21
+class ViewEstimate extends ViewRecord
22
+{
23
+    protected static string $resource = EstimateResource::class;
24
+
25
+    protected $listeners = [
26
+        'refresh' => '$refresh',
27
+    ];
28
+
29
+    public function getMaxContentWidth(): MaxWidth | string | null
30
+    {
31
+        return MaxWidth::SixExtraLarge;
32
+    }
33
+
34
+    protected function getHeaderActions(): array
35
+    {
36
+        return [
37
+            Actions\ActionGroup::make([
38
+                Actions\EditAction::make(),
39
+                Actions\DeleteAction::make(),
40
+                Estimate::getApproveDraftAction(),
41
+                Estimate::getMarkAsSentAction(),
42
+                Estimate::getMarkAsAcceptedAction(),
43
+                Estimate::getMarkAsDeclinedAction(),
44
+                Estimate::getReplicateAction(),
45
+                Estimate::getConvertToInvoiceAction(),
46
+            ])
47
+                ->label('Actions')
48
+                ->button()
49
+                ->outlined()
50
+                ->dropdownPlacement('bottom-end')
51
+                ->icon('heroicon-c-chevron-down')
52
+                ->iconSize(IconSize::Small)
53
+                ->iconPosition(IconPosition::After),
54
+        ];
55
+    }
56
+
57
+    public function infolist(Infolist $infolist): Infolist
58
+    {
59
+        return $infolist
60
+            ->schema([
61
+                Section::make('Estimate Details')
62
+                    ->columns(4)
63
+                    ->schema([
64
+                        Grid::make(1)
65
+                            ->schema([
66
+                                TextEntry::make('estimate_number')
67
+                                    ->label('Estimate #'),
68
+                                TextEntry::make('status')
69
+                                    ->badge(),
70
+                                TextEntry::make('client.name')
71
+                                    ->label('Client')
72
+                                    ->color('primary')
73
+                                    ->weight(FontWeight::SemiBold)
74
+                                    ->url(static fn (Estimate $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
75
+                                TextEntry::make('expiration_date')
76
+                                    ->label('Expiration Date')
77
+                                    ->asRelativeDay(),
78
+                                TextEntry::make('approved_at')
79
+                                    ->label('Approved At')
80
+                                    ->placeholder('Not Approved')
81
+                                    ->date(),
82
+                                TextEntry::make('last_sent_at')
83
+                                    ->label('Last Sent')
84
+                                    ->placeholder('Never')
85
+                                    ->date(),
86
+                                TextEntry::make('accepted_at')
87
+                                    ->label('Accepted At')
88
+                                    ->placeholder('Not Accepted')
89
+                                    ->date(),
90
+                            ])->columnSpan(1),
91
+                        DocumentPreview::make()
92
+                            ->type(DocumentType::Estimate),
93
+                    ]),
94
+            ]);
95
+    }
96
+}

+ 70
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php View File

@@ -0,0 +1,70 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
4
+
5
+use App\Enums\Accounting\EstimateStatus;
6
+use App\Filament\Company\Resources\Sales\EstimateResource\Pages\ListEstimates;
7
+use App\Filament\Widgets\EnhancedStatsOverviewWidget;
8
+use App\Utilities\Currency\CurrencyAccessor;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Widgets\Concerns\InteractsWithPageTable;
11
+use Illuminate\Support\Number;
12
+
13
+class EstimateOverview extends EnhancedStatsOverviewWidget
14
+{
15
+    use InteractsWithPageTable;
16
+
17
+    protected function getTablePage(): string
18
+    {
19
+        return ListEstimates::class;
20
+    }
21
+
22
+    protected function getStats(): array
23
+    {
24
+        $activeEstimates = $this->getPageTableQuery()->active();
25
+
26
+        $totalActiveCount = $activeEstimates->count();
27
+        $totalActiveAmount = $activeEstimates->get()->sumMoneyInDefaultCurrency('total');
28
+
29
+        $acceptedEstimates = $this->getPageTableQuery()
30
+            ->where('status', EstimateStatus::Accepted);
31
+
32
+        $totalAcceptedCount = $acceptedEstimates->count();
33
+        $totalAcceptedAmount = $acceptedEstimates->get()->sumMoneyInDefaultCurrency('total');
34
+
35
+        $convertedEstimates = $this->getPageTableQuery()
36
+            ->where('status', EstimateStatus::Converted);
37
+
38
+        $totalConvertedCount = $convertedEstimates->count();
39
+        $totalEstimatesCount = $this->getPageTableQuery()->count();
40
+
41
+        $percentConverted = $totalEstimatesCount > 0
42
+            ? Number::percentage(($totalConvertedCount / $totalEstimatesCount) * 100, maxPrecision: 1)
43
+            : Number::percentage(0, maxPrecision: 1);
44
+
45
+        $totalEstimateAmount = $this->getPageTableQuery()
46
+            ->get()
47
+            ->sumMoneyInDefaultCurrency('total');
48
+
49
+        $averageEstimateTotal = $totalEstimatesCount > 0
50
+            ? (int) round($totalEstimateAmount / $totalEstimatesCount)
51
+            : 0;
52
+
53
+        return [
54
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Active Estimates', CurrencyConverter::formatCentsToMoney($totalActiveAmount))
55
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
56
+                ->description($totalActiveCount . ' active estimates'),
57
+
58
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Accepted Estimates', CurrencyConverter::formatCentsToMoney($totalAcceptedAmount))
59
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
60
+                ->description($totalAcceptedCount . ' accepted'),
61
+
62
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Converted Estimates', $percentConverted)
63
+                ->suffix('converted')
64
+                ->description($totalConvertedCount . ' converted'),
65
+
66
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Estimate Total', CurrencyConverter::formatCentsToMoney($averageEstimateTotal))
67
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
68
+        ];
69
+    }
70
+}

+ 13
- 9
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

@@ -53,6 +53,7 @@ class InvoiceResource extends Resource
53 53
             ->schema([
54 54
                 Forms\Components\Section::make('Invoice Header')
55 55
                     ->collapsible()
56
+                    ->collapsed()
56 57
                     ->schema([
57 58
                         Forms\Components\Split::make([
58 59
                             Forms\Components\Group::make([
@@ -285,6 +286,7 @@ class InvoiceResource extends Resource
285 286
                     ]),
286 287
                 Forms\Components\Section::make('Invoice Footer')
287 288
                     ->collapsible()
289
+                    ->collapsed()
288 290
                     ->schema([
289 291
                         Forms\Components\Textarea::make('footer')
290 292
                             ->columnSpanFull(),
@@ -466,6 +468,10 @@ class InvoiceResource extends Resource
466 468
                             'invoice_number',
467 469
                             'date',
468 470
                             'due_date',
471
+                            'approved_at',
472
+                            'paid_at',
473
+                            'last_sent_at',
474
+                            'last_viewed_at',
469 475
                         ])
470 476
                         ->beforeReplicaSaved(function (Invoice $replica) {
471 477
                             $replica->status = InvoiceStatus::Draft;
@@ -489,9 +495,9 @@ class InvoiceResource extends Resource
489 495
                         ->successNotificationTitle('Invoices Approved')
490 496
                         ->failureNotificationTitle('Failed to Approve Invoices')
491 497
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
492
-                            $containsNonDrafts = $records->contains(fn (Invoice $record) => ! $record->isDraft());
498
+                            $isInvalid = $records->contains(fn (Invoice $record) => ! $record->canBeApproved());
493 499
 
494
-                            if ($containsNonDrafts) {
500
+                            if ($isInvalid) {
495 501
                                 Notification::make()
496 502
                                     ->title('Approval Failed')
497 503
                                     ->body('Only draft invoices can be approved. Please adjust your selection and try again.')
@@ -516,9 +522,9 @@ class InvoiceResource extends Resource
516 522
                         ->successNotificationTitle('Invoices Sent')
517 523
                         ->failureNotificationTitle('Failed to Mark Invoices as Sent')
518 524
                         ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
519
-                            $doesntContainUnsent = $records->contains(fn (Invoice $record) => $record->status !== InvoiceStatus::Unsent);
525
+                            $isInvalid = $records->contains(fn (Invoice $record) => ! $record->canBeMarkedAsSent());
520 526
 
521
-                            if ($doesntContainUnsent) {
527
+                            if ($isInvalid) {
522 528
                                 Notification::make()
523 529
                                     ->title('Sending Failed')
524 530
                                     ->body('Only unsent invoices can be marked as sent. Please adjust your selection and try again.')
@@ -531,9 +537,7 @@ class InvoiceResource extends Resource
531 537
                         })
532 538
                         ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
533 539
                             $records->each(function (Invoice $record) {
534
-                                $record->updateQuietly([
535
-                                    'status' => InvoiceStatus::Sent,
536
-                                ]);
540
+                                $record->markAsSent();
537 541
                             });
538 542
 
539 543
                             $action->success();
@@ -550,9 +554,9 @@ class InvoiceResource extends Resource
550 554
                         ->failureNotificationTitle('Failed to Record Payments')
551 555
                         ->deselectRecordsAfterCompletion()
552 556
                         ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
553
-                            $cantRecordPayments = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
557
+                            $isInvalid = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
554 558
 
555
-                            if ($cantRecordPayments) {
559
+                            if ($isInvalid) {
556 560
                                 Notification::make()
557 561
                                     ->title('Payment Recording Failed')
558 562
                                     ->body('Invoices that are either draft, paid, overpaid, voided, or are in a foreign currency cannot be processed through bulk payments. Please adjust your selection and try again.')

+ 5
- 15
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php View File

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

+ 36
- 0
app/Filament/Infolists/Components/DocumentPreview.php View File

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

app/Infolists/Components/ReportEntry.php → app/Filament/Infolists/Components/ReportEntry.php View File

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

+ 39
- 20
app/Models/Accounting/Bill.php View File

@@ -32,8 +32,8 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
32 32
 use Illuminate\Database\Eloquent\Relations\MorphOne;
33 33
 use Illuminate\Support\Carbon;
34 34
 
35
-#[ObservedBy(BillObserver::class)]
36 35
 #[CollectedBy(DocumentCollection::class)]
36
+#[ObservedBy(BillObserver::class)]
37 37
 class Bill extends Model
38 38
 {
39 39
     use Blamable;
@@ -129,6 +129,16 @@ class Bill extends Model
129 129
         });
130 130
     }
131 131
 
132
+    public function wasInitialized(): bool
133
+    {
134
+        return $this->hasInitialTransaction();
135
+    }
136
+
137
+    public function isPaid(): bool
138
+    {
139
+        return $this->paid_at !== null;
140
+    }
141
+
132 142
     public function canBeOverdue(): bool
133 143
     {
134 144
         return in_array($this->status, BillStatus::canBeOverdue());
@@ -142,6 +152,11 @@ class Bill extends Model
142 152
         ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
143 153
     }
144 154
 
155
+    public function hasLineItems(): bool
156
+    {
157
+        return $this->lineItems()->exists();
158
+    }
159
+
145 160
     public function hasPayments(): bool
146 161
     {
147 162
         return $this->payments->isNotEmpty();
@@ -379,28 +394,32 @@ class Bill extends Model
379 394
             })
380 395
             ->databaseTransaction()
381 396
             ->after(function (self $original, self $replica) {
382
-                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
383
-                    $replicaLineItem = $lineItem->replicate([
384
-                        'documentable_id',
385
-                        'documentable_type',
386
-                        'subtotal',
387
-                        'total',
388
-                        'created_by',
389
-                        'updated_by',
390
-                        'created_at',
391
-                        'updated_at',
392
-                    ]);
393
-
394
-                    $replicaLineItem->documentable_id = $replica->id;
395
-                    $replicaLineItem->documentable_type = $replica->getMorphClass();
396
-
397
-                    $replicaLineItem->save();
398
-
399
-                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
400
-                });
397
+                $original->replicateLineItems($replica);
401 398
             })
402 399
             ->successRedirectUrl(static function (self $replica) {
403 400
                 return BillResource::getUrl('edit', ['record' => $replica]);
404 401
             });
405 402
     }
403
+
404
+    public function replicateLineItems(Model $target): void
405
+    {
406
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
407
+            $replica = $lineItem->replicate([
408
+                'documentable_id',
409
+                'documentable_type',
410
+                'subtotal',
411
+                'total',
412
+                'created_by',
413
+                'updated_by',
414
+                'created_at',
415
+                'updated_at',
416
+            ]);
417
+
418
+            $replica->documentable_id = $target->id;
419
+            $replica->documentable_type = $target->getMorphClass();
420
+            $replica->save();
421
+
422
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
423
+        });
424
+    }
406 425
 }

+ 480
- 0
app/Models/Accounting/Estimate.php View File

@@ -0,0 +1,480 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Casts\RateCast;
7
+use App\Collections\Accounting\DocumentCollection;
8
+use App\Concerns\Blamable;
9
+use App\Concerns\CompanyOwned;
10
+use App\Enums\Accounting\AdjustmentComputation;
11
+use App\Enums\Accounting\DocumentDiscountMethod;
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;
16
+use App\Models\Common\Client;
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;
22
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
24
+use Illuminate\Database\Eloquent\Builder;
25
+use Illuminate\Database\Eloquent\Casts\Attribute;
26
+use Illuminate\Database\Eloquent\Factories\HasFactory;
27
+use Illuminate\Database\Eloquent\Model;
28
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
29
+use Illuminate\Database\Eloquent\Relations\HasOne;
30
+use Illuminate\Database\Eloquent\Relations\MorphMany;
31
+use Illuminate\Support\Carbon;
32
+
33
+#[CollectedBy(DocumentCollection::class)]
34
+#[ObservedBy(EstimateObserver::class)]
35
+class Estimate extends Model
36
+{
37
+    use Blamable;
38
+    use CompanyOwned;
39
+    use HasFactory;
40
+
41
+    protected $fillable = [
42
+        'company_id',
43
+        'client_id',
44
+        'logo',
45
+        'header',
46
+        'subheader',
47
+        'estimate_number',
48
+        'reference_number',
49
+        'date',
50
+        'expiration_date',
51
+        'approved_at',
52
+        'accepted_at',
53
+        'converted_at',
54
+        'declined_at',
55
+        'last_sent_at',
56
+        'last_viewed_at',
57
+        'status',
58
+        'currency_code',
59
+        'discount_method',
60
+        'discount_computation',
61
+        'discount_rate',
62
+        'subtotal',
63
+        'tax_total',
64
+        'discount_total',
65
+        'total',
66
+        'terms',
67
+        'footer',
68
+        'created_by',
69
+        'updated_by',
70
+    ];
71
+
72
+    protected $casts = [
73
+        'date' => 'date',
74
+        'expiration_date' => 'date',
75
+        'approved_at' => 'datetime',
76
+        'accepted_at' => 'datetime',
77
+        'declined_at' => 'datetime',
78
+        'last_sent_at' => 'datetime',
79
+        'last_viewed_at' => 'datetime',
80
+        'status' => EstimateStatus::class,
81
+        'discount_method' => DocumentDiscountMethod::class,
82
+        'discount_computation' => AdjustmentComputation::class,
83
+        'discount_rate' => RateCast::class,
84
+        'subtotal' => MoneyCast::class,
85
+        'tax_total' => MoneyCast::class,
86
+        'discount_total' => MoneyCast::class,
87
+        'total' => MoneyCast::class,
88
+    ];
89
+
90
+    public function client(): BelongsTo
91
+    {
92
+        return $this->belongsTo(Client::class);
93
+    }
94
+
95
+    public function currency(): BelongsTo
96
+    {
97
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
98
+    }
99
+
100
+    public function invoice(): HasOne
101
+    {
102
+        return $this->hasOne(Invoice::class);
103
+    }
104
+
105
+    public function lineItems(): MorphMany
106
+    {
107
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
108
+    }
109
+
110
+    protected function isCurrentlyExpired(): Attribute
111
+    {
112
+        return Attribute::get(function () {
113
+            return $this->expiration_date?->isBefore(today()) && $this->canBeExpired();
114
+        });
115
+    }
116
+
117
+    public function isDraft(): bool
118
+    {
119
+        return $this->status === EstimateStatus::Draft;
120
+    }
121
+
122
+    public function wasApproved(): bool
123
+    {
124
+        return $this->approved_at !== null;
125
+    }
126
+
127
+    public function wasAccepted(): bool
128
+    {
129
+        return $this->accepted_at !== null;
130
+    }
131
+
132
+    public function wasDeclined(): bool
133
+    {
134
+        return $this->declined_at !== null;
135
+    }
136
+
137
+    public function wasConverted(): bool
138
+    {
139
+        return $this->converted_at !== null;
140
+    }
141
+
142
+    public function hasBeenSent(): bool
143
+    {
144
+        return $this->last_sent_at !== null;
145
+    }
146
+
147
+    public function hasBeenViewed(): bool
148
+    {
149
+        return $this->last_viewed_at !== null;
150
+    }
151
+
152
+    public function canBeExpired(): bool
153
+    {
154
+        return ! in_array($this->status, [
155
+            EstimateStatus::Draft,
156
+            EstimateStatus::Accepted,
157
+            EstimateStatus::Declined,
158
+            EstimateStatus::Converted,
159
+        ]);
160
+    }
161
+
162
+    public function canBeApproved(): bool
163
+    {
164
+        return $this->isDraft() && ! $this->wasApproved();
165
+    }
166
+
167
+    public function canBeConverted(): bool
168
+    {
169
+        return $this->wasAccepted() && ! $this->wasConverted();
170
+    }
171
+
172
+    public function canBeMarkedAsDeclined(): bool
173
+    {
174
+        return $this->hasBeenSent()
175
+            && ! $this->wasDeclined()
176
+            && ! $this->wasConverted()
177
+            && ! $this->wasAccepted();
178
+    }
179
+
180
+    public function canBeMarkedAsSent(): bool
181
+    {
182
+        return ! $this->hasBeenSent();
183
+    }
184
+
185
+    public function canBeMarkedAsAccepted(): bool
186
+    {
187
+        return $this->hasBeenSent()
188
+            && ! $this->wasAccepted()
189
+            && ! $this->wasDeclined()
190
+            && ! $this->wasConverted();
191
+    }
192
+
193
+    public function hasLineItems(): bool
194
+    {
195
+        return $this->lineItems()->exists();
196
+    }
197
+
198
+    public function scopeActive(Builder $query): Builder
199
+    {
200
+        return $query->whereIn('status', [
201
+            EstimateStatus::Unsent,
202
+            EstimateStatus::Sent,
203
+            EstimateStatus::Viewed,
204
+            EstimateStatus::Accepted,
205
+        ]);
206
+    }
207
+
208
+    public static function getNextDocumentNumber(): string
209
+    {
210
+        $company = auth()->user()->currentCompany;
211
+
212
+        if (! $company) {
213
+            throw new \RuntimeException('No current company is set for the user.');
214
+        }
215
+
216
+        $defaultEstimateSettings = $company->defaultInvoice;
217
+
218
+        $numberPrefix = 'EST-';
219
+        $numberDigits = $defaultEstimateSettings->number_digits;
220
+
221
+        $latestDocument = static::query()
222
+            ->whereNotNull('estimate_number')
223
+            ->latest('estimate_number')
224
+            ->first();
225
+
226
+        $lastNumberNumericPart = $latestDocument
227
+            ? (int) substr($latestDocument->estimate_number, strlen($numberPrefix))
228
+            : 0;
229
+
230
+        $numberNext = $lastNumberNumericPart + 1;
231
+
232
+        return $defaultEstimateSettings->getNumberNext(
233
+            padded: true,
234
+            format: true,
235
+            prefix: $numberPrefix,
236
+            digits: $numberDigits,
237
+            next: $numberNext
238
+        );
239
+    }
240
+
241
+    public function approveDraft(?Carbon $approvedAt = null): void
242
+    {
243
+        if (! $this->isDraft()) {
244
+            throw new \RuntimeException('Estimate is not in draft status.');
245
+        }
246
+
247
+        $approvedAt ??= now();
248
+
249
+        $this->update([
250
+            'approved_at' => $approvedAt,
251
+            'status' => EstimateStatus::Unsent,
252
+        ]);
253
+    }
254
+
255
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
256
+    {
257
+        return $action::make('approveDraft')
258
+            ->label('Approve')
259
+            ->icon('heroicon-o-check-circle')
260
+            ->visible(function (self $record) {
261
+                return $record->canBeApproved();
262
+            })
263
+            ->databaseTransaction()
264
+            ->successNotificationTitle('Estimate Approved')
265
+            ->action(function (self $record, MountableAction $action) {
266
+                $record->approveDraft();
267
+
268
+                $action->success();
269
+            });
270
+    }
271
+
272
+    public static function getMarkAsSentAction(string $action = Action::class): MountableAction
273
+    {
274
+        return $action::make('markAsSent')
275
+            ->label('Mark as Sent')
276
+            ->icon('heroicon-o-paper-airplane')
277
+            ->visible(static function (self $record) {
278
+                return $record->canBeMarkedAsSent();
279
+            })
280
+            ->successNotificationTitle('Estimate Sent')
281
+            ->action(function (self $record, MountableAction $action) {
282
+                $record->markAsSent();
283
+
284
+                $action->success();
285
+            });
286
+    }
287
+
288
+    public function markAsSent(?Carbon $sentAt = null): void
289
+    {
290
+        $sentAt ??= now();
291
+
292
+        $this->update([
293
+            'status' => EstimateStatus::Sent,
294
+            'last_sent_at' => $sentAt,
295
+        ]);
296
+    }
297
+
298
+    public function markAsViewed(?Carbon $viewedAt = null): void
299
+    {
300
+        $viewedAt ??= now();
301
+
302
+        $this->update([
303
+            'status' => EstimateStatus::Viewed,
304
+            'last_viewed_at' => $viewedAt,
305
+        ]);
306
+    }
307
+
308
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
309
+    {
310
+        return $action::make()
311
+            ->excludeAttributes([
312
+                'estimate_number',
313
+                'date',
314
+                'expiration_date',
315
+                'approved_at',
316
+                'accepted_at',
317
+                'converted_at',
318
+                'declined_at',
319
+                'last_sent_at',
320
+                'last_viewed_at',
321
+                'status',
322
+                'created_by',
323
+                'updated_by',
324
+                'created_at',
325
+                'updated_at',
326
+            ])
327
+            ->modal(false)
328
+            ->beforeReplicaSaved(function (self $original, self $replica) {
329
+                $replica->status = EstimateStatus::Draft;
330
+                $replica->estimate_number = self::getNextDocumentNumber();
331
+                $replica->date = now();
332
+                $replica->expiration_date = now()->addDays($original->company->defaultInvoice->payment_terms->getDays());
333
+            })
334
+            ->databaseTransaction()
335
+            ->after(function (self $original, self $replica) {
336
+                $original->replicateLineItems($replica);
337
+            })
338
+            ->successRedirectUrl(static function (self $replica) {
339
+                return EstimateResource::getUrl('edit', ['record' => $replica]);
340
+            });
341
+    }
342
+
343
+    public static function getMarkAsAcceptedAction(string $action = Action::class): MountableAction
344
+    {
345
+        return $action::make('markAsAccepted')
346
+            ->label('Mark as Accepted')
347
+            ->icon('heroicon-o-check-badge')
348
+            ->visible(static function (self $record) {
349
+                return $record->canBeMarkedAsAccepted();
350
+            })
351
+            ->databaseTransaction()
352
+            ->successNotificationTitle('Estimate Accepted')
353
+            ->action(function (self $record, MountableAction $action) {
354
+                $record->markAsAccepted();
355
+
356
+                $action->success();
357
+            });
358
+    }
359
+
360
+    public function markAsAccepted(?Carbon $acceptedAt = null): void
361
+    {
362
+        $acceptedAt ??= now();
363
+
364
+        $this->update([
365
+            'status' => EstimateStatus::Accepted,
366
+            'accepted_at' => $acceptedAt,
367
+        ]);
368
+    }
369
+
370
+    public static function getMarkAsDeclinedAction(string $action = Action::class): MountableAction
371
+    {
372
+        return $action::make('markAsDeclined')
373
+            ->label('Mark as Declined')
374
+            ->icon('heroicon-o-x-circle')
375
+            ->visible(static function (self $record) {
376
+                return $record->canBeMarkedAsDeclined();
377
+            })
378
+            ->color('danger')
379
+            ->requiresConfirmation()
380
+            ->databaseTransaction()
381
+            ->successNotificationTitle('Estimate Declined')
382
+            ->action(function (self $record, MountableAction $action) {
383
+                $record->markAsDeclined();
384
+
385
+                $action->success();
386
+            });
387
+    }
388
+
389
+    public function markAsDeclined(?Carbon $declinedAt = null): void
390
+    {
391
+        $declinedAt ??= now();
392
+
393
+        $this->update([
394
+            'status' => EstimateStatus::Declined,
395
+            'declined_at' => $declinedAt,
396
+        ]);
397
+    }
398
+
399
+    public static function getConvertToInvoiceAction(string $action = Action::class): MountableAction
400
+    {
401
+        return $action::make('convertToInvoice')
402
+            ->label('Convert to Invoice')
403
+            ->icon('heroicon-o-arrow-right-on-rectangle')
404
+            ->visible(static function (self $record) {
405
+                return $record->canBeConverted();
406
+            })
407
+            ->databaseTransaction()
408
+            ->successNotificationTitle('Estimate Converted to Invoice')
409
+            ->action(function (self $record, MountableAction $action) {
410
+                $record->convertToInvoice();
411
+
412
+                $action->success();
413
+            })
414
+            ->successRedirectUrl(static function (self $record) {
415
+                return InvoiceResource::getUrl('edit', ['record' => $record->refresh()->invoice]);
416
+            });
417
+    }
418
+
419
+    public function convertToInvoice(?Carbon $convertedAt = null): void
420
+    {
421
+        if ($this->invoice) {
422
+            throw new \RuntimeException('Estimate has already been converted to an invoice.');
423
+        }
424
+
425
+        $invoice = $this->invoice()->create([
426
+            'company_id' => $this->company_id,
427
+            'client_id' => $this->client_id,
428
+            'logo' => $this->logo,
429
+            'header' => $this->company->defaultInvoice->header,
430
+            'subheader' => $this->company->defaultInvoice->subheader,
431
+            'invoice_number' => Invoice::getNextDocumentNumber($this->company),
432
+            'date' => now(),
433
+            'due_date' => now()->addDays($this->company->defaultInvoice->payment_terms->getDays()),
434
+            'status' => InvoiceStatus::Draft,
435
+            'currency_code' => $this->currency_code,
436
+            'discount_method' => $this->discount_method,
437
+            'discount_computation' => $this->discount_computation,
438
+            'discount_rate' => $this->discount_rate,
439
+            'subtotal' => $this->subtotal,
440
+            'tax_total' => $this->tax_total,
441
+            'discount_total' => $this->discount_total,
442
+            'total' => $this->total,
443
+            'terms' => $this->terms,
444
+            'footer' => $this->footer,
445
+            'created_by' => auth()->id(),
446
+            'updated_by' => auth()->id(),
447
+        ]);
448
+
449
+        $this->replicateLineItems($invoice);
450
+
451
+        $convertedAt ??= now();
452
+
453
+        $this->update([
454
+            'status' => EstimateStatus::Converted,
455
+            'converted_at' => $convertedAt,
456
+        ]);
457
+    }
458
+
459
+    public function replicateLineItems(Model $target): void
460
+    {
461
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
462
+            $replica = $lineItem->replicate([
463
+                'documentable_id',
464
+                'documentable_type',
465
+                'subtotal',
466
+                'total',
467
+                'created_by',
468
+                'updated_by',
469
+                'created_at',
470
+                'updated_at',
471
+            ]);
472
+
473
+            $replica->documentable_id = $target->id;
474
+            $replica->documentable_type = $target->getMorphClass();
475
+            $replica->save();
476
+
477
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
478
+        });
479
+    }
480
+}

+ 98
- 32
app/Models/Accounting/Invoice.php View File

@@ -15,6 +15,7 @@ use App\Enums\Accounting\TransactionType;
15 15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
16 16
 use App\Models\Banking\BankAccount;
17 17
 use App\Models\Common\Client;
18
+use App\Models\Company;
18 19
 use App\Models\Setting\Currency;
19 20
 use App\Observers\InvoiceObserver;
20 21
 use App\Utilities\Currency\CurrencyAccessor;
@@ -33,8 +34,8 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
33 34
 use Illuminate\Database\Eloquent\Relations\MorphOne;
34 35
 use Illuminate\Support\Carbon;
35 36
 
36
-#[ObservedBy(InvoiceObserver::class)]
37 37
 #[CollectedBy(DocumentCollection::class)]
38
+#[ObservedBy(InvoiceObserver::class)]
38 39
 class Invoice extends Model
39 40
 {
40 41
     use Blamable;
@@ -46,6 +47,7 @@ class Invoice extends Model
46 47
     protected $fillable = [
47 48
         'company_id',
48 49
         'client_id',
50
+        'estimate_id',
49 51
         'logo',
50 52
         'header',
51 53
         'subheader',
@@ -55,7 +57,8 @@ class Invoice extends Model
55 57
         'due_date',
56 58
         'approved_at',
57 59
         'paid_at',
58
-        'last_sent',
60
+        'last_sent_at',
61
+        'last_viewed_at',
59 62
         'status',
60 63
         'currency_code',
61 64
         'discount_method',
@@ -77,7 +80,8 @@ class Invoice extends Model
77 80
         'due_date' => 'date',
78 81
         'approved_at' => 'datetime',
79 82
         'paid_at' => 'datetime',
80
-        'last_sent' => 'datetime',
83
+        'last_sent_at' => 'datetime',
84
+        'last_viewed_at' => 'datetime',
81 85
         'status' => InvoiceStatus::class,
82 86
         'discount_method' => DocumentDiscountMethod::class,
83 87
         'discount_computation' => AdjustmentComputation::class,
@@ -100,6 +104,11 @@ class Invoice extends Model
100 104
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
101 105
     }
102 106
 
107
+    public function estimate(): BelongsTo
108
+    {
109
+        return $this->belongsTo(Estimate::class);
110
+    }
111
+
103 112
     public function lineItems(): MorphMany
104 113
     {
105 114
         return $this->morphMany(DocumentLineItem::class, 'documentable');
@@ -153,6 +162,26 @@ class Invoice extends Model
153 162
         return $this->status === InvoiceStatus::Draft;
154 163
     }
155 164
 
165
+    public function wasApproved(): bool
166
+    {
167
+        return $this->approved_at !== null;
168
+    }
169
+
170
+    public function isPaid(): bool
171
+    {
172
+        return $this->paid_at !== null;
173
+    }
174
+
175
+    public function hasBeenSent(): bool
176
+    {
177
+        return $this->last_sent_at !== null;
178
+    }
179
+
180
+    public function hasBeenViewed(): bool
181
+    {
182
+        return $this->last_viewed_at !== null;
183
+    }
184
+
156 185
     public function canRecordPayment(): bool
157 186
     {
158 187
         return ! in_array($this->status, [
@@ -177,14 +206,29 @@ class Invoice extends Model
177 206
         return in_array($this->status, InvoiceStatus::canBeOverdue());
178 207
     }
179 208
 
209
+    public function canBeApproved(): bool
210
+    {
211
+        return $this->isDraft() && ! $this->wasApproved();
212
+    }
213
+
214
+    public function canBeMarkedAsSent(): bool
215
+    {
216
+        return ! $this->hasBeenSent();
217
+    }
218
+
219
+    public function hasLineItems(): bool
220
+    {
221
+        return $this->lineItems()->exists();
222
+    }
223
+
180 224
     public function hasPayments(): bool
181 225
     {
182
-        return $this->payments->isNotEmpty();
226
+        return $this->payments()->exists();
183 227
     }
184 228
 
185
-    public static function getNextDocumentNumber(): string
229
+    public static function getNextDocumentNumber(?Company $company = null): string
186 230
     {
187
-        $company = auth()->user()->currentCompany;
231
+        $company ??= auth()->user()?->currentCompany;
188 232
 
189 233
         if (! $company) {
190 234
             throw new \RuntimeException('No current company is set for the user.');
@@ -391,7 +435,7 @@ class Invoice extends Model
391 435
             ->label('Approve')
392 436
             ->icon('heroicon-o-check-circle')
393 437
             ->visible(function (self $record) {
394
-                return $record->isDraft();
438
+                return $record->canBeApproved();
395 439
             })
396 440
             ->databaseTransaction()
397 441
             ->successNotificationTitle('Invoice Approved')
@@ -408,19 +452,36 @@ class Invoice extends Model
408 452
             ->label('Mark as Sent')
409 453
             ->icon('heroicon-o-paper-airplane')
410 454
             ->visible(static function (self $record) {
411
-                return ! $record->last_sent;
455
+                return $record->canBeMarkedAsSent();
412 456
             })
413 457
             ->successNotificationTitle('Invoice Sent')
414 458
             ->action(function (self $record, MountableAction $action) {
415
-                $record->update([
416
-                    'status' => InvoiceStatus::Sent,
417
-                    'last_sent' => now(),
418
-                ]);
459
+                $record->markAsSent();
419 460
 
420 461
                 $action->success();
421 462
             });
422 463
     }
423 464
 
465
+    public function markAsSent(?Carbon $sentAt = null): void
466
+    {
467
+        $sentAt ??= now();
468
+
469
+        $this->update([
470
+            'status' => InvoiceStatus::Sent,
471
+            'last_sent_at' => $sentAt,
472
+        ]);
473
+    }
474
+
475
+    public function markAsViewed(?Carbon $viewedAt = null): void
476
+    {
477
+        $viewedAt ??= now();
478
+
479
+        $this->update([
480
+            'status' => InvoiceStatus::Viewed,
481
+            'last_viewed_at' => $viewedAt,
482
+        ]);
483
+    }
484
+
424 485
     public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
425 486
     {
426 487
         return $action::make()
@@ -437,7 +498,8 @@ class Invoice extends Model
437 498
                 'due_date',
438 499
                 'approved_at',
439 500
                 'paid_at',
440
-                'last_sent',
501
+                'last_sent_at',
502
+                'last_viewed_at',
441 503
             ])
442 504
             ->modal(false)
443 505
             ->beforeReplicaSaved(function (self $original, self $replica) {
@@ -448,28 +510,32 @@ class Invoice extends Model
448 510
             })
449 511
             ->databaseTransaction()
450 512
             ->after(function (self $original, self $replica) {
451
-                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
452
-                    $replicaLineItem = $lineItem->replicate([
453
-                        'documentable_id',
454
-                        'documentable_type',
455
-                        'subtotal',
456
-                        'total',
457
-                        'created_by',
458
-                        'updated_by',
459
-                        'created_at',
460
-                        'updated_at',
461
-                    ]);
462
-
463
-                    $replicaLineItem->documentable_id = $replica->id;
464
-                    $replicaLineItem->documentable_type = $replica->getMorphClass();
465
-
466
-                    $replicaLineItem->save();
467
-
468
-                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
469
-                });
513
+                $original->replicateLineItems($replica);
470 514
             })
471 515
             ->successRedirectUrl(static function (self $replica) {
472 516
                 return InvoiceResource::getUrl('edit', ['record' => $replica]);
473 517
             });
474 518
     }
519
+
520
+    public function replicateLineItems(Model $target): void
521
+    {
522
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
523
+            $replica = $lineItem->replicate([
524
+                'documentable_id',
525
+                'documentable_type',
526
+                'subtotal',
527
+                'total',
528
+                'created_by',
529
+                'updated_by',
530
+                'created_at',
531
+                'updated_at',
532
+            ]);
533
+
534
+            $replica->documentable_id = $target->id;
535
+            $replica->documentable_type = $target->getMorphClass();
536
+            $replica->save();
537
+
538
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
539
+        });
540
+    }
475 541
 }

+ 5
- 0
app/Models/Company.php View File

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

+ 27
- 0
app/Observers/EstimateObserver.php View File

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

+ 0
- 3
app/Observers/InvoiceObserver.php View File

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

+ 69
- 0
app/Policies/EstimatePolicy.php View File

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

+ 2
- 0
app/Providers/FilamentCompaniesServiceProvider.php View File

@@ -30,6 +30,7 @@ use App\Filament\Company\Resources\Common\OfferingResource;
30 30
 use App\Filament\Company\Resources\Purchases\BillResource;
31 31
 use App\Filament\Company\Resources\Purchases\VendorResource;
32 32
 use App\Filament\Company\Resources\Sales\ClientResource;
33
+use App\Filament\Company\Resources\Sales\EstimateResource;
33 34
 use App\Filament\Company\Resources\Sales\InvoiceResource;
34 35
 use App\Filament\Components\PanelShiftDropdown;
35 36
 use App\Filament\User\Clusters\Account;
@@ -131,6 +132,7 @@ class FilamentCompaniesServiceProvider extends PanelProvider
131 132
                             ->icon('heroicon-o-currency-dollar')
132 133
                             ->items([
133 134
                                 ...InvoiceResource::getNavigationItems(),
135
+                                ...EstimateResource::getNavigationItems(),
134 136
                                 ...ClientResource::getNavigationItems(),
135 137
                             ]),
136 138
                         NavigationGroup::make('Purchases')

+ 118
- 0
app/View/Models/DocumentPreviewViewModel.php View File

@@ -0,0 +1,118 @@
1
+<?php
2
+
3
+namespace App\View\Models;
4
+
5
+use App\Enums\Accounting\DocumentType;
6
+use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Common\Client;
8
+use App\Models\Company;
9
+use App\Models\Setting\DocumentDefault;
10
+use App\Utilities\Currency\CurrencyAccessor;
11
+use App\Utilities\Currency\CurrencyConverter;
12
+use Illuminate\Database\Eloquent\Model;
13
+
14
+class DocumentPreviewViewModel
15
+{
16
+    public function __construct(
17
+        public Model $document,
18
+        public DocumentType $documentType = DocumentType::Invoice,
19
+    ) {}
20
+
21
+    public function buildViewData(): array
22
+    {
23
+        return [
24
+            'company' => $this->getCompanyDetails(),
25
+            'client' => $this->getClientDetails(),
26
+            'metadata' => $this->getDocumentMetadata(),
27
+            'lineItems' => $this->getLineItems(),
28
+            'totals' => $this->getTotals(),
29
+            'header' => $this->document->header,
30
+            'footer' => $this->document->footer,
31
+            'terms' => $this->document->terms,
32
+            'logo' => $this->document->logo,
33
+            'style' => $this->getStyle(),
34
+            'labels' => $this->documentType->getLabels(),
35
+        ];
36
+    }
37
+
38
+    private function getCompanyDetails(): array
39
+    {
40
+        /** @var Company $company */
41
+        $company = $this->document->company;
42
+        $profile = $company->profile;
43
+
44
+        return [
45
+            'name' => $company->name,
46
+            'address' => $profile->address ?? '',
47
+            'city' => $profile->city?->name ?? '',
48
+            'state' => $profile->state?->name ?? '',
49
+            'zip_code' => $profile->zip_code ?? '',
50
+            'country' => $profile->state?->country->name ?? '',
51
+        ];
52
+    }
53
+
54
+    private function getClientDetails(): array
55
+    {
56
+        /** @var Client $client */
57
+        $client = $this->document->client;
58
+        $address = $client->billingAddress ?? null;
59
+
60
+        return [
61
+            'name' => $client->name,
62
+            'address_line_1' => $address->address_line_1 ?? '',
63
+            'address_line_2' => $address->address_line_2 ?? '',
64
+            'city' => $address->city ?? '',
65
+            'state' => $address->state ?? '',
66
+            'postal_code' => $address->postal_code ?? '',
67
+            'country' => $address->country ?? '',
68
+        ];
69
+    }
70
+
71
+    private function getDocumentMetadata(): array
72
+    {
73
+        return [
74
+            'number' => $this->document->invoice_number ?? $this->document->estimate_number,
75
+            'reference_number' => $this->document->order_number ?? $this->document->reference_number,
76
+            'date' => $this->document->date?->toDefaultDateFormat(),
77
+            'due_date' => $this->document->due_date?->toDefaultDateFormat() ?? $this->document->expiration_date?->toDefaultDateFormat(),
78
+            'currency_code' => $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
79
+        ];
80
+    }
81
+
82
+    private function getLineItems(): array
83
+    {
84
+        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
85
+
86
+        return $this->document->lineItems->map(fn (DocumentLineItem $item) => [
87
+            'name' => $item->offering->name ?? '',
88
+            'description' => $item->description ?? '',
89
+            'quantity' => $item->quantity,
90
+            'unit_price' => CurrencyConverter::formatToMoney($item->unit_price, $currencyCode),
91
+            'subtotal' => CurrencyConverter::formatToMoney($item->subtotal, $currencyCode),
92
+        ])->toArray();
93
+    }
94
+
95
+    private function getTotals(): array
96
+    {
97
+        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
98
+
99
+        return [
100
+            'subtotal' => CurrencyConverter::formatToMoney($this->document->subtotal, $currencyCode),
101
+            'discount' => CurrencyConverter::formatToMoney($this->document->discount_total, $currencyCode),
102
+            'tax' => CurrencyConverter::formatToMoney($this->document->tax_total, $currencyCode),
103
+            'total' => CurrencyConverter::formatToMoney($this->document->total, $currencyCode),
104
+            'amount_due' => $this->document->amount_due ? CurrencyConverter::formatToMoney($this->document->amount_due, $currencyCode) : null,
105
+        ];
106
+    }
107
+
108
+    private function getStyle(): array
109
+    {
110
+        /** @var DocumentDefault $settings */
111
+        $settings = $this->document->company->defaultInvoice;
112
+
113
+        return [
114
+            'accent_color' => $settings->accent_color ?? '#000000',
115
+            'show_logo' => $settings->show_logo ?? false,
116
+        ];
117
+    }
118
+}

+ 55
- 53
composer.lock View File

@@ -497,16 +497,16 @@
497 497
         },
498 498
         {
499 499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.0",
500
+            "version": "3.336.6",
501 501
             "source": {
502 502
                 "type": "git",
503 503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "5a62003bf183e32da038056a4b9077c27224e034"
504
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad"
505 505
             },
506 506
             "dist": {
507 507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/5a62003bf183e32da038056a4b9077c27224e034",
509
-                "reference": "5a62003bf183e32da038056a4b9077c27224e034",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/0a99dab427f0a1c082775301141aeac3558691ad",
509
+                "reference": "0a99dab427f0a1c082775301141aeac3558691ad",
510 510
                 "shasum": ""
511 511
             },
512 512
             "require": {
@@ -589,9 +589,9 @@
589 589
             "support": {
590 590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591 591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
592
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.0"
592
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.6"
593 593
             },
594
-            "time": "2024-12-18T19:04:32+00:00"
594
+            "time": "2024-12-28T04:16:13+00:00"
595 595
         },
596 596
         {
597 597
             "name": "aws/aws-sdk-php-laravel",
@@ -1595,16 +1595,16 @@
1595 1595
         },
1596 1596
         {
1597 1597
             "name": "egulias/email-validator",
1598
-            "version": "4.0.2",
1598
+            "version": "4.0.3",
1599 1599
             "source": {
1600 1600
                 "type": "git",
1601 1601
                 "url": "https://github.com/egulias/EmailValidator.git",
1602
-                "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e"
1602
+                "reference": "b115554301161fa21467629f1e1391c1936de517"
1603 1603
             },
1604 1604
             "dist": {
1605 1605
                 "type": "zip",
1606
-                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/ebaaf5be6c0286928352e054f2d5125608e5405e",
1607
-                "reference": "ebaaf5be6c0286928352e054f2d5125608e5405e",
1606
+                "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/b115554301161fa21467629f1e1391c1936de517",
1607
+                "reference": "b115554301161fa21467629f1e1391c1936de517",
1608 1608
                 "shasum": ""
1609 1609
             },
1610 1610
             "require": {
@@ -1650,7 +1650,7 @@
1650 1650
             ],
1651 1651
             "support": {
1652 1652
                 "issues": "https://github.com/egulias/EmailValidator/issues",
1653
-                "source": "https://github.com/egulias/EmailValidator/tree/4.0.2"
1653
+                "source": "https://github.com/egulias/EmailValidator/tree/4.0.3"
1654 1654
             },
1655 1655
             "funding": [
1656 1656
                 {
@@ -1658,7 +1658,7 @@
1658 1658
                     "type": "github"
1659 1659
                 }
1660 1660
             ],
1661
-            "time": "2023-10-06T06:47:41+00:00"
1661
+            "time": "2024-12-27T00:36:43+00:00"
1662 1662
         },
1663 1663
         {
1664 1664
             "name": "filament/actions",
@@ -4666,16 +4666,16 @@
4666 4666
         },
4667 4667
         {
4668 4668
             "name": "nesbot/carbon",
4669
-            "version": "3.8.2",
4669
+            "version": "3.8.4",
4670 4670
             "source": {
4671 4671
                 "type": "git",
4672 4672
                 "url": "https://github.com/briannesbitt/Carbon.git",
4673
-                "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947"
4673
+                "reference": "129700ed449b1f02d70272d2ac802357c8c30c58"
4674 4674
             },
4675 4675
             "dist": {
4676 4676
                 "type": "zip",
4677
-                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
4678
-                "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947",
4677
+                "url": "https://api.github.com/repos/briannesbitt/Carbon/zipball/129700ed449b1f02d70272d2ac802357c8c30c58",
4678
+                "reference": "129700ed449b1f02d70272d2ac802357c8c30c58",
4679 4679
                 "shasum": ""
4680 4680
             },
4681 4681
             "require": {
@@ -4707,10 +4707,6 @@
4707 4707
             ],
4708 4708
             "type": "library",
4709 4709
             "extra": {
4710
-                "branch-alias": {
4711
-                    "dev-master": "3.x-dev",
4712
-                    "dev-2.x": "2.x-dev"
4713
-                },
4714 4710
                 "laravel": {
4715 4711
                     "providers": [
4716 4712
                         "Carbon\\Laravel\\ServiceProvider"
@@ -4720,6 +4716,10 @@
4720 4716
                     "includes": [
4721 4717
                         "extension.neon"
4722 4718
                     ]
4719
+                },
4720
+                "branch-alias": {
4721
+                    "dev-2.x": "2.x-dev",
4722
+                    "dev-master": "3.x-dev"
4723 4723
                 }
4724 4724
             },
4725 4725
             "autoload": {
@@ -4768,7 +4768,7 @@
4768 4768
                     "type": "tidelift"
4769 4769
                 }
4770 4770
             ],
4771
-            "time": "2024-11-07T17:46:48+00:00"
4771
+            "time": "2024-12-27T09:25:35+00:00"
4772 4772
         },
4773 4773
         {
4774 4774
             "name": "nette/schema",
@@ -6303,16 +6303,16 @@
6303 6303
         },
6304 6304
         {
6305 6305
             "name": "spatie/color",
6306
-            "version": "1.6.2",
6306
+            "version": "1.6.3",
6307 6307
             "source": {
6308 6308
                 "type": "git",
6309 6309
                 "url": "https://github.com/spatie/color.git",
6310
-                "reference": "b4fac074a9e5999dcca12cbfab0f7c73e2684d6d"
6310
+                "reference": "45c4354ffa7e65f05c502b009834ecac7928daa3"
6311 6311
             },
6312 6312
             "dist": {
6313 6313
                 "type": "zip",
6314
-                "url": "https://api.github.com/repos/spatie/color/zipball/b4fac074a9e5999dcca12cbfab0f7c73e2684d6d",
6315
-                "reference": "b4fac074a9e5999dcca12cbfab0f7c73e2684d6d",
6314
+                "url": "https://api.github.com/repos/spatie/color/zipball/45c4354ffa7e65f05c502b009834ecac7928daa3",
6315
+                "reference": "45c4354ffa7e65f05c502b009834ecac7928daa3",
6316 6316
                 "shasum": ""
6317 6317
             },
6318 6318
             "require": {
@@ -6350,7 +6350,7 @@
6350 6350
             ],
6351 6351
             "support": {
6352 6352
                 "issues": "https://github.com/spatie/color/issues",
6353
-                "source": "https://github.com/spatie/color/tree/1.6.2"
6353
+                "source": "https://github.com/spatie/color/tree/1.6.3"
6354 6354
             },
6355 6355
             "funding": [
6356 6356
                 {
@@ -6358,7 +6358,7 @@
6358 6358
                     "type": "github"
6359 6359
                 }
6360 6360
             ],
6361
-            "time": "2024-12-09T16:20:38+00:00"
6361
+            "time": "2024-12-23T11:00:34+00:00"
6362 6362
         },
6363 6363
         {
6364 6364
             "name": "spatie/invade",
@@ -6839,12 +6839,12 @@
6839 6839
             },
6840 6840
             "type": "library",
6841 6841
             "extra": {
6842
+                "thanks": {
6843
+                    "url": "https://github.com/symfony/contracts",
6844
+                    "name": "symfony/contracts"
6845
+                },
6842 6846
                 "branch-alias": {
6843 6847
                     "dev-main": "3.5-dev"
6844
-                },
6845
-                "thanks": {
6846
-                    "name": "symfony/contracts",
6847
-                    "url": "https://github.com/symfony/contracts"
6848 6848
                 }
6849 6849
             },
6850 6850
             "autoload": {
@@ -7062,12 +7062,12 @@
7062 7062
             },
7063 7063
             "type": "library",
7064 7064
             "extra": {
7065
+                "thanks": {
7066
+                    "url": "https://github.com/symfony/contracts",
7067
+                    "name": "symfony/contracts"
7068
+                },
7065 7069
                 "branch-alias": {
7066 7070
                     "dev-main": "3.5-dev"
7067
-                },
7068
-                "thanks": {
7069
-                    "name": "symfony/contracts",
7070
-                    "url": "https://github.com/symfony/contracts"
7071 7071
                 }
7072 7072
             },
7073 7073
             "autoload": {
@@ -8492,12 +8492,12 @@
8492 8492
             },
8493 8493
             "type": "library",
8494 8494
             "extra": {
8495
+                "thanks": {
8496
+                    "url": "https://github.com/symfony/contracts",
8497
+                    "name": "symfony/contracts"
8498
+                },
8495 8499
                 "branch-alias": {
8496 8500
                     "dev-main": "3.5-dev"
8497
-                },
8498
-                "thanks": {
8499
-                    "name": "symfony/contracts",
8500
-                    "url": "https://github.com/symfony/contracts"
8501 8501
                 }
8502 8502
             },
8503 8503
             "autoload": {
@@ -8752,12 +8752,12 @@
8752 8752
             },
8753 8753
             "type": "library",
8754 8754
             "extra": {
8755
+                "thanks": {
8756
+                    "url": "https://github.com/symfony/contracts",
8757
+                    "name": "symfony/contracts"
8758
+                },
8755 8759
                 "branch-alias": {
8756 8760
                     "dev-main": "3.5-dev"
8757
-                },
8758
-                "thanks": {
8759
-                    "name": "symfony/contracts",
8760
-                    "url": "https://github.com/symfony/contracts"
8761 8761
                 }
8762 8762
             },
8763 8763
             "autoload": {
@@ -8970,31 +8970,33 @@
8970 8970
         },
8971 8971
         {
8972 8972
             "name": "tijsverkoyen/css-to-inline-styles",
8973
-            "version": "v2.2.7",
8973
+            "version": "v2.3.0",
8974 8974
             "source": {
8975 8975
                 "type": "git",
8976 8976
                 "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git",
8977
-                "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb"
8977
+                "reference": "0d72ac1c00084279c1816675284073c5a337c20d"
8978 8978
             },
8979 8979
             "dist": {
8980 8980
                 "type": "zip",
8981
-                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/83ee6f38df0a63106a9e4536e3060458b74ccedb",
8982
-                "reference": "83ee6f38df0a63106a9e4536e3060458b74ccedb",
8981
+                "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/0d72ac1c00084279c1816675284073c5a337c20d",
8982
+                "reference": "0d72ac1c00084279c1816675284073c5a337c20d",
8983 8983
                 "shasum": ""
8984 8984
             },
8985 8985
             "require": {
8986 8986
                 "ext-dom": "*",
8987 8987
                 "ext-libxml": "*",
8988
-                "php": "^5.5 || ^7.0 || ^8.0",
8989
-                "symfony/css-selector": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0"
8988
+                "php": "^7.4 || ^8.0",
8989
+                "symfony/css-selector": "^5.4 || ^6.0 || ^7.0"
8990 8990
             },
8991 8991
             "require-dev": {
8992
-                "phpunit/phpunit": "^4.8.35 || ^5.7 || ^6.0 || ^7.5 || ^8.5.21 || ^9.5.10"
8992
+                "phpstan/phpstan": "^2.0",
8993
+                "phpstan/phpstan-phpunit": "^2.0",
8994
+                "phpunit/phpunit": "^8.5.21 || ^9.5.10"
8993 8995
             },
8994 8996
             "type": "library",
8995 8997
             "extra": {
8996 8998
                 "branch-alias": {
8997
-                    "dev-master": "2.2.x-dev"
8999
+                    "dev-master": "2.x-dev"
8998 9000
                 }
8999 9001
             },
9000 9002
             "autoload": {
@@ -9017,9 +9019,9 @@
9017 9019
             "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles",
9018 9020
             "support": {
9019 9021
                 "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues",
9020
-                "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.2.7"
9022
+                "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.3.0"
9021 9023
             },
9022
-            "time": "2023-12-08T13:03:43+00:00"
9024
+            "time": "2024-12-21T16:25:41+00:00"
9023 9025
         },
9024 9026
         {
9025 9027
             "name": "vlucas/phpdotenv",

+ 94
- 43
database/factories/Accounting/BillFactory.php View File

@@ -29,14 +29,11 @@ class BillFactory extends Factory
29 29
      */
30 30
     public function definition(): array
31 31
     {
32
-        // 50% chance of being a future bill
33 32
         $isFutureBill = $this->faker->boolean();
34 33
 
35 34
         if ($isFutureBill) {
36
-            // For future bills, date is recent and due date is in near future
37 35
             $billDate = $this->faker->dateTimeBetween('-10 days', '+10 days');
38 36
         } else {
39
-            // For past bills, both date and due date are in the past
40 37
             $billDate = $this->faker->dateTimeBetween('-1 year', '-30 days');
41 38
         }
42 39
 
@@ -57,44 +54,80 @@ class BillFactory extends Factory
57 54
         ];
58 55
     }
59 56
 
60
-    public function withLineItems(int $count = 3): self
57
+    public function withLineItems(int $count = 3): static
61 58
     {
62
-        return $this->has(DocumentLineItem::factory()->forBill()->count($count), 'lineItems');
59
+        return $this->afterCreating(function (Bill $bill) use ($count) {
60
+            DocumentLineItem::factory()
61
+                ->count($count)
62
+                ->forBill($bill)
63
+                ->create();
64
+
65
+            $this->recalculateTotals($bill);
66
+        });
63 67
     }
64 68
 
65 69
     public function initialized(): static
66 70
     {
67 71
         return $this->afterCreating(function (Bill $bill) {
68
-            if ($bill->hasInitialTransaction()) {
72
+            $this->ensureLineItems($bill);
73
+
74
+            if ($bill->wasInitialized()) {
69 75
                 return;
70 76
             }
71 77
 
72
-            $this->recalculateTotals($bill);
73
-
74
-            $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
78
+            $postedAt = Carbon::parse($bill->date)
79
+                ->addHours($this->faker->numberBetween(1, 24));
75 80
 
76 81
             $bill->createInitialTransaction($postedAt);
77 82
         });
78 83
     }
79 84
 
80
-    public function withPayments(?int $min = 1, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
85
+    public function partial(int $maxPayments = 4): static
81 86
     {
82
-        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
83
-            if (! $bill->hasInitialTransaction()) {
84
-                $this->recalculateTotals($bill);
87
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
88
+            $this->ensureInitialized($bill);
85 89
 
86
-                $postedAt = Carbon::parse($bill->date)->addHours($this->faker->numberBetween(1, 24));
90
+            $this->withPayments(max: $maxPayments, billStatus: BillStatus::Partial)
91
+                ->callAfterCreating(collect([$bill]));
92
+        });
93
+    }
87 94
 
88
-                $bill->createInitialTransaction($postedAt);
89
-            }
95
+    public function paid(int $maxPayments = 4): static
96
+    {
97
+        return $this->afterCreating(function (Bill $bill) use ($maxPayments) {
98
+            $this->ensureInitialized($bill);
99
+
100
+            $this->withPayments(max: $maxPayments)
101
+                ->callAfterCreating(collect([$bill]));
102
+        });
103
+    }
104
+
105
+    public function overdue(): static
106
+    {
107
+        return $this
108
+            ->state([
109
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
110
+            ])
111
+            ->afterCreating(function (Bill $bill) {
112
+                $this->ensureInitialized($bill);
113
+            });
114
+    }
115
+
116
+    public function withPayments(?int $min = null, ?int $max = null, BillStatus $billStatus = BillStatus::Paid): static
117
+    {
118
+        $min ??= 1;
119
+
120
+        return $this->afterCreating(function (Bill $bill) use ($billStatus, $max, $min) {
121
+            $this->ensureInitialized($bill);
90 122
 
91 123
             $bill->refresh();
92 124
 
93
-            $totalAmountDue = $bill->getRawOriginal('amount_due');
125
+            $amountDue = $bill->getRawOriginal('amount_due');
94 126
 
95
-            if ($billStatus === BillStatus::Partial) {
96
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
97
-            }
127
+            $totalAmountDue = match ($billStatus) {
128
+                BillStatus::Partial => (int) floor($amountDue * 0.5),
129
+                default => $amountDue,
130
+            };
98 131
 
99 132
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
100 133
                 return;
@@ -129,19 +162,23 @@ class BillFactory extends Factory
129 162
                 $remainingAmount -= $amount;
130 163
             }
131 164
 
132
-            if ($billStatus === BillStatus::Paid) {
133
-                $latestPaymentDate = max($paymentDates);
134
-                $bill->updateQuietly([
135
-                    'status' => $billStatus,
136
-                    'paid_at' => $latestPaymentDate,
137
-                ]);
165
+            if ($billStatus !== BillStatus::Paid) {
166
+                return;
138 167
             }
168
+
169
+            $latestPaymentDate = max($paymentDates);
170
+            $bill->updateQuietly([
171
+                'status' => $billStatus,
172
+                'paid_at' => $latestPaymentDate,
173
+            ]);
139 174
         });
140 175
     }
141 176
 
142 177
     public function configure(): static
143 178
     {
144 179
         return $this->afterCreating(function (Bill $bill) {
180
+            $this->ensureInitialized($bill);
181
+
145 182
             $paddedId = str_pad((string) $bill->id, 5, '0', STR_PAD_LEFT);
146 183
 
147 184
             $bill->updateQuietly([
@@ -149,10 +186,7 @@ class BillFactory extends Factory
149 186
                 'order_number' => "PO-{$paddedId}",
150 187
             ]);
151 188
 
152
-            $this->recalculateTotals($bill);
153
-
154
-            // Check for overdue status
155
-            if ($bill->due_date < today() && $bill->status !== BillStatus::Paid) {
189
+            if ($bill->wasInitialized() && $bill->is_currently_overdue) {
156 190
                 $bill->updateQuietly([
157 191
                     'status' => BillStatus::Overdue,
158 192
                 ]);
@@ -160,21 +194,38 @@ class BillFactory extends Factory
160 194
         });
161 195
     }
162 196
 
197
+    protected function ensureLineItems(Bill $bill): void
198
+    {
199
+        if (! $bill->hasLineItems()) {
200
+            $this->withLineItems()->callAfterCreating(collect([$bill]));
201
+        }
202
+    }
203
+
204
+    protected function ensureInitialized(Bill $bill): void
205
+    {
206
+        if (! $bill->wasInitialized()) {
207
+            $this->initialized()->callAfterCreating(collect([$bill]));
208
+        }
209
+    }
210
+
163 211
     protected function recalculateTotals(Bill $bill): void
164 212
     {
165
-        if ($bill->lineItems()->exists()) {
166
-            $bill->refresh();
167
-            $subtotal = $bill->lineItems()->sum('subtotal') / 100;
168
-            $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
169
-            $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
170
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
171
-
172
-            $bill->update([
173
-                'subtotal' => $subtotal,
174
-                'tax_total' => $taxTotal,
175
-                'discount_total' => $discountTotal,
176
-                'total' => $grandTotal,
177
-            ]);
213
+        $bill->refresh();
214
+
215
+        if (! $bill->hasLineItems()) {
216
+            return;
178 217
         }
218
+
219
+        $subtotal = $bill->lineItems()->sum('subtotal') / 100;
220
+        $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
221
+        $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
222
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
223
+
224
+        $bill->update([
225
+            'subtotal' => $subtotal,
226
+            'tax_total' => $taxTotal,
227
+            'discount_total' => $discountTotal,
228
+            'total' => $grandTotal,
229
+        ]);
179 230
     }
180 231
 }

+ 99
- 56
database/factories/Accounting/DocumentLineItemFactory.php View File

@@ -2,7 +2,10 @@
2 2
 
3 3
 namespace Database\Factories\Accounting;
4 4
 
5
+use App\Models\Accounting\Bill;
5 6
 use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\Estimate;
8
+use App\Models\Accounting\Invoice;
6 9
 use App\Models\Common\Offering;
7 10
 use Illuminate\Database\Eloquent\Factories\Factory;
8 11
 
@@ -34,65 +37,105 @@ class DocumentLineItemFactory extends Factory
34 37
         ];
35 38
     }
36 39
 
37
-    public function forInvoice(): static
40
+    public function forInvoice(Invoice $invoice): static
38 41
     {
39
-        return $this->state(function (array $attributes) {
40
-            $offering = Offering::where('sellable', true)
41
-                ->inRandomOrder()
42
-                ->first();
43
-
44
-            return [
45
-                'offering_id' => $offering->id,
46
-                'unit_price' => $offering->price,
47
-            ];
48
-        })->afterCreating(function (DocumentLineItem $lineItem) {
49
-            $offering = $lineItem->offering;
50
-
51
-            if ($offering) {
52
-                $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
53
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
54
-            }
55
-
56
-            $lineItem->refresh();
57
-
58
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
59
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
60
-
61
-            $lineItem->updateQuietly([
62
-                'tax_total' => $taxTotal,
63
-                'discount_total' => $discountTotal,
64
-            ]);
65
-        });
42
+        return $this
43
+            ->for($invoice, 'documentable')
44
+            ->state(function (array $attributes) {
45
+                $offering = Offering::where('sellable', true)
46
+                    ->inRandomOrder()
47
+                    ->first();
48
+
49
+                return [
50
+                    'offering_id' => $offering->id,
51
+                    'unit_price' => $offering->price,
52
+                ];
53
+            })
54
+            ->afterCreating(function (DocumentLineItem $lineItem) {
55
+                $offering = $lineItem->offering;
56
+
57
+                if ($offering) {
58
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
59
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
60
+                }
61
+
62
+                $lineItem->refresh();
63
+
64
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
65
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
66
+
67
+                $lineItem->updateQuietly([
68
+                    'tax_total' => $taxTotal,
69
+                    'discount_total' => $discountTotal,
70
+                ]);
71
+            });
66 72
     }
67 73
 
68
-    public function forBill(): static
74
+    public function forEstimate(Estimate $estimate): static
69 75
     {
70
-        return $this->state(function (array $attributes) {
71
-            $offering = Offering::where('purchasable', true)
72
-                ->inRandomOrder()
73
-                ->first();
74
-
75
-            return [
76
-                'offering_id' => $offering->id,
77
-                'unit_price' => $offering->price,
78
-            ];
79
-        })->afterCreating(function (DocumentLineItem $lineItem) {
80
-            $offering = $lineItem->offering;
81
-
82
-            if ($offering) {
83
-                $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
84
-                $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
85
-            }
86
-
87
-            $lineItem->refresh();
88
-
89
-            $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
90
-            $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
91
-
92
-            $lineItem->updateQuietly([
93
-                'tax_total' => $taxTotal,
94
-                'discount_total' => $discountTotal,
95
-            ]);
96
-        });
76
+        return $this
77
+            ->for($estimate, 'documentable')
78
+            ->state(function (array $attributes) {
79
+                $offering = Offering::where('sellable', true)
80
+                    ->inRandomOrder()
81
+                    ->first();
82
+
83
+                return [
84
+                    'offering_id' => $offering->id,
85
+                    'unit_price' => $offering->price,
86
+                ];
87
+            })
88
+            ->afterCreating(function (DocumentLineItem $lineItem) {
89
+                $offering = $lineItem->offering;
90
+
91
+                if ($offering) {
92
+                    $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
93
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
94
+                }
95
+
96
+                $lineItem->refresh();
97
+
98
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
99
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
100
+
101
+                $lineItem->updateQuietly([
102
+                    'tax_total' => $taxTotal,
103
+                    'discount_total' => $discountTotal,
104
+                ]);
105
+            });
106
+    }
107
+
108
+    public function forBill(Bill $bill): static
109
+    {
110
+        return $this
111
+            ->for($bill, 'documentable')
112
+            ->state(function (array $attributes) {
113
+                $offering = Offering::where('purchasable', true)
114
+                    ->inRandomOrder()
115
+                    ->first();
116
+
117
+                return [
118
+                    'offering_id' => $offering->id,
119
+                    'unit_price' => $offering->price,
120
+                ];
121
+            })
122
+            ->afterCreating(function (DocumentLineItem $lineItem) {
123
+                $offering = $lineItem->offering;
124
+
125
+                if ($offering) {
126
+                    $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
127
+                    $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
128
+                }
129
+
130
+                $lineItem->refresh();
131
+
132
+                $taxTotal = $lineItem->calculateTaxTotal()->getAmount();
133
+                $discountTotal = $lineItem->calculateDiscountTotal()->getAmount();
134
+
135
+                $lineItem->updateQuietly([
136
+                    'tax_total' => $taxTotal,
137
+                    'discount_total' => $discountTotal,
138
+                ]);
139
+            });
97 140
     }
98 141
 }

+ 211
- 0
database/factories/Accounting/EstimateFactory.php View File

@@ -0,0 +1,211 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Enums\Accounting\EstimateStatus;
6
+use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\Estimate;
8
+use App\Models\Common\Client;
9
+use Illuminate\Database\Eloquent\Factories\Factory;
10
+use Illuminate\Support\Carbon;
11
+
12
+/**
13
+ * @extends Factory<Estimate>
14
+ */
15
+class EstimateFactory extends Factory
16
+{
17
+    /**
18
+     * The name of the factory's corresponding model.
19
+     */
20
+    protected $model = Estimate::class;
21
+
22
+    /**
23
+     * Define the model's default state.
24
+     *
25
+     * @return array<string, mixed>
26
+     */
27
+    public function definition(): array
28
+    {
29
+        $estimateDate = $this->faker->dateTimeBetween('-1 year');
30
+
31
+        return [
32
+            'company_id' => 1,
33
+            'client_id' => Client::inRandomOrder()->value('id'),
34
+            'header' => 'Estimate',
35
+            'subheader' => 'Estimate',
36
+            'estimate_number' => $this->faker->unique()->numerify('EST-#####'),
37
+            'reference_number' => $this->faker->unique()->numerify('REF-#####'),
38
+            'date' => $estimateDate,
39
+            'expiration_date' => Carbon::parse($estimateDate)->addDays($this->faker->numberBetween(14, 30)),
40
+            'status' => EstimateStatus::Draft,
41
+            'currency_code' => 'USD',
42
+            'terms' => $this->faker->sentence,
43
+            'footer' => $this->faker->sentence,
44
+            'created_by' => 1,
45
+            'updated_by' => 1,
46
+        ];
47
+    }
48
+
49
+    public function withLineItems(int $count = 3): static
50
+    {
51
+        return $this->afterCreating(function (Estimate $estimate) use ($count) {
52
+            DocumentLineItem::factory()
53
+                ->count($count)
54
+                ->forEstimate($estimate)
55
+                ->create();
56
+
57
+            $this->recalculateTotals($estimate);
58
+        });
59
+    }
60
+
61
+    public function approved(): static
62
+    {
63
+        return $this->afterCreating(function (Estimate $estimate) {
64
+            $this->ensureLineItems($estimate);
65
+
66
+            if (! $estimate->canBeApproved()) {
67
+                return;
68
+            }
69
+
70
+            $approvedAt = Carbon::parse($estimate->date)
71
+                ->addHours($this->faker->numberBetween(1, 24));
72
+
73
+            $estimate->approveDraft($approvedAt);
74
+        });
75
+    }
76
+
77
+    public function accepted(): static
78
+    {
79
+        return $this->afterCreating(function (Estimate $estimate) {
80
+            $this->ensureSent($estimate);
81
+
82
+            $acceptedAt = Carbon::parse($estimate->last_sent_at)
83
+                ->addDays($this->faker->numberBetween(1, 7));
84
+
85
+            $estimate->markAsAccepted($acceptedAt);
86
+        });
87
+    }
88
+
89
+    public function converted(): static
90
+    {
91
+        return $this->afterCreating(function (Estimate $estimate) {
92
+            if (! $estimate->wasAccepted()) {
93
+                $this->accepted()->callAfterCreating(collect([$estimate]));
94
+            }
95
+
96
+            $convertedAt = Carbon::parse($estimate->accepted_at)
97
+                ->addDays($this->faker->numberBetween(1, 7));
98
+
99
+            $estimate->convertToInvoice($convertedAt);
100
+        });
101
+    }
102
+
103
+    public function declined(): static
104
+    {
105
+        return $this->afterCreating(function (Estimate $estimate) {
106
+            $this->ensureSent($estimate);
107
+
108
+            $declinedAt = Carbon::parse($estimate->last_sent_at)
109
+                ->addDays($this->faker->numberBetween(1, 7));
110
+
111
+            $estimate->markAsDeclined($declinedAt);
112
+        });
113
+    }
114
+
115
+    public function sent(): static
116
+    {
117
+        return $this->afterCreating(function (Estimate $estimate) {
118
+            $this->ensureApproved($estimate);
119
+
120
+            $sentAt = Carbon::parse($estimate->approved_at)
121
+                ->addHours($this->faker->numberBetween(1, 24));
122
+
123
+            $estimate->markAsSent($sentAt);
124
+        });
125
+    }
126
+
127
+    public function viewed(): static
128
+    {
129
+        return $this->afterCreating(function (Estimate $estimate) {
130
+            $this->ensureSent($estimate);
131
+
132
+            $viewedAt = Carbon::parse($estimate->last_sent_at)
133
+                ->addHours($this->faker->numberBetween(1, 24));
134
+
135
+            $estimate->markAsViewed($viewedAt);
136
+        });
137
+    }
138
+
139
+    public function expired(): static
140
+    {
141
+        return $this
142
+            ->state([
143
+                'expiration_date' => now()->subDays($this->faker->numberBetween(1, 30)),
144
+            ])
145
+            ->afterCreating(function (Estimate $estimate) {
146
+                $this->ensureApproved($estimate);
147
+            });
148
+    }
149
+
150
+    public function configure(): static
151
+    {
152
+        return $this->afterCreating(function (Estimate $estimate) {
153
+            $this->ensureLineItems($estimate);
154
+
155
+            $paddedId = str_pad((string) $estimate->id, 5, '0', STR_PAD_LEFT);
156
+
157
+            $estimate->updateQuietly([
158
+                'estimate_number' => "EST-{$paddedId}",
159
+                'reference_number' => "REF-{$paddedId}",
160
+            ]);
161
+
162
+            if ($estimate->wasApproved() && $estimate->is_currently_expired) {
163
+                $estimate->updateQuietly([
164
+                    'status' => EstimateStatus::Expired,
165
+                ]);
166
+            }
167
+        });
168
+    }
169
+
170
+    protected function ensureLineItems(Estimate $estimate): void
171
+    {
172
+        if (! $estimate->hasLineItems()) {
173
+            $this->withLineItems()->callAfterCreating(collect([$estimate]));
174
+        }
175
+    }
176
+
177
+    protected function ensureApproved(Estimate $estimate): void
178
+    {
179
+        if (! $estimate->wasApproved()) {
180
+            $this->approved()->callAfterCreating(collect([$estimate]));
181
+        }
182
+    }
183
+
184
+    protected function ensureSent(Estimate $estimate): void
185
+    {
186
+        if (! $estimate->hasBeenSent()) {
187
+            $this->sent()->callAfterCreating(collect([$estimate]));
188
+        }
189
+    }
190
+
191
+    protected function recalculateTotals(Estimate $estimate): void
192
+    {
193
+        $estimate->refresh();
194
+
195
+        if (! $estimate->hasLineItems()) {
196
+            return;
197
+        }
198
+
199
+        $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
200
+        $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
201
+        $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
202
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
203
+
204
+        $estimate->update([
205
+            'subtotal' => $subtotal,
206
+            'tax_total' => $taxTotal,
207
+            'discount_total' => $discountTotal,
208
+            'total' => $grandTotal,
209
+        ]);
210
+    }
211
+}

+ 125
- 43
database/factories/Accounting/InvoiceFactory.php View File

@@ -49,45 +49,103 @@ class InvoiceFactory extends Factory
49 49
         ];
50 50
     }
51 51
 
52
-    public function withLineItems(int $count = 3): self
52
+    public function withLineItems(int $count = 3): static
53 53
     {
54
-        return $this->has(DocumentLineItem::factory()->forInvoice()->count($count), 'lineItems');
54
+        return $this->afterCreating(function (Invoice $invoice) use ($count) {
55
+            DocumentLineItem::factory()
56
+                ->count($count)
57
+                ->forInvoice($invoice)
58
+                ->create();
59
+
60
+            $this->recalculateTotals($invoice);
61
+        });
55 62
     }
56 63
 
57 64
     public function approved(): static
58 65
     {
59 66
         return $this->afterCreating(function (Invoice $invoice) {
60
-            if (! $invoice->isDraft()) {
67
+            $this->ensureLineItems($invoice);
68
+
69
+            if (! $invoice->canBeApproved()) {
61 70
                 return;
62 71
             }
63 72
 
64
-            $this->recalculateTotals($invoice);
65
-
66
-            $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
73
+            $approvedAt = Carbon::parse($invoice->date)
74
+                ->addHours($this->faker->numberBetween(1, 24));
67 75
 
68 76
             $invoice->approveDraft($approvedAt);
69 77
         });
70 78
     }
71 79
 
72
-    public function withPayments(?int $min = 1, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
80
+    public function sent(): static
73 81
     {
74
-        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
75
-            if ($invoice->isDraft()) {
76
-                $this->recalculateTotals($invoice);
82
+        return $this->afterCreating(function (Invoice $invoice) {
83
+            $this->ensureApproved($invoice);
77 84
 
78
-                $approvedAt = Carbon::parse($invoice->date)->addHours($this->faker->numberBetween(1, 24));
79
-                $invoice->approveDraft($approvedAt);
80
-            }
85
+            $sentAt = Carbon::parse($invoice->approved_at)
86
+                ->addHours($this->faker->numberBetween(1, 24));
87
+
88
+            $invoice->markAsSent($sentAt);
89
+        });
90
+    }
91
+
92
+    public function partial(int $maxPayments = 4): static
93
+    {
94
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
95
+            $this->ensureSent($invoice);
96
+
97
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Partial)
98
+                ->callAfterCreating(collect([$invoice]));
99
+        });
100
+    }
101
+
102
+    public function paid(int $maxPayments = 4): static
103
+    {
104
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
105
+            $this->ensureSent($invoice);
106
+
107
+            $this->withPayments(max: $maxPayments)
108
+                ->callAfterCreating(collect([$invoice]));
109
+        });
110
+    }
111
+
112
+    public function overpaid(int $maxPayments = 4): static
113
+    {
114
+        return $this->afterCreating(function (Invoice $invoice) use ($maxPayments) {
115
+            $this->ensureSent($invoice);
116
+
117
+            $this->withPayments(max: $maxPayments, invoiceStatus: InvoiceStatus::Overpaid)
118
+                ->callAfterCreating(collect([$invoice]));
119
+        });
120
+    }
121
+
122
+    public function overdue(): static
123
+    {
124
+        return $this
125
+            ->state([
126
+                'due_date' => now()->subDays($this->faker->numberBetween(1, 30)),
127
+            ])
128
+            ->afterCreating(function (Invoice $invoice) {
129
+                $this->ensureApproved($invoice);
130
+            });
131
+    }
132
+
133
+    public function withPayments(?int $min = null, ?int $max = null, InvoiceStatus $invoiceStatus = InvoiceStatus::Paid): static
134
+    {
135
+        $min ??= 1;
136
+
137
+        return $this->afterCreating(function (Invoice $invoice) use ($invoiceStatus, $max, $min) {
138
+            $this->ensureSent($invoice);
81 139
 
82 140
             $invoice->refresh();
83 141
 
84
-            $totalAmountDue = $invoice->getRawOriginal('amount_due');
142
+            $amountDue = $invoice->getRawOriginal('amount_due');
85 143
 
86
-            if ($invoiceStatus === InvoiceStatus::Overpaid) {
87
-                $totalAmountDue += random_int(1000, 10000);
88
-            } elseif ($invoiceStatus === InvoiceStatus::Partial) {
89
-                $totalAmountDue = (int) floor($totalAmountDue * 0.5);
90
-            }
144
+            $totalAmountDue = match ($invoiceStatus) {
145
+                InvoiceStatus::Overpaid => $amountDue + random_int(1000, 10000),
146
+                InvoiceStatus::Partial => (int) floor($amountDue * 0.5),
147
+                default => $amountDue,
148
+            };
91 149
 
92 150
             if ($totalAmountDue <= 0 || empty($totalAmountDue)) {
93 151
                 return;
@@ -122,21 +180,23 @@ class InvoiceFactory extends Factory
122 180
                 $remainingAmount -= $amount;
123 181
             }
124 182
 
125
-            // If it's a paid invoice, use the latest payment date as paid_at
126
-            if ($invoiceStatus === InvoiceStatus::Paid) {
127
-                $latestPaymentDate = max($paymentDates);
128
-                $invoice->updateQuietly([
129
-                    'status' => $invoiceStatus,
130
-                    'paid_at' => $latestPaymentDate,
131
-                ]);
183
+            if ($invoiceStatus !== InvoiceStatus::Paid) {
184
+                return;
132 185
             }
186
+
187
+            $latestPaymentDate = max($paymentDates);
188
+            $invoice->updateQuietly([
189
+                'status' => $invoiceStatus,
190
+                'paid_at' => $latestPaymentDate,
191
+            ]);
133 192
         });
134 193
     }
135 194
 
136 195
     public function configure(): static
137 196
     {
138 197
         return $this->afterCreating(function (Invoice $invoice) {
139
-            // Use the invoice's ID to generate invoice and order numbers
198
+            $this->ensureLineItems($invoice);
199
+
140 200
             $paddedId = str_pad((string) $invoice->id, 5, '0', STR_PAD_LEFT);
141 201
 
142 202
             $invoice->updateQuietly([
@@ -144,9 +204,7 @@ class InvoiceFactory extends Factory
144 204
                 'order_number' => "ORD-{$paddedId}",
145 205
             ]);
146 206
 
147
-            $this->recalculateTotals($invoice);
148
-
149
-            if ($invoice->approved_at && $invoice->is_currently_overdue) {
207
+            if ($invoice->wasApproved() && $invoice->is_currently_overdue) {
150 208
                 $invoice->updateQuietly([
151 209
                     'status' => InvoiceStatus::Overdue,
152 210
                 ]);
@@ -154,21 +212,45 @@ class InvoiceFactory extends Factory
154 212
         });
155 213
     }
156 214
 
215
+    protected function ensureLineItems(Invoice $invoice): void
216
+    {
217
+        if (! $invoice->hasLineItems()) {
218
+            $this->withLineItems()->callAfterCreating(collect([$invoice]));
219
+        }
220
+    }
221
+
222
+    protected function ensureApproved(Invoice $invoice): void
223
+    {
224
+        if (! $invoice->wasApproved()) {
225
+            $this->approved()->callAfterCreating(collect([$invoice]));
226
+        }
227
+    }
228
+
229
+    protected function ensureSent(Invoice $invoice): void
230
+    {
231
+        if (! $invoice->hasBeenSent()) {
232
+            $this->sent()->callAfterCreating(collect([$invoice]));
233
+        }
234
+    }
235
+
157 236
     protected function recalculateTotals(Invoice $invoice): void
158 237
     {
159
-        if ($invoice->lineItems()->exists()) {
160
-            $invoice->refresh();
161
-            $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
162
-            $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
163
-            $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
164
-            $grandTotal = $subtotal + $taxTotal - $discountTotal;
165
-
166
-            $invoice->update([
167
-                'subtotal' => $subtotal,
168
-                'tax_total' => $taxTotal,
169
-                'discount_total' => $discountTotal,
170
-                'total' => $grandTotal,
171
-            ]);
238
+        $invoice->refresh();
239
+
240
+        if (! $invoice->hasLineItems()) {
241
+            return;
172 242
         }
243
+
244
+        $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
245
+        $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
246
+        $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
247
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
248
+
249
+        $invoice->update([
250
+            'subtotal' => $subtotal,
251
+            'tax_total' => $taxTotal,
252
+            'discount_total' => $discountTotal,
253
+            'total' => $grandTotal,
254
+        ]);
173 255
     }
174 256
 }

+ 94
- 27
database/factories/CompanyFactory.php View File

@@ -2,9 +2,8 @@
2 2
 
3 3
 namespace Database\Factories;
4 4
 
5
-use App\Enums\Accounting\BillStatus;
6
-use App\Enums\Accounting\InvoiceStatus;
7 5
 use App\Models\Accounting\Bill;
6
+use App\Models\Accounting\Estimate;
8 7
 use App\Models\Accounting\Invoice;
9 8
 use App\Models\Accounting\Transaction;
10 9
 use App\Models\Common\Client;
@@ -106,12 +105,12 @@ class CompanyFactory extends Factory
106 105
             $draftCount = (int) floor($count * 0.2);
107 106
             $approvedCount = (int) floor($count * 0.2);
108 107
             $paidCount = (int) floor($count * 0.3);
109
-            $partialCount = (int) floor($count * 0.2);
110
-            $overpaidCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount);
108
+            $partialCount = (int) floor($count * 0.1);
109
+            $overpaidCount = (int) floor($count * 0.1);
110
+            $overdueCount = $count - ($draftCount + $approvedCount + $paidCount + $partialCount + $overpaidCount);
111 111
 
112 112
             Invoice::factory()
113 113
                 ->count($draftCount)
114
-                ->withLineItems()
115 114
                 ->create([
116 115
                     'company_id' => $company->id,
117 116
                     'created_by' => $company->user_id,
@@ -120,7 +119,6 @@ class CompanyFactory extends Factory
120 119
 
121 120
             Invoice::factory()
122 121
                 ->count($approvedCount)
123
-                ->withLineItems()
124 122
                 ->approved()
125 123
                 ->create([
126 124
                     'company_id' => $company->id,
@@ -130,9 +128,7 @@ class CompanyFactory extends Factory
130 128
 
131 129
             Invoice::factory()
132 130
                 ->count($paidCount)
133
-                ->withLineItems()
134
-                ->approved()
135
-                ->withPayments(max: 4)
131
+                ->paid()
136 132
                 ->create([
137 133
                     'company_id' => $company->id,
138 134
                     'created_by' => $company->user_id,
@@ -141,9 +137,7 @@ class CompanyFactory extends Factory
141 137
 
142 138
             Invoice::factory()
143 139
                 ->count($partialCount)
144
-                ->withLineItems()
145
-                ->approved()
146
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Partial)
140
+                ->partial()
147 141
                 ->create([
148 142
                     'company_id' => $company->id,
149 143
                     'created_by' => $company->user_id,
@@ -152,9 +146,81 @@ class CompanyFactory extends Factory
152 146
 
153 147
             Invoice::factory()
154 148
                 ->count($overpaidCount)
155
-                ->withLineItems()
149
+                ->overpaid()
150
+                ->create([
151
+                    'company_id' => $company->id,
152
+                    'created_by' => $company->user_id,
153
+                    'updated_by' => $company->user_id,
154
+                ]);
155
+
156
+            Invoice::factory()
157
+                ->count($overdueCount)
158
+                ->overdue()
159
+                ->create([
160
+                    'company_id' => $company->id,
161
+                    'created_by' => $company->user_id,
162
+                    'updated_by' => $company->user_id,
163
+                ]);
164
+        });
165
+    }
166
+
167
+    public function withEstimates(int $count = 10): self
168
+    {
169
+        return $this->afterCreating(function (Company $company) use ($count) {
170
+            $draftCount = (int) floor($count * 0.2);     // 20% drafts
171
+            $approvedCount = (int) floor($count * 0.3);   // 30% approved
172
+            $acceptedCount = (int) floor($count * 0.2);  // 20% accepted
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%
176
+
177
+            Estimate::factory()
178
+                ->count($draftCount)
179
+                ->create([
180
+                    'company_id' => $company->id,
181
+                    'created_by' => $company->user_id,
182
+                    'updated_by' => $company->user_id,
183
+                ]);
184
+
185
+            Estimate::factory()
186
+                ->count($approvedCount)
156 187
                 ->approved()
157
-                ->withPayments(max: 4, invoiceStatus: InvoiceStatus::Overpaid)
188
+                ->create([
189
+                    'company_id' => $company->id,
190
+                    'created_by' => $company->user_id,
191
+                    'updated_by' => $company->user_id,
192
+                ]);
193
+
194
+            Estimate::factory()
195
+                ->count($acceptedCount)
196
+                ->accepted()
197
+                ->create([
198
+                    'company_id' => $company->id,
199
+                    'created_by' => $company->user_id,
200
+                    'updated_by' => $company->user_id,
201
+                ]);
202
+
203
+            Estimate::factory()
204
+                ->count($declinedCount)
205
+                ->declined()
206
+                ->create([
207
+                    'company_id' => $company->id,
208
+                    'created_by' => $company->user_id,
209
+                    'updated_by' => $company->user_id,
210
+                ]);
211
+
212
+            Estimate::factory()
213
+                ->count($convertedCount)
214
+                ->converted()
215
+                ->create([
216
+                    'company_id' => $company->id,
217
+                    'created_by' => $company->user_id,
218
+                    'updated_by' => $company->user_id,
219
+                ]);
220
+
221
+            Estimate::factory()
222
+                ->count($expiredCount)
223
+                ->expired()
158 224
                 ->create([
159 225
                     'company_id' => $company->id,
160 226
                     'created_by' => $company->user_id,
@@ -167,38 +233,39 @@ class CompanyFactory extends Factory
167 233
     {
168 234
         return $this->afterCreating(function (Company $company) use ($count) {
169 235
             $unpaidCount = (int) floor($count * 0.4);
170
-            $paidCount = (int) floor($count * 0.4);
171
-            $partialCount = $count - ($unpaidCount + $paidCount);
236
+            $paidCount = (int) floor($count * 0.3);
237
+            $partialCount = (int) floor($count * 0.2);
238
+            $overdueCount = $count - ($unpaidCount + $paidCount + $partialCount);
172 239
 
173
-            // Create unpaid bills
174 240
             Bill::factory()
175 241
                 ->count($unpaidCount)
176
-                ->withLineItems()
177
-                ->initialized()
178 242
                 ->create([
179 243
                     'company_id' => $company->id,
180 244
                     'created_by' => $company->user_id,
181 245
                     'updated_by' => $company->user_id,
182 246
                 ]);
183 247
 
184
-            // Create paid bills
185 248
             Bill::factory()
186 249
                 ->count($paidCount)
187
-                ->withLineItems()
188
-                ->initialized()
189
-                ->withPayments(max: 4)
250
+                ->paid()
190 251
                 ->create([
191 252
                     'company_id' => $company->id,
192 253
                     'created_by' => $company->user_id,
193 254
                     'updated_by' => $company->user_id,
194 255
                 ]);
195 256
 
196
-            // Create partially paid bills
197 257
             Bill::factory()
198 258
                 ->count($partialCount)
199
-                ->withLineItems()
200
-                ->initialized()
201
-                ->withPayments(max: 4, billStatus: BillStatus::Partial)
259
+                ->partial()
260
+                ->create([
261
+                    'company_id' => $company->id,
262
+                    'created_by' => $company->user_id,
263
+                    'updated_by' => $company->user_id,
264
+                ]);
265
+
266
+            Bill::factory()
267
+                ->count($overdueCount)
268
+                ->overdue()
202 269
                 ->create([
203 270
                     'company_id' => $company->id,
204 271
                     'created_by' => $company->user_id,

+ 55
- 0
database/migrations/2024_11_27_223000_create_estimates_table.php View File

@@ -0,0 +1,55 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('estimates', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->string('logo')->nullable();
19
+            $table->string('header')->nullable();
20
+            $table->string('subheader')->nullable();
21
+            $table->string('estimate_number')->nullable();
22
+            $table->string('reference_number')->nullable(); // PO, SO, etc.
23
+            $table->date('date')->nullable();
24
+            $table->date('expiration_date')->nullable();
25
+            $table->timestamp('approved_at')->nullable();
26
+            $table->timestamp('accepted_at')->nullable();
27
+            $table->timestamp('converted_at')->nullable();
28
+            $table->timestamp('declined_at')->nullable();
29
+            $table->timestamp('last_sent_at')->nullable();
30
+            $table->timestamp('last_viewed_at')->nullable();
31
+            $table->string('status')->default('draft');
32
+            $table->string('currency_code')->nullable();
33
+            $table->string('discount_method')->default('per_line_item');
34
+            $table->string('discount_computation')->default('percentage');
35
+            $table->integer('discount_rate')->default(0);
36
+            $table->integer('subtotal')->default(0);
37
+            $table->integer('tax_total')->default(0);
38
+            $table->integer('discount_total')->default(0);
39
+            $table->integer('total')->default(0);
40
+            $table->text('terms')->nullable(); // terms, notes
41
+            $table->text('footer')->nullable();
42
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
43
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
44
+            $table->timestamps();
45
+        });
46
+    }
47
+
48
+    /**
49
+     * Reverse the migrations.
50
+     */
51
+    public function down(): void
52
+    {
53
+        Schema::dropIfExists('estimates');
54
+    }
55
+};

+ 3
- 1
database/migrations/2024_11_27_223015_create_invoices_table.php View File

@@ -15,6 +15,7 @@ return new class extends Migration
15 15
             $table->id();
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->foreignId('estimate_id')->nullable()->constrained('estimates')->nullOnDelete();
18 19
             $table->string('logo')->nullable();
19 20
             $table->string('header')->nullable();
20 21
             $table->string('subheader')->nullable();
@@ -24,7 +25,8 @@ return new class extends Migration
24 25
             $table->date('due_date')->nullable();
25 26
             $table->timestamp('approved_at')->nullable();
26 27
             $table->timestamp('paid_at')->nullable();
27
-            $table->timestamp('last_sent')->nullable();
28
+            $table->timestamp('last_sent_at')->nullable();
29
+            $table->timestamp('last_viewed_at')->nullable();
28 30
             $table->string('status')->default('draft');
29 31
             $table->string('currency_code')->nullable();
30 32
             $table->string('discount_method')->default('per_line_item');

+ 1
- 0
database/seeders/DatabaseSeeder.php View File

@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
25 25
                     ->withClients()
26 26
                     ->withVendors()
27 27
                     ->withInvoices(50)
28
+                    ->withEstimates(50)
28 29
                     ->withBills(50);
29 30
             })
30 31
             ->create([

+ 218
- 197
package-lock.json View File

@@ -30,9 +30,9 @@
30 30
             }
31 31
         },
32 32
         "node_modules/@esbuild/aix-ppc64": {
33
-            "version": "0.24.0",
34
-            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz",
35
-            "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==",
33
+            "version": "0.24.2",
34
+            "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
35
+            "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
36 36
             "cpu": [
37 37
                 "ppc64"
38 38
             ],
@@ -47,9 +47,9 @@
47 47
             }
48 48
         },
49 49
         "node_modules/@esbuild/android-arm": {
50
-            "version": "0.24.0",
51
-            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz",
52
-            "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==",
50
+            "version": "0.24.2",
51
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
52
+            "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
53 53
             "cpu": [
54 54
                 "arm"
55 55
             ],
@@ -64,9 +64,9 @@
64 64
             }
65 65
         },
66 66
         "node_modules/@esbuild/android-arm64": {
67
-            "version": "0.24.0",
68
-            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz",
69
-            "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==",
67
+            "version": "0.24.2",
68
+            "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
69
+            "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
70 70
             "cpu": [
71 71
                 "arm64"
72 72
             ],
@@ -81,9 +81,9 @@
81 81
             }
82 82
         },
83 83
         "node_modules/@esbuild/android-x64": {
84
-            "version": "0.24.0",
85
-            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz",
86
-            "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==",
84
+            "version": "0.24.2",
85
+            "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
86
+            "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
87 87
             "cpu": [
88 88
                 "x64"
89 89
             ],
@@ -98,9 +98,9 @@
98 98
             }
99 99
         },
100 100
         "node_modules/@esbuild/darwin-arm64": {
101
-            "version": "0.24.0",
102
-            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz",
103
-            "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==",
101
+            "version": "0.24.2",
102
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
103
+            "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
104 104
             "cpu": [
105 105
                 "arm64"
106 106
             ],
@@ -115,9 +115,9 @@
115 115
             }
116 116
         },
117 117
         "node_modules/@esbuild/darwin-x64": {
118
-            "version": "0.24.0",
119
-            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz",
120
-            "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==",
118
+            "version": "0.24.2",
119
+            "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
120
+            "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
121 121
             "cpu": [
122 122
                 "x64"
123 123
             ],
@@ -132,9 +132,9 @@
132 132
             }
133 133
         },
134 134
         "node_modules/@esbuild/freebsd-arm64": {
135
-            "version": "0.24.0",
136
-            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz",
137
-            "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==",
135
+            "version": "0.24.2",
136
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
137
+            "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
138 138
             "cpu": [
139 139
                 "arm64"
140 140
             ],
@@ -149,9 +149,9 @@
149 149
             }
150 150
         },
151 151
         "node_modules/@esbuild/freebsd-x64": {
152
-            "version": "0.24.0",
153
-            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz",
154
-            "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==",
152
+            "version": "0.24.2",
153
+            "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
154
+            "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
155 155
             "cpu": [
156 156
                 "x64"
157 157
             ],
@@ -166,9 +166,9 @@
166 166
             }
167 167
         },
168 168
         "node_modules/@esbuild/linux-arm": {
169
-            "version": "0.24.0",
170
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz",
171
-            "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==",
169
+            "version": "0.24.2",
170
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
171
+            "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
172 172
             "cpu": [
173 173
                 "arm"
174 174
             ],
@@ -183,9 +183,9 @@
183 183
             }
184 184
         },
185 185
         "node_modules/@esbuild/linux-arm64": {
186
-            "version": "0.24.0",
187
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz",
188
-            "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==",
186
+            "version": "0.24.2",
187
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
188
+            "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
189 189
             "cpu": [
190 190
                 "arm64"
191 191
             ],
@@ -200,9 +200,9 @@
200 200
             }
201 201
         },
202 202
         "node_modules/@esbuild/linux-ia32": {
203
-            "version": "0.24.0",
204
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz",
205
-            "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==",
203
+            "version": "0.24.2",
204
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
205
+            "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
206 206
             "cpu": [
207 207
                 "ia32"
208 208
             ],
@@ -217,9 +217,9 @@
217 217
             }
218 218
         },
219 219
         "node_modules/@esbuild/linux-loong64": {
220
-            "version": "0.24.0",
221
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz",
222
-            "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==",
220
+            "version": "0.24.2",
221
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
222
+            "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
223 223
             "cpu": [
224 224
                 "loong64"
225 225
             ],
@@ -234,9 +234,9 @@
234 234
             }
235 235
         },
236 236
         "node_modules/@esbuild/linux-mips64el": {
237
-            "version": "0.24.0",
238
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz",
239
-            "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==",
237
+            "version": "0.24.2",
238
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
239
+            "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
240 240
             "cpu": [
241 241
                 "mips64el"
242 242
             ],
@@ -251,9 +251,9 @@
251 251
             }
252 252
         },
253 253
         "node_modules/@esbuild/linux-ppc64": {
254
-            "version": "0.24.0",
255
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz",
256
-            "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==",
254
+            "version": "0.24.2",
255
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
256
+            "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
257 257
             "cpu": [
258 258
                 "ppc64"
259 259
             ],
@@ -268,9 +268,9 @@
268 268
             }
269 269
         },
270 270
         "node_modules/@esbuild/linux-riscv64": {
271
-            "version": "0.24.0",
272
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz",
273
-            "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==",
271
+            "version": "0.24.2",
272
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
273
+            "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
274 274
             "cpu": [
275 275
                 "riscv64"
276 276
             ],
@@ -285,9 +285,9 @@
285 285
             }
286 286
         },
287 287
         "node_modules/@esbuild/linux-s390x": {
288
-            "version": "0.24.0",
289
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz",
290
-            "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==",
288
+            "version": "0.24.2",
289
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
290
+            "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
291 291
             "cpu": [
292 292
                 "s390x"
293 293
             ],
@@ -302,9 +302,9 @@
302 302
             }
303 303
         },
304 304
         "node_modules/@esbuild/linux-x64": {
305
-            "version": "0.24.0",
306
-            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz",
307
-            "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==",
305
+            "version": "0.24.2",
306
+            "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
307
+            "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
308 308
             "cpu": [
309 309
                 "x64"
310 310
             ],
@@ -318,10 +318,27 @@
318 318
                 "node": ">=18"
319 319
             }
320 320
         },
321
+        "node_modules/@esbuild/netbsd-arm64": {
322
+            "version": "0.24.2",
323
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
324
+            "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
325
+            "cpu": [
326
+                "arm64"
327
+            ],
328
+            "dev": true,
329
+            "license": "MIT",
330
+            "optional": true,
331
+            "os": [
332
+                "netbsd"
333
+            ],
334
+            "engines": {
335
+                "node": ">=18"
336
+            }
337
+        },
321 338
         "node_modules/@esbuild/netbsd-x64": {
322
-            "version": "0.24.0",
323
-            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz",
324
-            "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==",
339
+            "version": "0.24.2",
340
+            "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
341
+            "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
325 342
             "cpu": [
326 343
                 "x64"
327 344
             ],
@@ -336,9 +353,9 @@
336 353
             }
337 354
         },
338 355
         "node_modules/@esbuild/openbsd-arm64": {
339
-            "version": "0.24.0",
340
-            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz",
341
-            "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==",
356
+            "version": "0.24.2",
357
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
358
+            "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
342 359
             "cpu": [
343 360
                 "arm64"
344 361
             ],
@@ -353,9 +370,9 @@
353 370
             }
354 371
         },
355 372
         "node_modules/@esbuild/openbsd-x64": {
356
-            "version": "0.24.0",
357
-            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz",
358
-            "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==",
373
+            "version": "0.24.2",
374
+            "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
375
+            "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
359 376
             "cpu": [
360 377
                 "x64"
361 378
             ],
@@ -370,9 +387,9 @@
370 387
             }
371 388
         },
372 389
         "node_modules/@esbuild/sunos-x64": {
373
-            "version": "0.24.0",
374
-            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz",
375
-            "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==",
390
+            "version": "0.24.2",
391
+            "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
392
+            "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
376 393
             "cpu": [
377 394
                 "x64"
378 395
             ],
@@ -387,9 +404,9 @@
387 404
             }
388 405
         },
389 406
         "node_modules/@esbuild/win32-arm64": {
390
-            "version": "0.24.0",
391
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz",
392
-            "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==",
407
+            "version": "0.24.2",
408
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
409
+            "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
393 410
             "cpu": [
394 411
                 "arm64"
395 412
             ],
@@ -404,9 +421,9 @@
404 421
             }
405 422
         },
406 423
         "node_modules/@esbuild/win32-ia32": {
407
-            "version": "0.24.0",
408
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz",
409
-            "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==",
424
+            "version": "0.24.2",
425
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
426
+            "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
410 427
             "cpu": [
411 428
                 "ia32"
412 429
             ],
@@ -421,9 +438,9 @@
421 438
             }
422 439
         },
423 440
         "node_modules/@esbuild/win32-x64": {
424
-            "version": "0.24.0",
425
-            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz",
426
-            "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==",
441
+            "version": "0.24.2",
442
+            "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
443
+            "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
427 444
             "cpu": [
428 445
                 "x64"
429 446
             ],
@@ -558,9 +575,9 @@
558 575
             }
559 576
         },
560 577
         "node_modules/@rollup/rollup-android-arm-eabi": {
561
-            "version": "4.28.1",
562
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.28.1.tgz",
563
-            "integrity": "sha512-2aZp8AES04KI2dy3Ss6/MDjXbwBzj+i0GqKtWXgw2/Ma6E4jJvujryO6gJAghIRVz7Vwr9Gtl/8na3nDUKpraQ==",
578
+            "version": "4.29.1",
579
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz",
580
+            "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==",
564 581
             "cpu": [
565 582
                 "arm"
566 583
             ],
@@ -572,9 +589,9 @@
572 589
             ]
573 590
         },
574 591
         "node_modules/@rollup/rollup-android-arm64": {
575
-            "version": "4.28.1",
576
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.28.1.tgz",
577
-            "integrity": "sha512-EbkK285O+1YMrg57xVA+Dp0tDBRB93/BZKph9XhMjezf6F4TpYjaUSuPt5J0fZXlSag0LmZAsTmdGGqPp4pQFA==",
592
+            "version": "4.29.1",
593
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.1.tgz",
594
+            "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==",
578 595
             "cpu": [
579 596
                 "arm64"
580 597
             ],
@@ -586,9 +603,9 @@
586 603
             ]
587 604
         },
588 605
         "node_modules/@rollup/rollup-darwin-arm64": {
589
-            "version": "4.28.1",
590
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.28.1.tgz",
591
-            "integrity": "sha512-prduvrMKU6NzMq6nxzQw445zXgaDBbMQvmKSJaxpaZ5R1QDM8w+eGxo6Y/jhT/cLoCvnZI42oEqf9KQNYz1fqQ==",
606
+            "version": "4.29.1",
607
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.1.tgz",
608
+            "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==",
592 609
             "cpu": [
593 610
                 "arm64"
594 611
             ],
@@ -600,9 +617,9 @@
600 617
             ]
601 618
         },
602 619
         "node_modules/@rollup/rollup-darwin-x64": {
603
-            "version": "4.28.1",
604
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.28.1.tgz",
605
-            "integrity": "sha512-WsvbOunsUk0wccO/TV4o7IKgloJ942hVFK1CLatwv6TJspcCZb9umQkPdvB7FihmdxgaKR5JyxDjWpCOp4uZlQ==",
620
+            "version": "4.29.1",
621
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.1.tgz",
622
+            "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==",
606 623
             "cpu": [
607 624
                 "x64"
608 625
             ],
@@ -614,9 +631,9 @@
614 631
             ]
615 632
         },
616 633
         "node_modules/@rollup/rollup-freebsd-arm64": {
617
-            "version": "4.28.1",
618
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.28.1.tgz",
619
-            "integrity": "sha512-HTDPdY1caUcU4qK23FeeGxCdJF64cKkqajU0iBnTVxS8F7H/7BewvYoG+va1KPSL63kQ1PGNyiwKOfReavzvNA==",
634
+            "version": "4.29.1",
635
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.1.tgz",
636
+            "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==",
620 637
             "cpu": [
621 638
                 "arm64"
622 639
             ],
@@ -628,9 +645,9 @@
628 645
             ]
629 646
         },
630 647
         "node_modules/@rollup/rollup-freebsd-x64": {
631
-            "version": "4.28.1",
632
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.28.1.tgz",
633
-            "integrity": "sha512-m/uYasxkUevcFTeRSM9TeLyPe2QDuqtjkeoTpP9SW0XxUWfcYrGDMkO/m2tTw+4NMAF9P2fU3Mw4ahNvo7QmsQ==",
648
+            "version": "4.29.1",
649
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.1.tgz",
650
+            "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==",
634 651
             "cpu": [
635 652
                 "x64"
636 653
             ],
@@ -642,9 +659,9 @@
642 659
             ]
643 660
         },
644 661
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
645
-            "version": "4.28.1",
646
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.28.1.tgz",
647
-            "integrity": "sha512-QAg11ZIt6mcmzpNE6JZBpKfJaKkqTm1A9+y9O+frdZJEuhQxiugM05gnCWiANHj4RmbgeVJpTdmKRmH/a+0QbA==",
662
+            "version": "4.29.1",
663
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.1.tgz",
664
+            "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==",
648 665
             "cpu": [
649 666
                 "arm"
650 667
             ],
@@ -656,9 +673,9 @@
656 673
             ]
657 674
         },
658 675
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
659
-            "version": "4.28.1",
660
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.28.1.tgz",
661
-            "integrity": "sha512-dRP9PEBfolq1dmMcFqbEPSd9VlRuVWEGSmbxVEfiq2cs2jlZAl0YNxFzAQS2OrQmsLBLAATDMb3Z6MFv5vOcXg==",
676
+            "version": "4.29.1",
677
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.1.tgz",
678
+            "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==",
662 679
             "cpu": [
663 680
                 "arm"
664 681
             ],
@@ -670,9 +687,9 @@
670 687
             ]
671 688
         },
672 689
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
673
-            "version": "4.28.1",
674
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.28.1.tgz",
675
-            "integrity": "sha512-uGr8khxO+CKT4XU8ZUH1TTEUtlktK6Kgtv0+6bIFSeiSlnGJHG1tSFSjm41uQ9sAO/5ULx9mWOz70jYLyv1QkA==",
690
+            "version": "4.29.1",
691
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.1.tgz",
692
+            "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==",
676 693
             "cpu": [
677 694
                 "arm64"
678 695
             ],
@@ -684,9 +701,9 @@
684 701
             ]
685 702
         },
686 703
         "node_modules/@rollup/rollup-linux-arm64-musl": {
687
-            "version": "4.28.1",
688
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.28.1.tgz",
689
-            "integrity": "sha512-QF54q8MYGAqMLrX2t7tNpi01nvq5RI59UBNx+3+37zoKX5KViPo/gk2QLhsuqok05sSCRluj0D00LzCwBikb0A==",
704
+            "version": "4.29.1",
705
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.1.tgz",
706
+            "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==",
690 707
             "cpu": [
691 708
                 "arm64"
692 709
             ],
@@ -698,9 +715,9 @@
698 715
             ]
699 716
         },
700 717
         "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
701
-            "version": "4.28.1",
702
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.28.1.tgz",
703
-            "integrity": "sha512-vPul4uodvWvLhRco2w0GcyZcdyBfpfDRgNKU+p35AWEbJ/HPs1tOUrkSueVbBS0RQHAf/A+nNtDpvw95PeVKOA==",
718
+            "version": "4.29.1",
719
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.1.tgz",
720
+            "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==",
704 721
             "cpu": [
705 722
                 "loong64"
706 723
             ],
@@ -712,9 +729,9 @@
712 729
             ]
713 730
         },
714 731
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
715
-            "version": "4.28.1",
716
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.28.1.tgz",
717
-            "integrity": "sha512-pTnTdBuC2+pt1Rmm2SV7JWRqzhYpEILML4PKODqLz+C7Ou2apEV52h19CR7es+u04KlqplggmN9sqZlekg3R1A==",
732
+            "version": "4.29.1",
733
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.1.tgz",
734
+            "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==",
718 735
             "cpu": [
719 736
                 "ppc64"
720 737
             ],
@@ -726,9 +743,9 @@
726 743
             ]
727 744
         },
728 745
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
729
-            "version": "4.28.1",
730
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.28.1.tgz",
731
-            "integrity": "sha512-vWXy1Nfg7TPBSuAncfInmAI/WZDd5vOklyLJDdIRKABcZWojNDY0NJwruY2AcnCLnRJKSaBgf/GiJfauu8cQZA==",
746
+            "version": "4.29.1",
747
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.1.tgz",
748
+            "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==",
732 749
             "cpu": [
733 750
                 "riscv64"
734 751
             ],
@@ -740,9 +757,9 @@
740 757
             ]
741 758
         },
742 759
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
743
-            "version": "4.28.1",
744
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.28.1.tgz",
745
-            "integrity": "sha512-/yqC2Y53oZjb0yz8PVuGOQQNOTwxcizudunl/tFs1aLvObTclTwZ0JhXF2XcPT/zuaymemCDSuuUPXJJyqeDOg==",
760
+            "version": "4.29.1",
761
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.1.tgz",
762
+            "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==",
746 763
             "cpu": [
747 764
                 "s390x"
748 765
             ],
@@ -754,9 +771,9 @@
754 771
             ]
755 772
         },
756 773
         "node_modules/@rollup/rollup-linux-x64-gnu": {
757
-            "version": "4.28.1",
758
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.28.1.tgz",
759
-            "integrity": "sha512-fzgeABz7rrAlKYB0y2kSEiURrI0691CSL0+KXwKwhxvj92VULEDQLpBYLHpF49MSiPG4sq5CK3qHMnb9tlCjBw==",
774
+            "version": "4.29.1",
775
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.1.tgz",
776
+            "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==",
760 777
             "cpu": [
761 778
                 "x64"
762 779
             ],
@@ -768,9 +785,9 @@
768 785
             ]
769 786
         },
770 787
         "node_modules/@rollup/rollup-linux-x64-musl": {
771
-            "version": "4.28.1",
772
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.28.1.tgz",
773
-            "integrity": "sha512-xQTDVzSGiMlSshpJCtudbWyRfLaNiVPXt1WgdWTwWz9n0U12cI2ZVtWe/Jgwyv/6wjL7b66uu61Vg0POWVfz4g==",
788
+            "version": "4.29.1",
789
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.1.tgz",
790
+            "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==",
774 791
             "cpu": [
775 792
                 "x64"
776 793
             ],
@@ -782,9 +799,9 @@
782 799
             ]
783 800
         },
784 801
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
785
-            "version": "4.28.1",
786
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.28.1.tgz",
787
-            "integrity": "sha512-wSXmDRVupJstFP7elGMgv+2HqXelQhuNf+IS4V+nUpNVi/GUiBgDmfwD0UGN3pcAnWsgKG3I52wMOBnk1VHr/A==",
802
+            "version": "4.29.1",
803
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.1.tgz",
804
+            "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==",
788 805
             "cpu": [
789 806
                 "arm64"
790 807
             ],
@@ -796,9 +813,9 @@
796 813
             ]
797 814
         },
798 815
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
799
-            "version": "4.28.1",
800
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.28.1.tgz",
801
-            "integrity": "sha512-ZkyTJ/9vkgrE/Rk9vhMXhf8l9D+eAhbAVbsGsXKy2ohmJaWg0LPQLnIxRdRp/bKyr8tXuPlXhIoGlEB5XpJnGA==",
816
+            "version": "4.29.1",
817
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.1.tgz",
818
+            "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==",
802 819
             "cpu": [
803 820
                 "ia32"
804 821
             ],
@@ -810,9 +827,9 @@
810 827
             ]
811 828
         },
812 829
         "node_modules/@rollup/rollup-win32-x64-msvc": {
813
-            "version": "4.28.1",
814
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.28.1.tgz",
815
-            "integrity": "sha512-ZvK2jBafvttJjoIdKm/Q/Bh7IJ1Ose9IBOwpOXcOvW3ikGTQGmKDgxTC6oCAzW6PynbkKP8+um1du81XJHZ0JA==",
830
+            "version": "4.29.1",
831
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.1.tgz",
832
+            "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==",
816 833
             "cpu": [
817 834
                 "x64"
818 835
             ],
@@ -1057,9 +1074,9 @@
1057 1074
             }
1058 1075
         },
1059 1076
         "node_modules/caniuse-lite": {
1060
-            "version": "1.0.30001689",
1061
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001689.tgz",
1062
-            "integrity": "sha512-CmeR2VBycfa+5/jOfnp/NpWPGd06nf1XYiefUvhXFfZE4GkRc9jv+eGPS4nT558WS/8lYCzV8SlANCIPvbWP1g==",
1077
+            "version": "1.0.30001690",
1078
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
1079
+            "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
1063 1080
             "dev": true,
1064 1081
             "funding": [
1065 1082
                 {
@@ -1218,9 +1235,9 @@
1218 1235
             "license": "MIT"
1219 1236
         },
1220 1237
         "node_modules/electron-to-chromium": {
1221
-            "version": "1.5.74",
1222
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.74.tgz",
1223
-            "integrity": "sha512-ck3//9RC+6oss/1Bh9tiAVFy5vfSKbRHAFh7Z3/eTRkEqJeWgymloShB17Vg3Z4nmDNp35vAd1BZ6CMW4Wt6Iw==",
1238
+            "version": "1.5.76",
1239
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
1240
+            "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==",
1224 1241
             "dev": true,
1225 1242
             "license": "ISC"
1226 1243
         },
@@ -1232,9 +1249,9 @@
1232 1249
             "license": "MIT"
1233 1250
         },
1234 1251
         "node_modules/esbuild": {
1235
-            "version": "0.24.0",
1236
-            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz",
1237
-            "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==",
1252
+            "version": "0.24.2",
1253
+            "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
1254
+            "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
1238 1255
             "dev": true,
1239 1256
             "hasInstallScript": true,
1240 1257
             "license": "MIT",
@@ -1245,30 +1262,31 @@
1245 1262
                 "node": ">=18"
1246 1263
             },
1247 1264
             "optionalDependencies": {
1248
-                "@esbuild/aix-ppc64": "0.24.0",
1249
-                "@esbuild/android-arm": "0.24.0",
1250
-                "@esbuild/android-arm64": "0.24.0",
1251
-                "@esbuild/android-x64": "0.24.0",
1252
-                "@esbuild/darwin-arm64": "0.24.0",
1253
-                "@esbuild/darwin-x64": "0.24.0",
1254
-                "@esbuild/freebsd-arm64": "0.24.0",
1255
-                "@esbuild/freebsd-x64": "0.24.0",
1256
-                "@esbuild/linux-arm": "0.24.0",
1257
-                "@esbuild/linux-arm64": "0.24.0",
1258
-                "@esbuild/linux-ia32": "0.24.0",
1259
-                "@esbuild/linux-loong64": "0.24.0",
1260
-                "@esbuild/linux-mips64el": "0.24.0",
1261
-                "@esbuild/linux-ppc64": "0.24.0",
1262
-                "@esbuild/linux-riscv64": "0.24.0",
1263
-                "@esbuild/linux-s390x": "0.24.0",
1264
-                "@esbuild/linux-x64": "0.24.0",
1265
-                "@esbuild/netbsd-x64": "0.24.0",
1266
-                "@esbuild/openbsd-arm64": "0.24.0",
1267
-                "@esbuild/openbsd-x64": "0.24.0",
1268
-                "@esbuild/sunos-x64": "0.24.0",
1269
-                "@esbuild/win32-arm64": "0.24.0",
1270
-                "@esbuild/win32-ia32": "0.24.0",
1271
-                "@esbuild/win32-x64": "0.24.0"
1265
+                "@esbuild/aix-ppc64": "0.24.2",
1266
+                "@esbuild/android-arm": "0.24.2",
1267
+                "@esbuild/android-arm64": "0.24.2",
1268
+                "@esbuild/android-x64": "0.24.2",
1269
+                "@esbuild/darwin-arm64": "0.24.2",
1270
+                "@esbuild/darwin-x64": "0.24.2",
1271
+                "@esbuild/freebsd-arm64": "0.24.2",
1272
+                "@esbuild/freebsd-x64": "0.24.2",
1273
+                "@esbuild/linux-arm": "0.24.2",
1274
+                "@esbuild/linux-arm64": "0.24.2",
1275
+                "@esbuild/linux-ia32": "0.24.2",
1276
+                "@esbuild/linux-loong64": "0.24.2",
1277
+                "@esbuild/linux-mips64el": "0.24.2",
1278
+                "@esbuild/linux-ppc64": "0.24.2",
1279
+                "@esbuild/linux-riscv64": "0.24.2",
1280
+                "@esbuild/linux-s390x": "0.24.2",
1281
+                "@esbuild/linux-x64": "0.24.2",
1282
+                "@esbuild/netbsd-arm64": "0.24.2",
1283
+                "@esbuild/netbsd-x64": "0.24.2",
1284
+                "@esbuild/openbsd-arm64": "0.24.2",
1285
+                "@esbuild/openbsd-x64": "0.24.2",
1286
+                "@esbuild/sunos-x64": "0.24.2",
1287
+                "@esbuild/win32-arm64": "0.24.2",
1288
+                "@esbuild/win32-ia32": "0.24.2",
1289
+                "@esbuild/win32-x64": "0.24.2"
1272 1290
             }
1273 1291
         },
1274 1292
         "node_modules/escalade": {
@@ -1312,9 +1330,9 @@
1312 1330
             }
1313 1331
         },
1314 1332
         "node_modules/fastq": {
1315
-            "version": "1.17.1",
1316
-            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
1317
-            "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==",
1333
+            "version": "1.18.0",
1334
+            "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
1335
+            "integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
1318 1336
             "dev": true,
1319 1337
             "license": "ISC",
1320 1338
             "dependencies": {
@@ -1487,9 +1505,9 @@
1487 1505
             }
1488 1506
         },
1489 1507
         "node_modules/is-core-module": {
1490
-            "version": "2.16.0",
1491
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.0.tgz",
1492
-            "integrity": "sha512-urTSINYfAYgcbLb0yDQ6egFm6h3Mo1DcF9EkyXSRjjzdHbsulg01qhwWuXdOoUBuTkbQ80KDboXa0vFJ+BDH+g==",
1508
+            "version": "2.16.1",
1509
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
1510
+            "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
1493 1511
             "dev": true,
1494 1512
             "license": "MIT",
1495 1513
             "dependencies": {
@@ -2192,9 +2210,9 @@
2192 2210
             }
2193 2211
         },
2194 2212
         "node_modules/resolve": {
2195
-            "version": "1.22.9",
2196
-            "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.9.tgz",
2197
-            "integrity": "sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==",
2213
+            "version": "1.22.10",
2214
+            "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
2215
+            "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
2198 2216
             "dev": true,
2199 2217
             "license": "MIT",
2200 2218
             "dependencies": {
@@ -2205,6 +2223,9 @@
2205 2223
             "bin": {
2206 2224
                 "resolve": "bin/resolve"
2207 2225
             },
2226
+            "engines": {
2227
+                "node": ">= 0.4"
2228
+            },
2208 2229
             "funding": {
2209 2230
                 "url": "https://github.com/sponsors/ljharb"
2210 2231
             }
@@ -2221,9 +2242,9 @@
2221 2242
             }
2222 2243
         },
2223 2244
         "node_modules/rollup": {
2224
-            "version": "4.28.1",
2225
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.28.1.tgz",
2226
-            "integrity": "sha512-61fXYl/qNVinKmGSTHAZ6Yy8I3YIJC/r2m9feHo6SwVAVcLT5MPwOUFe7EuURA/4m0NR8lXG4BBXuo/IZEsjMg==",
2245
+            "version": "4.29.1",
2246
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz",
2247
+            "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==",
2227 2248
             "dev": true,
2228 2249
             "license": "MIT",
2229 2250
             "dependencies": {
@@ -2237,25 +2258,25 @@
2237 2258
                 "npm": ">=8.0.0"
2238 2259
             },
2239 2260
             "optionalDependencies": {
2240
-                "@rollup/rollup-android-arm-eabi": "4.28.1",
2241
-                "@rollup/rollup-android-arm64": "4.28.1",
2242
-                "@rollup/rollup-darwin-arm64": "4.28.1",
2243
-                "@rollup/rollup-darwin-x64": "4.28.1",
2244
-                "@rollup/rollup-freebsd-arm64": "4.28.1",
2245
-                "@rollup/rollup-freebsd-x64": "4.28.1",
2246
-                "@rollup/rollup-linux-arm-gnueabihf": "4.28.1",
2247
-                "@rollup/rollup-linux-arm-musleabihf": "4.28.1",
2248
-                "@rollup/rollup-linux-arm64-gnu": "4.28.1",
2249
-                "@rollup/rollup-linux-arm64-musl": "4.28.1",
2250
-                "@rollup/rollup-linux-loongarch64-gnu": "4.28.1",
2251
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.28.1",
2252
-                "@rollup/rollup-linux-riscv64-gnu": "4.28.1",
2253
-                "@rollup/rollup-linux-s390x-gnu": "4.28.1",
2254
-                "@rollup/rollup-linux-x64-gnu": "4.28.1",
2255
-                "@rollup/rollup-linux-x64-musl": "4.28.1",
2256
-                "@rollup/rollup-win32-arm64-msvc": "4.28.1",
2257
-                "@rollup/rollup-win32-ia32-msvc": "4.28.1",
2258
-                "@rollup/rollup-win32-x64-msvc": "4.28.1",
2261
+                "@rollup/rollup-android-arm-eabi": "4.29.1",
2262
+                "@rollup/rollup-android-arm64": "4.29.1",
2263
+                "@rollup/rollup-darwin-arm64": "4.29.1",
2264
+                "@rollup/rollup-darwin-x64": "4.29.1",
2265
+                "@rollup/rollup-freebsd-arm64": "4.29.1",
2266
+                "@rollup/rollup-freebsd-x64": "4.29.1",
2267
+                "@rollup/rollup-linux-arm-gnueabihf": "4.29.1",
2268
+                "@rollup/rollup-linux-arm-musleabihf": "4.29.1",
2269
+                "@rollup/rollup-linux-arm64-gnu": "4.29.1",
2270
+                "@rollup/rollup-linux-arm64-musl": "4.29.1",
2271
+                "@rollup/rollup-linux-loongarch64-gnu": "4.29.1",
2272
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.29.1",
2273
+                "@rollup/rollup-linux-riscv64-gnu": "4.29.1",
2274
+                "@rollup/rollup-linux-s390x-gnu": "4.29.1",
2275
+                "@rollup/rollup-linux-x64-gnu": "4.29.1",
2276
+                "@rollup/rollup-linux-x64-musl": "4.29.1",
2277
+                "@rollup/rollup-win32-arm64-msvc": "4.29.1",
2278
+                "@rollup/rollup-win32-ia32-msvc": "4.29.1",
2279
+                "@rollup/rollup-win32-x64-msvc": "4.29.1",
2259 2280
                 "fsevents": "~2.3.2"
2260 2281
             }
2261 2282
         },
@@ -2603,13 +2624,13 @@
2603 2624
             "license": "MIT"
2604 2625
         },
2605 2626
         "node_modules/vite": {
2606
-            "version": "6.0.3",
2607
-            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.3.tgz",
2608
-            "integrity": "sha512-Cmuo5P0ENTN6HxLSo6IHsjCLn/81Vgrp81oaiFFMRa8gGDj5xEjIcEpf2ZymZtZR8oU0P2JX5WuUp/rlXcHkAw==",
2627
+            "version": "6.0.6",
2628
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz",
2629
+            "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==",
2609 2630
             "dev": true,
2610 2631
             "license": "MIT",
2611 2632
             "dependencies": {
2612
-                "esbuild": "^0.24.0",
2633
+                "esbuild": "^0.24.2",
2613 2634
                 "postcss": "^8.4.49",
2614 2635
                 "rollup": "^4.23.0"
2615 2636
             },

+ 3
- 0
pint.json View File

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

+ 4
- 1
resources/data/lang/en.json View File

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

+ 2
- 2
resources/views/components/company/tables/reports/account-transactions.blade.php View File

@@ -2,7 +2,7 @@
2 2
     use App\Filament\Company\Pages\Accounting\Transactions;
3 3
     use App\Models\Accounting\Bill;
4 4
     use App\Filament\Company\Resources\Purchases\BillResource\Pages\ViewBill;
5
-    use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ViewInvoice;
5
+    use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ViewEstimate;
6 6
 
7 7
     $iconPosition = \Filament\Support\Enums\IconPosition::After;
8 8
 @endphp
@@ -68,7 +68,7 @@
68 68
                                         <x-filament::link
69 69
                                             :href="$cell['tableAction']['model'] === Bill::class
70 70
                                                 ? ViewBill::getUrl(['record' => $cell['tableAction']['id']])
71
-                                                : ViewInvoice::getUrl(['record' => $cell['tableAction']['id']])"
71
+                                                : ViewEstimate::getUrl(['record' => $cell['tableAction']['id']])"
72 72
                                             target="_blank"
73 73
                                             color="primary"
74 74
                                             icon="heroicon-o-arrow-top-right-on-square"

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

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

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

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

+ 178
- 0
resources/views/filament/infolists/components/document-preview.blade.php View File

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

resources/views/infolists/components/report-entry.blade.php → resources/views/filament/infolists/components/report-entry.blade.php View File


Loading…
Cancel
Save