瀏覽代碼

wip Estimates

3.x
Andrew Wallo 9 月之前
父節點
當前提交
71c704cdfc

+ 4
- 4
app/Enums/Accounting/DocumentType.php 查看文件

@@ -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,7 +38,7 @@ 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
     }

+ 22
- 0
app/Enums/Accounting/EstimateStatus.php 查看文件

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

+ 467
- 0
app/Filament/Company/Resources/Sales/EstimateResource.php 查看文件

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

+ 38
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/CreateEstimate.php 查看文件

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

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

@@ -0,0 +1,54 @@
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
+        $record = parent::handleRecordUpdate($record, $data);
47
+
48
+        if ($record->approved_at && $record->approvalTransaction) {
49
+            $record->updateApprovalTransaction();
50
+        }
51
+
52
+        return $record;
53
+    }
54
+}

+ 62
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ListEstimates.php 查看文件

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

+ 106
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/ViewEstimate.php 查看文件

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

+ 80
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Widgets/EstimateOverview.php 查看文件

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

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

@@ -80,7 +80,7 @@ class ViewInvoice extends ViewRecord
80 80
                                     ->label('Approved At')
81 81
                                     ->placeholder('Not Approved')
82 82
                                     ->date(),
83
-                                TextEntry::make('last_sent')
83
+                                TextEntry::make('last_sent_at')
84 84
                                     ->label('Last Sent')
85 85
                                     ->placeholder('Never')
86 86
                                     ->date(),

+ 192
- 0
app/Models/Accounting/Estimate.php 查看文件

@@ -0,0 +1,192 @@
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\Models\Common\Client;
14
+use App\Models\Setting\Currency;
15
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
16
+use Illuminate\Database\Eloquent\Builder;
17
+use Illuminate\Database\Eloquent\Casts\Attribute;
18
+use Illuminate\Database\Eloquent\Factories\HasFactory;
19
+use Illuminate\Database\Eloquent\Model;
20
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
21
+use Illuminate\Database\Eloquent\Relations\MorphMany;
22
+use Illuminate\Support\Carbon;
23
+
24
+#[CollectedBy(DocumentCollection::class)]
25
+class Estimate extends Model
26
+{
27
+    use Blamable;
28
+    use CompanyOwned;
29
+    use HasFactory;
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'client_id',
34
+        'logo',
35
+        'header',
36
+        'subheader',
37
+        'estimate_number',
38
+        'reference_number',
39
+        'date',
40
+        'expiration_date',
41
+        'approved_at',
42
+        'accepted_at',
43
+        'declined_at',
44
+        'last_sent_at',
45
+        'last_viewed_at',
46
+        'status',
47
+        'currency_code',
48
+        'discount_method',
49
+        'discount_computation',
50
+        'discount_rate',
51
+        'subtotal',
52
+        'tax_total',
53
+        'discount_total',
54
+        'total',
55
+        'terms',
56
+        'footer',
57
+        'created_by',
58
+        'updated_by',
59
+    ];
60
+
61
+    protected $casts = [
62
+        'date' => 'date',
63
+        'expiration_date' => 'date',
64
+        'approved_at' => 'datetime',
65
+        'accepted_at' => 'datetime',
66
+        'declined_at' => 'datetime',
67
+        'last_sent_at' => 'datetime',
68
+        'last_viewed_at' => 'datetime',
69
+        'status' => EstimateStatus::class,
70
+        'discount_method' => DocumentDiscountMethod::class,
71
+        'discount_computation' => AdjustmentComputation::class,
72
+        'discount_rate' => RateCast::class,
73
+        'subtotal' => MoneyCast::class,
74
+        'tax_total' => MoneyCast::class,
75
+        'discount_total' => MoneyCast::class,
76
+        'total' => MoneyCast::class,
77
+    ];
78
+
79
+    public function client(): BelongsTo
80
+    {
81
+        return $this->belongsTo(Client::class);
82
+    }
83
+
84
+    public function currency(): BelongsTo
85
+    {
86
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
87
+    }
88
+
89
+    public function lineItems(): MorphMany
90
+    {
91
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
92
+    }
93
+
94
+    protected function isCurrentlyExpired(): Attribute
95
+    {
96
+        return Attribute::get(function () {
97
+            return $this->expiration_date?->isBefore(today()) && $this->canBeExpired();
98
+        });
99
+    }
100
+
101
+    public function isDraft(): bool
102
+    {
103
+        return $this->status === EstimateStatus::Draft;
104
+    }
105
+
106
+    public function isApproved(): bool
107
+    {
108
+        return $this->approved_at !== null;
109
+    }
110
+
111
+    public function isAccepted(): bool
112
+    {
113
+        return $this->accepted_at !== null;
114
+    }
115
+
116
+    public function isDeclined(): bool
117
+    {
118
+        return $this->declined_at !== null;
119
+    }
120
+
121
+    public function isSent(): bool
122
+    {
123
+        return $this->last_sent_at !== null;
124
+    }
125
+
126
+    public function canBeExpired(): bool
127
+    {
128
+        return ! in_array($this->status, [
129
+            EstimateStatus::Draft,
130
+            EstimateStatus::Accepted,
131
+            EstimateStatus::Declined,
132
+            EstimateStatus::Converted,
133
+        ]);
134
+    }
135
+
136
+    public function scopeActive(Builder $query): Builder
137
+    {
138
+        return $query->whereIn('status', [
139
+            EstimateStatus::Unsent,
140
+            EstimateStatus::Sent,
141
+            EstimateStatus::Viewed,
142
+            EstimateStatus::Accepted,
143
+        ]);
144
+    }
145
+
146
+    public static function getNextDocumentNumber(): string
147
+    {
148
+        $company = auth()->user()->currentCompany;
149
+
150
+        if (! $company) {
151
+            throw new \RuntimeException('No current company is set for the user.');
152
+        }
153
+
154
+        $defaultEstimateSettings = $company->defaultInvoice;
155
+
156
+        $numberPrefix = 'EST-';
157
+        $numberDigits = $defaultEstimateSettings->number_digits;
158
+
159
+        $latestDocument = static::query()
160
+            ->whereNotNull('estimate_number')
161
+            ->latest('estimate_number')
162
+            ->first();
163
+
164
+        $lastNumberNumericPart = $latestDocument
165
+            ? (int) substr($latestDocument->estimate_number, strlen($numberPrefix))
166
+            : 0;
167
+
168
+        $numberNext = $lastNumberNumericPart + 1;
169
+
170
+        return $defaultEstimateSettings->getNumberNext(
171
+            padded: true,
172
+            format: true,
173
+            prefix: $numberPrefix,
174
+            digits: $numberDigits,
175
+            next: $numberNext
176
+        );
177
+    }
178
+
179
+    public function approveDraft(?Carbon $approvedAt = null): void
180
+    {
181
+        if (! $this->isDraft()) {
182
+            throw new \RuntimeException('Invoice is not in draft status.');
183
+        }
184
+
185
+        $approvedAt ??= now();
186
+
187
+        $this->update([
188
+            'approved_at' => $approvedAt,
189
+            'status' => EstimateStatus::Unsent,
190
+        ]);
191
+    }
192
+}

+ 5
- 5
app/Models/Accounting/Invoice.php 查看文件

@@ -55,7 +55,7 @@ class Invoice extends Model
55 55
         'due_date',
56 56
         'approved_at',
57 57
         'paid_at',
58
-        'last_sent',
58
+        'last_sent_at',
59 59
         'status',
60 60
         'currency_code',
61 61
         'discount_method',
@@ -77,7 +77,7 @@ class Invoice extends Model
77 77
         'due_date' => 'date',
78 78
         'approved_at' => 'datetime',
79 79
         'paid_at' => 'datetime',
80
-        'last_sent' => 'datetime',
80
+        'last_sent_at' => 'datetime',
81 81
         'status' => InvoiceStatus::class,
82 82
         'discount_method' => DocumentDiscountMethod::class,
83 83
         'discount_computation' => AdjustmentComputation::class,
@@ -408,13 +408,13 @@ class Invoice extends Model
408 408
             ->label('Mark as Sent')
409 409
             ->icon('heroicon-o-paper-airplane')
410 410
             ->visible(static function (self $record) {
411
-                return ! $record->last_sent;
411
+                return ! $record->last_sent_at;
412 412
             })
413 413
             ->successNotificationTitle('Invoice Sent')
414 414
             ->action(function (self $record, MountableAction $action) {
415 415
                 $record->update([
416 416
                     'status' => InvoiceStatus::Sent,
417
-                    'last_sent' => now(),
417
+                    'last_sent_at' => now(),
418 418
                 ]);
419 419
 
420 420
                 $action->success();
@@ -437,7 +437,7 @@ class Invoice extends Model
437 437
                 'due_date',
438 438
                 'approved_at',
439 439
                 'paid_at',
440
-                'last_sent',
440
+                'last_sent_at',
441 441
             ])
442 442
             ->modal(false)
443 443
             ->beforeReplicaSaved(function (self $original, self $replica) {

+ 2
- 0
app/Providers/FilamentCompaniesServiceProvider.php 查看文件

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

+ 27
- 25
composer.lock 查看文件

@@ -497,16 +497,16 @@
497 497
         },
498 498
         {
499 499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.0",
500
+            "version": "3.336.2",
501 501
             "source": {
502 502
                 "type": "git",
503 503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "5a62003bf183e32da038056a4b9077c27224e034"
504
+                "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4"
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/954bfdfc048840ca34afe2a2e1cbcff6681989c4",
509
+                "reference": "954bfdfc048840ca34afe2a2e1cbcff6681989c4",
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.2"
593 593
             },
594
-            "time": "2024-12-18T19:04:32+00:00"
594
+            "time": "2024-12-20T19:05:10+00:00"
595 595
         },
596 596
         {
597 597
             "name": "aws/aws-sdk-php-laravel",
@@ -4666,16 +4666,16 @@
4666 4666
         },
4667 4667
         {
4668 4668
             "name": "nesbot/carbon",
4669
-            "version": "3.8.2",
4669
+            "version": "3.8.3",
4670 4670
             "source": {
4671 4671
                 "type": "git",
4672 4672
                 "url": "https://github.com/briannesbitt/Carbon.git",
4673
-                "reference": "e1268cdbc486d97ce23fef2c666dc3c6b6de9947"
4673
+                "reference": "f01cfa96468f4c38325f507ab81a4f1d2cd93cfe"
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/f01cfa96468f4c38325f507ab81a4f1d2cd93cfe",
4678
+                "reference": "f01cfa96468f4c38325f507ab81a4f1d2cd93cfe",
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-21T18:03:19+00:00"
4772 4772
         },
4773 4773
         {
4774 4774
             "name": "nette/schema",
@@ -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",

+ 31
- 0
database/factories/Accounting/DocumentLineItemFactory.php 查看文件

@@ -65,6 +65,37 @@ class DocumentLineItemFactory extends Factory
65 65
         });
66 66
     }
67 67
 
68
+    public function forEstimate(): static
69
+    {
70
+        return $this->state(function (array $attributes) {
71
+            $offering = Offering::where('sellable', 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->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
84
+                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->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
+        });
97
+    }
98
+
68 99
     public function forBill(): static
69 100
     {
70 101
         return $this->state(function (array $attributes) {

+ 161
- 0
database/factories/Accounting/EstimateFactory.php 查看文件

@@ -0,0 +1,161 @@
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): self
50
+    {
51
+        return $this->has(DocumentLineItem::factory()->forEstimate()->count($count), 'lineItems');
52
+    }
53
+
54
+    public function approved(): static
55
+    {
56
+        return $this->afterCreating(function (Estimate $estimate) {
57
+            if (! $estimate->isDraft()) {
58
+                return;
59
+            }
60
+
61
+            $this->recalculateTotals($estimate);
62
+
63
+            $approvedAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
64
+
65
+            $estimate->update([
66
+                'status' => EstimateStatus::Unsent,
67
+                'approved_at' => $approvedAt,
68
+            ]);
69
+        });
70
+    }
71
+
72
+    public function accepted(): static
73
+    {
74
+        return $this->afterCreating(function (Estimate $estimate) {
75
+            if (! $estimate->isApproved()) {
76
+                $this->approved()->create();
77
+            }
78
+
79
+            $acceptedAt = Carbon::parse($estimate->approved_at)
80
+                ->addDays($this->faker->numberBetween(1, 7));
81
+
82
+            $estimate->update([
83
+                'status' => EstimateStatus::Accepted,
84
+                'accepted_at' => $acceptedAt,
85
+            ]);
86
+        });
87
+    }
88
+
89
+    public function declined(): static
90
+    {
91
+        return $this->afterCreating(function (Estimate $estimate) {
92
+            if (! $estimate->isApproved()) {
93
+                $this->approved()->create();
94
+            }
95
+
96
+            $declinedAt = Carbon::parse($estimate->approved_at)
97
+                ->addDays($this->faker->numberBetween(1, 7));
98
+
99
+            $estimate->update([
100
+                'status' => EstimateStatus::Declined,
101
+                'declined_at' => $declinedAt,
102
+            ]);
103
+        });
104
+    }
105
+
106
+    public function sent(): static
107
+    {
108
+        return $this->afterCreating(function (Estimate $estimate) {
109
+            if (! $estimate->isDraft()) {
110
+                return;
111
+            }
112
+
113
+            $this->recalculateTotals($estimate);
114
+
115
+            $sentAt = Carbon::parse($estimate->date)->addHours($this->faker->numberBetween(1, 24));
116
+
117
+            $estimate->update([
118
+                'status' => EstimateStatus::Sent,
119
+                'last_sent_at' => $sentAt,
120
+            ]);
121
+        });
122
+    }
123
+
124
+    public function configure(): static
125
+    {
126
+        return $this->afterCreating(function (Estimate $estimate) {
127
+            $paddedId = str_pad((string) $estimate->id, 5, '0', STR_PAD_LEFT);
128
+
129
+            $estimate->updateQuietly([
130
+                'estimate_number' => "EST-{$paddedId}",
131
+                'reference_number' => "REF-{$paddedId}",
132
+            ]);
133
+
134
+            $this->recalculateTotals($estimate);
135
+
136
+            if ($estimate->approved_at && $estimate->is_currently_expired) {
137
+                $estimate->updateQuietly([
138
+                    'status' => EstimateStatus::Expired,
139
+                ]);
140
+            }
141
+        });
142
+    }
143
+
144
+    protected function recalculateTotals(Estimate $estimate): void
145
+    {
146
+        if ($estimate->lineItems()->exists()) {
147
+            $estimate->refresh();
148
+            $subtotal = $estimate->lineItems()->sum('subtotal') / 100;
149
+            $taxTotal = $estimate->lineItems()->sum('tax_total') / 100;
150
+            $discountTotal = $estimate->lineItems()->sum('discount_total') / 100;
151
+            $grandTotal = $subtotal + $taxTotal - $discountTotal;
152
+
153
+            $estimate->update([
154
+                'subtotal' => $subtotal,
155
+                'tax_total' => $taxTotal,
156
+                'discount_total' => $discountTotal,
157
+                'total' => $grandTotal,
158
+            ]);
159
+        }
160
+    }
161
+}

+ 69
- 0
database/factories/CompanyFactory.php 查看文件

@@ -5,6 +5,7 @@ namespace Database\Factories;
5 5
 use App\Enums\Accounting\BillStatus;
6 6
 use App\Enums\Accounting\InvoiceStatus;
7 7
 use App\Models\Accounting\Bill;
8
+use App\Models\Accounting\Estimate;
8 9
 use App\Models\Accounting\Invoice;
9 10
 use App\Models\Accounting\Transaction;
10 11
 use App\Models\Common\Client;
@@ -163,6 +164,74 @@ class CompanyFactory extends Factory
163 164
         });
164 165
     }
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.2);  // 20% declined
174
+            $expiredCount = $count - ($draftCount + $approvedCount + $acceptedCount + $declinedCount); // remaining as expired
175
+
176
+            // Create draft estimates
177
+            Estimate::factory()
178
+                ->count($draftCount)
179
+                ->withLineItems()
180
+                ->create([
181
+                    'company_id' => $company->id,
182
+                    'created_by' => $company->user_id,
183
+                    'updated_by' => $company->user_id,
184
+                ]);
185
+
186
+            // Create pending (approved) estimates
187
+            Estimate::factory()
188
+                ->count($approvedCount)
189
+                ->withLineItems()
190
+                ->approved()
191
+                ->create([
192
+                    'company_id' => $company->id,
193
+                    'created_by' => $company->user_id,
194
+                    'updated_by' => $company->user_id,
195
+                ]);
196
+
197
+            // Create accepted estimates
198
+            Estimate::factory()
199
+                ->count($acceptedCount)
200
+                ->withLineItems()
201
+                ->accepted()
202
+                ->create([
203
+                    'company_id' => $company->id,
204
+                    'created_by' => $company->user_id,
205
+                    'updated_by' => $company->user_id,
206
+                ]);
207
+
208
+            // Create declined estimates
209
+            Estimate::factory()
210
+                ->count($declinedCount)
211
+                ->withLineItems()
212
+                ->declined()
213
+                ->create([
214
+                    'company_id' => $company->id,
215
+                    'created_by' => $company->user_id,
216
+                    'updated_by' => $company->user_id,
217
+                ]);
218
+
219
+            // Create expired estimates (approved but past expiration date)
220
+            Estimate::factory()
221
+                ->count($expiredCount)
222
+                ->withLineItems()
223
+                ->approved()
224
+                ->state([
225
+                    'expiration_date' => now()->subDays(rand(1, 30)),
226
+                ])
227
+                ->create([
228
+                    'company_id' => $company->id,
229
+                    'created_by' => $company->user_id,
230
+                    'updated_by' => $company->user_id,
231
+                ]);
232
+        });
233
+    }
234
+
166 235
     public function withBills(int $count = 10): self
167 236
     {
168 237
         return $this->afterCreating(function (Company $company) use ($count) {

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

@@ -24,7 +24,7 @@ return new class extends Migration
24 24
             $table->date('due_date')->nullable();
25 25
             $table->timestamp('approved_at')->nullable();
26 26
             $table->timestamp('paid_at')->nullable();
27
-            $table->timestamp('last_sent')->nullable();
27
+            $table->timestamp('last_sent_at')->nullable();
28 28
             $table->string('status')->default('draft');
29 29
             $table->string('currency_code')->nullable();
30 30
             $table->string('discount_method')->default('per_line_item');

+ 54
- 0
database/migrations/2024_12_22_140044_create_estimates_table.php 查看文件

@@ -0,0 +1,54 @@
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('declined_at')->nullable();
28
+            $table->timestamp('last_sent_at')->nullable();
29
+            $table->timestamp('last_viewed_at')->nullable();
30
+            $table->string('status')->default('draft');
31
+            $table->string('currency_code')->nullable();
32
+            $table->string('discount_method')->default('per_line_item');
33
+            $table->string('discount_computation')->default('percentage');
34
+            $table->integer('discount_rate')->default(0);
35
+            $table->integer('subtotal')->default(0);
36
+            $table->integer('tax_total')->default(0);
37
+            $table->integer('discount_total')->default(0);
38
+            $table->integer('total')->default(0);
39
+            $table->text('terms')->nullable(); // terms, notes
40
+            $table->text('footer')->nullable();
41
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
42
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
43
+            $table->timestamps();
44
+        });
45
+    }
46
+
47
+    /**
48
+     * Reverse the migrations.
49
+     */
50
+    public function down(): void
51
+    {
52
+        Schema::dropIfExists('estimates');
53
+    }
54
+};

+ 1
- 0
database/seeders/DatabaseSeeder.php 查看文件

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

+ 98
- 95
package-lock.json 查看文件

@@ -558,9 +558,9 @@
558 558
             }
559 559
         },
560 560
         "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==",
561
+            "version": "4.29.1",
562
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz",
563
+            "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==",
564 564
             "cpu": [
565 565
                 "arm"
566 566
             ],
@@ -572,9 +572,9 @@
572 572
             ]
573 573
         },
574 574
         "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==",
575
+            "version": "4.29.1",
576
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.1.tgz",
577
+            "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==",
578 578
             "cpu": [
579 579
                 "arm64"
580 580
             ],
@@ -586,9 +586,9 @@
586 586
             ]
587 587
         },
588 588
         "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==",
589
+            "version": "4.29.1",
590
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.1.tgz",
591
+            "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==",
592 592
             "cpu": [
593 593
                 "arm64"
594 594
             ],
@@ -600,9 +600,9 @@
600 600
             ]
601 601
         },
602 602
         "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==",
603
+            "version": "4.29.1",
604
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.1.tgz",
605
+            "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==",
606 606
             "cpu": [
607 607
                 "x64"
608 608
             ],
@@ -614,9 +614,9 @@
614 614
             ]
615 615
         },
616 616
         "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==",
617
+            "version": "4.29.1",
618
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.1.tgz",
619
+            "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==",
620 620
             "cpu": [
621 621
                 "arm64"
622 622
             ],
@@ -628,9 +628,9 @@
628 628
             ]
629 629
         },
630 630
         "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==",
631
+            "version": "4.29.1",
632
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.1.tgz",
633
+            "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==",
634 634
             "cpu": [
635 635
                 "x64"
636 636
             ],
@@ -642,9 +642,9 @@
642 642
             ]
643 643
         },
644 644
         "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==",
645
+            "version": "4.29.1",
646
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.1.tgz",
647
+            "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==",
648 648
             "cpu": [
649 649
                 "arm"
650 650
             ],
@@ -656,9 +656,9 @@
656 656
             ]
657 657
         },
658 658
         "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==",
659
+            "version": "4.29.1",
660
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.1.tgz",
661
+            "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==",
662 662
             "cpu": [
663 663
                 "arm"
664 664
             ],
@@ -670,9 +670,9 @@
670 670
             ]
671 671
         },
672 672
         "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==",
673
+            "version": "4.29.1",
674
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.1.tgz",
675
+            "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==",
676 676
             "cpu": [
677 677
                 "arm64"
678 678
             ],
@@ -684,9 +684,9 @@
684 684
             ]
685 685
         },
686 686
         "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==",
687
+            "version": "4.29.1",
688
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.1.tgz",
689
+            "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==",
690 690
             "cpu": [
691 691
                 "arm64"
692 692
             ],
@@ -698,9 +698,9 @@
698 698
             ]
699 699
         },
700 700
         "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==",
701
+            "version": "4.29.1",
702
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.1.tgz",
703
+            "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==",
704 704
             "cpu": [
705 705
                 "loong64"
706 706
             ],
@@ -712,9 +712,9 @@
712 712
             ]
713 713
         },
714 714
         "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==",
715
+            "version": "4.29.1",
716
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.1.tgz",
717
+            "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==",
718 718
             "cpu": [
719 719
                 "ppc64"
720 720
             ],
@@ -726,9 +726,9 @@
726 726
             ]
727 727
         },
728 728
         "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==",
729
+            "version": "4.29.1",
730
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.1.tgz",
731
+            "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==",
732 732
             "cpu": [
733 733
                 "riscv64"
734 734
             ],
@@ -740,9 +740,9 @@
740 740
             ]
741 741
         },
742 742
         "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==",
743
+            "version": "4.29.1",
744
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.1.tgz",
745
+            "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==",
746 746
             "cpu": [
747 747
                 "s390x"
748 748
             ],
@@ -754,9 +754,9 @@
754 754
             ]
755 755
         },
756 756
         "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==",
757
+            "version": "4.29.1",
758
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.1.tgz",
759
+            "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==",
760 760
             "cpu": [
761 761
                 "x64"
762 762
             ],
@@ -768,9 +768,9 @@
768 768
             ]
769 769
         },
770 770
         "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==",
771
+            "version": "4.29.1",
772
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.1.tgz",
773
+            "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==",
774 774
             "cpu": [
775 775
                 "x64"
776 776
             ],
@@ -782,9 +782,9 @@
782 782
             ]
783 783
         },
784 784
         "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==",
785
+            "version": "4.29.1",
786
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.1.tgz",
787
+            "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==",
788 788
             "cpu": [
789 789
                 "arm64"
790 790
             ],
@@ -796,9 +796,9 @@
796 796
             ]
797 797
         },
798 798
         "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==",
799
+            "version": "4.29.1",
800
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.1.tgz",
801
+            "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==",
802 802
             "cpu": [
803 803
                 "ia32"
804 804
             ],
@@ -810,9 +810,9 @@
810 810
             ]
811 811
         },
812 812
         "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==",
813
+            "version": "4.29.1",
814
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.1.tgz",
815
+            "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==",
816 816
             "cpu": [
817 817
                 "x64"
818 818
             ],
@@ -1057,9 +1057,9 @@
1057 1057
             }
1058 1058
         },
1059 1059
         "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==",
1060
+            "version": "1.0.30001690",
1061
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
1062
+            "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
1063 1063
             "dev": true,
1064 1064
             "funding": [
1065 1065
                 {
@@ -1218,9 +1218,9 @@
1218 1218
             "license": "MIT"
1219 1219
         },
1220 1220
         "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==",
1221
+            "version": "1.5.75",
1222
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.75.tgz",
1223
+            "integrity": "sha512-Lf3++DumRE/QmweGjU+ZcKqQ+3bKkU/qjaKYhIJKEOhgIO9Xs6IiAQFkfFoj+RhgDk4LUeNsLo6plExHqSyu6Q==",
1224 1224
             "dev": true,
1225 1225
             "license": "ISC"
1226 1226
         },
@@ -1487,9 +1487,9 @@
1487 1487
             }
1488 1488
         },
1489 1489
         "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==",
1490
+            "version": "2.16.1",
1491
+            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
1492
+            "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
1493 1493
             "dev": true,
1494 1494
             "license": "MIT",
1495 1495
             "dependencies": {
@@ -2192,9 +2192,9 @@
2192 2192
             }
2193 2193
         },
2194 2194
         "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==",
2195
+            "version": "1.22.10",
2196
+            "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
2197
+            "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
2198 2198
             "dev": true,
2199 2199
             "license": "MIT",
2200 2200
             "dependencies": {
@@ -2205,6 +2205,9 @@
2205 2205
             "bin": {
2206 2206
                 "resolve": "bin/resolve"
2207 2207
             },
2208
+            "engines": {
2209
+                "node": ">= 0.4"
2210
+            },
2208 2211
             "funding": {
2209 2212
                 "url": "https://github.com/sponsors/ljharb"
2210 2213
             }
@@ -2221,9 +2224,9 @@
2221 2224
             }
2222 2225
         },
2223 2226
         "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==",
2227
+            "version": "4.29.1",
2228
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz",
2229
+            "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==",
2227 2230
             "dev": true,
2228 2231
             "license": "MIT",
2229 2232
             "dependencies": {
@@ -2237,25 +2240,25 @@
2237 2240
                 "npm": ">=8.0.0"
2238 2241
             },
2239 2242
             "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",
2243
+                "@rollup/rollup-android-arm-eabi": "4.29.1",
2244
+                "@rollup/rollup-android-arm64": "4.29.1",
2245
+                "@rollup/rollup-darwin-arm64": "4.29.1",
2246
+                "@rollup/rollup-darwin-x64": "4.29.1",
2247
+                "@rollup/rollup-freebsd-arm64": "4.29.1",
2248
+                "@rollup/rollup-freebsd-x64": "4.29.1",
2249
+                "@rollup/rollup-linux-arm-gnueabihf": "4.29.1",
2250
+                "@rollup/rollup-linux-arm-musleabihf": "4.29.1",
2251
+                "@rollup/rollup-linux-arm64-gnu": "4.29.1",
2252
+                "@rollup/rollup-linux-arm64-musl": "4.29.1",
2253
+                "@rollup/rollup-linux-loongarch64-gnu": "4.29.1",
2254
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.29.1",
2255
+                "@rollup/rollup-linux-riscv64-gnu": "4.29.1",
2256
+                "@rollup/rollup-linux-s390x-gnu": "4.29.1",
2257
+                "@rollup/rollup-linux-x64-gnu": "4.29.1",
2258
+                "@rollup/rollup-linux-x64-musl": "4.29.1",
2259
+                "@rollup/rollup-win32-arm64-msvc": "4.29.1",
2260
+                "@rollup/rollup-win32-ia32-msvc": "4.29.1",
2261
+                "@rollup/rollup-win32-x64-msvc": "4.29.1",
2259 2262
                 "fsevents": "~2.3.2"
2260 2263
             }
2261 2264
         },
@@ -2603,13 +2606,13 @@
2603 2606
             "license": "MIT"
2604 2607
         },
2605 2608
         "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==",
2609
+            "version": "6.0.5",
2610
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",
2611
+            "integrity": "sha512-akD5IAH/ID5imgue2DYhzsEwCi0/4VKY31uhMLEYJwPP4TiUp8pL5PIK+Wo7H8qT8JY9i+pVfPydcFPYD1EL7g==",
2609 2612
             "dev": true,
2610 2613
             "license": "MIT",
2611 2614
             "dependencies": {
2612
-                "esbuild": "^0.24.0",
2615
+                "esbuild": "0.24.0",
2613 2616
                 "postcss": "^8.4.49",
2614 2617
                 "rollup": "^4.23.0"
2615 2618
             },

+ 2
- 2
resources/views/components/company/tables/reports/account-transactions.blade.php 查看文件

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

Loading…
取消
儲存