浏览代码

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

Development 3.x
3.x
Andrew Wallo 10 个月前
父节点
当前提交
755a322527
没有帐户链接到提交者的电子邮件
共有 41 个文件被更改,包括 1330 次插入1000 次删除
  1. 119
    0
      app/Concerns/ManagesLineItems.php
  2. 13
    0
      app/Enums/Accounting/AdjustmentComputation.php
  3. 32
    0
      app/Enums/Accounting/DocumentDiscountMethod.php
  4. 0
    73
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  5. 2
    2
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php
  6. 52
    125
      app/Filament/Company/Resources/Purchases/BillResource.php
  7. 22
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php
  8. 28
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php
  9. 52
    121
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  10. 18
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php
  11. 26
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php
  12. 51
    33
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  13. 2
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  14. 3
    1
      app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php
  15. 37
    0
      app/Filament/Forms/Components/BillTotals.php
  16. 37
    0
      app/Filament/Forms/Components/InvoiceTotals.php
  17. 10
    0
      app/Models/Accounting/Account.php
  18. 38
    1
      app/Models/Accounting/Bill.php
  19. 38
    1
      app/Models/Accounting/Invoice.php
  20. 0
    82
      app/Models/Setting/Discount.php
  21. 0
    78
      app/Models/Setting/Tax.php
  22. 0
    2
      app/Providers/AuthServiceProvider.php
  23. 16
    13
      app/Providers/MacroServiceProvider.php
  24. 11
    2
      app/Services/ReportService.php
  25. 27
    11
      app/View/Models/BillTotalViewModel.php
  26. 27
    11
      app/View/Models/InvoiceTotalViewModel.php
  27. 1
    1
      composer.json
  28. 313
    376
      composer.lock
  29. 3
    1
      database/factories/Accounting/AdjustmentFactory.php
  30. 3
    0
      database/migrations/2024_11_27_221657_create_bills_table.php
  31. 3
    0
      database/migrations/2024_11_27_223015_create_invoices_table.php
  32. 25
    25
      package-lock.json
  33. 11
    1
      resources/views/components/company/invoice/container.blade.php
  34. 1
    1
      resources/views/components/company/invoice/footer.blade.php
  35. 1
    1
      resources/views/filament/company/components/invoice-layouts/classic.blade.php
  36. 1
    1
      resources/views/filament/company/components/invoice-layouts/default.blade.php
  37. 1
    1
      resources/views/filament/company/components/invoice-layouts/modern.blade.php
  38. 169
    0
      resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php
  39. 47
    0
      resources/views/filament/company/resources/sales/invoices/pages/view-invoice.blade.php
  40. 45
    18
      resources/views/filament/forms/components/bill-totals.blade.php
  41. 45
    18
      resources/views/filament/forms/components/invoice-totals.blade.php

+ 119
- 0
app/Concerns/ManagesLineItems.php 查看文件

@@ -0,0 +1,119 @@
1
+<?php
2
+
3
+namespace App\Concerns;
4
+
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
7
+use App\Models\Accounting\Bill;
8
+use App\Models\Accounting\DocumentLineItem;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Illuminate\Database\Eloquent\Model;
11
+use Illuminate\Support\Collection;
12
+
13
+trait ManagesLineItems
14
+{
15
+    protected function handleLineItems(Model $record, Collection $lineItems): void
16
+    {
17
+        foreach ($lineItems as $itemData) {
18
+            $lineItem = isset($itemData['id'])
19
+                ? $record->lineItems->find($itemData['id'])
20
+                : $record->lineItems()->make();
21
+
22
+            $lineItem->fill([
23
+                'offering_id' => $itemData['offering_id'],
24
+                'description' => $itemData['description'],
25
+                'quantity' => $itemData['quantity'],
26
+                'unit_price' => $itemData['unit_price'],
27
+            ]);
28
+
29
+            if (! $lineItem->exists) {
30
+                $lineItem->documentable()->associate($record);
31
+            }
32
+
33
+            $lineItem->save();
34
+
35
+            $this->handleLineItemAdjustments($lineItem, $itemData, $record->discount_method);
36
+            $this->updateLineItemTotals($lineItem, $record->discount_method);
37
+        }
38
+    }
39
+
40
+    protected function deleteRemovedLineItems(Model $record, Collection $lineItems): void
41
+    {
42
+        $existingLineItemIds = $record->lineItems->pluck('id');
43
+        $updatedLineItemIds = $lineItems->pluck('id')->filter();
44
+        $lineItemsToDelete = $existingLineItemIds->diff($updatedLineItemIds);
45
+
46
+        if ($lineItemsToDelete->isNotEmpty()) {
47
+            $record
48
+                ->lineItems()
49
+                ->whereIn('id', $lineItemsToDelete)
50
+                ->each(fn (DocumentLineItem $lineItem) => $lineItem->delete());
51
+        }
52
+    }
53
+
54
+    protected function handleLineItemAdjustments(DocumentLineItem $lineItem, array $itemData, DocumentDiscountMethod $discountMethod): void
55
+    {
56
+        $isBill = $lineItem->documentable instanceof Bill;
57
+
58
+        $taxType = $isBill ? 'purchaseTaxes' : 'salesTaxes';
59
+        $discountType = $isBill ? 'purchaseDiscounts' : 'salesDiscounts';
60
+
61
+        $adjustmentIds = collect($itemData[$taxType] ?? [])
62
+            ->merge($discountMethod->isPerLineItem() ? ($itemData[$discountType] ?? []) : [])
63
+            ->filter()
64
+            ->unique();
65
+
66
+        $lineItem->adjustments()->sync($adjustmentIds);
67
+        $lineItem->refresh();
68
+    }
69
+
70
+    protected function updateLineItemTotals(DocumentLineItem $lineItem, DocumentDiscountMethod $discountMethod): void
71
+    {
72
+        $lineItem->updateQuietly([
73
+            'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
74
+            'discount_total' => $discountMethod->isPerLineItem()
75
+                ? $lineItem->calculateDiscountTotal()->getAmount()
76
+                : 0,
77
+        ]);
78
+    }
79
+
80
+    protected function updateDocumentTotals(Model $record, array $data): array
81
+    {
82
+        $subtotalCents = $record->lineItems()->sum('subtotal');
83
+        $taxTotalCents = $record->lineItems()->sum('tax_total');
84
+        $discountTotalCents = $this->calculateDiscountTotal(
85
+            DocumentDiscountMethod::parse($data['discount_method']),
86
+            AdjustmentComputation::parse($data['discount_computation']),
87
+            $data['discount_rate'] ?? null,
88
+            $subtotalCents,
89
+            $record
90
+        );
91
+
92
+        $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
93
+
94
+        return [
95
+            'subtotal' => CurrencyConverter::convertCentsToFormatSimple($subtotalCents),
96
+            'tax_total' => CurrencyConverter::convertCentsToFormatSimple($taxTotalCents),
97
+            'discount_total' => CurrencyConverter::convertCentsToFormatSimple($discountTotalCents),
98
+            'total' => CurrencyConverter::convertCentsToFormatSimple($grandTotalCents),
99
+        ];
100
+    }
101
+
102
+    protected function calculateDiscountTotal(
103
+        DocumentDiscountMethod $discountMethod,
104
+        ?AdjustmentComputation $discountComputation,
105
+        ?string $discountRate,
106
+        int $subtotalCents,
107
+        Model $record
108
+    ): int {
109
+        if ($discountMethod->isPerLineItem()) {
110
+            return $record->lineItems()->sum('discount_total');
111
+        }
112
+
113
+        if ($discountComputation?->isPercentage()) {
114
+            return (int) ($subtotalCents * ((float) $discountRate / 100));
115
+        }
116
+
117
+        return CurrencyConverter::convertToCents($discountRate);
118
+    }
119
+}

+ 13
- 0
app/Enums/Accounting/AdjustmentComputation.php 查看文件

@@ -2,10 +2,13 @@
2 2
 
3 3
 namespace App\Enums\Accounting;
4 4
 
5
+use App\Enums\Concerns\ParsesEnum;
5 6
 use Filament\Support\Contracts\HasLabel;
6 7
 
7 8
 enum AdjustmentComputation: string implements HasLabel
8 9
 {
10
+    use ParsesEnum;
11
+
9 12
     case Percentage = 'percentage';
10 13
     case Fixed = 'fixed';
11 14
 
@@ -13,4 +16,14 @@ enum AdjustmentComputation: string implements HasLabel
13 16
     {
14 17
         return translate($this->name);
15 18
     }
19
+
20
+    public function isPercentage(): bool
21
+    {
22
+        return $this == self::Percentage;
23
+    }
24
+
25
+    public function isFixed(): bool
26
+    {
27
+        return $this == self::Fixed;
28
+    }
16 29
 }

+ 32
- 0
app/Enums/Accounting/DocumentDiscountMethod.php 查看文件

@@ -0,0 +1,32 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum DocumentDiscountMethod: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case PerLineItem = 'per_line_item';
13
+    case PerDocument = 'per_document';
14
+
15
+    public function getLabel(): string
16
+    {
17
+        return match ($this) {
18
+            self::PerLineItem => 'Per Line Item',
19
+            self::PerDocument => 'Per Document',
20
+        };
21
+    }
22
+
23
+    public function isPerLineItem(): bool
24
+    {
25
+        return $this == self::PerLineItem;
26
+    }
27
+
28
+    public function isPerDocument(): bool
29
+    {
30
+        return $this == self::PerDocument;
31
+    }
32
+}

+ 0
- 73
app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php 查看文件

@@ -6,8 +6,6 @@ use App\Events\CompanyDefaultUpdated;
6 6
 use App\Filament\Company\Clusters\Settings;
7 7
 use App\Models\Banking\BankAccount;
8 8
 use App\Models\Setting\CompanyDefault as CompanyDefaultModel;
9
-use App\Models\Setting\Discount;
10
-use App\Models\Setting\Tax;
11 9
 use Filament\Actions\Action;
12 10
 use Filament\Actions\ActionGroup;
13 11
 use Filament\Forms\Components\Component;
@@ -108,7 +106,6 @@ class CompanyDefault extends Page
108 106
         return $form
109 107
             ->schema([
110 108
                 $this->getGeneralSection(),
111
-                // $this->getModifiersSection(),
112 109
             ])
113 110
             ->model($this->record)
114 111
             ->statePath('data')
@@ -140,76 +137,6 @@ class CompanyDefault extends Page
140 137
             ])->columns();
141 138
     }
142 139
 
143
-    protected function getModifiersSection(): Component
144
-    {
145
-        return Section::make('Taxes & Discounts')
146
-            ->schema([
147
-                Select::make('sales_tax_id')
148
-                    ->softRequired()
149
-                    ->localizeLabel()
150
-                    ->relationship('salesTax', 'name')
151
-                    ->getOptionLabelFromRecordUsing(function (Tax $record) {
152
-                        $currencyCode = $this->record->currency_code;
153
-
154
-                        $rate = rateFormat($record->rate, $record->computation->value, $currencyCode);
155
-
156
-                        $rateBadge = $this->renderBadgeOptionLabel($rate);
157
-
158
-                        return "{$record->name} ⁓ {$rateBadge}";
159
-                    })
160
-                    ->allowHtml()
161
-                    ->saveRelationshipsUsing(null)
162
-                    ->searchable(),
163
-                Select::make('purchase_tax_id')
164
-                    ->softRequired()
165
-                    ->localizeLabel()
166
-                    ->relationship('purchaseTax', 'name')
167
-                    ->getOptionLabelFromRecordUsing(function (Tax $record) {
168
-                        $currencyCode = $this->record->currency_code;
169
-
170
-                        $rate = rateFormat($record->rate, $record->computation->value, $currencyCode);
171
-
172
-                        $rateBadge = $this->renderBadgeOptionLabel($rate);
173
-
174
-                        return "{$record->name} ⁓ {$rateBadge}";
175
-                    })
176
-                    ->allowHtml()
177
-                    ->saveRelationshipsUsing(null)
178
-                    ->searchable(),
179
-                Select::make('sales_discount_id')
180
-                    ->softRequired()
181
-                    ->localizeLabel()
182
-                    ->relationship('salesDiscount', 'name')
183
-                    ->getOptionLabelFromRecordUsing(function (Discount $record) {
184
-                        $currencyCode = $this->record->currency_code;
185
-
186
-                        $rate = rateFormat($record->rate, $record->computation->value, $currencyCode);
187
-
188
-                        $rateBadge = $this->renderBadgeOptionLabel($rate);
189
-
190
-                        return "{$record->name} ⁓ {$rateBadge}";
191
-                    })
192
-                    ->saveRelationshipsUsing(null)
193
-                    ->allowHtml()
194
-                    ->searchable(),
195
-                Select::make('purchase_discount_id')
196
-                    ->softRequired()
197
-                    ->localizeLabel()
198
-                    ->relationship('purchaseDiscount', 'name')
199
-                    ->getOptionLabelFromRecordUsing(function (Discount $record) {
200
-                        $currencyCode = $this->record->currency_code;
201
-                        $rate = rateFormat($record->rate, $record->computation->value, $currencyCode);
202
-
203
-                        $rateBadge = $this->renderBadgeOptionLabel($rate);
204
-
205
-                        return "{$record->name} ⁓ {$rateBadge}";
206
-                    })
207
-                    ->allowHtml()
208
-                    ->saveRelationshipsUsing(null)
209
-                    ->searchable(),
210
-            ])->columns();
211
-    }
212
-
213 140
     public function renderBadgeOptionLabel(string $label): string
214 141
     {
215 142
         return Blade::render('filament::components.badge', [

+ 2
- 2
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php 查看文件

@@ -53,7 +53,7 @@ class AdjustmentResource extends Resource
53 53
                         ToggleButton::make('recoverable')
54 54
                             ->label('Recoverable')
55 55
                             ->default(false)
56
-                            ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Tax && AdjustmentType::parse($get('type')) === AdjustmentType::Purchase),
56
+                            ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category'))->isTax() && AdjustmentType::parse($get('type'))->isPurchase()),
57 57
                     ])
58 58
                     ->columns()
59 59
                     ->visibleOn('create'),
@@ -80,7 +80,7 @@ class AdjustmentResource extends Resource
80 80
                         Forms\Components\DateTimePicker::make('end_date'),
81 81
                     ])
82 82
                     ->columns()
83
-                    ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Discount),
83
+                    ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category'))->isDiscount()),
84 84
             ]);
85 85
     }
86 86
 

+ 52
- 125
app/Filament/Company/Resources/Purchases/BillResource.php 查看文件

@@ -3,13 +3,14 @@
3 3
 namespace App\Filament\Company\Resources\Purchases;
4 4
 
5 5
 use App\Enums\Accounting\BillStatus;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
6 7
 use App\Enums\Accounting\PaymentMethod;
7 8
 use App\Filament\Company\Resources\Purchases\BillResource\Pages;
9
+use App\Filament\Forms\Components\BillTotals;
8 10
 use App\Filament\Tables\Actions\ReplicateBulkAction;
9 11
 use App\Filament\Tables\Filters\DateRangeFilter;
10 12
 use App\Models\Accounting\Adjustment;
11 13
 use App\Models\Accounting\Bill;
12
-use App\Models\Accounting\DocumentLineItem;
13 14
 use App\Models\Banking\BankAccount;
14 15
 use App\Models\Common\Offering;
15 16
 use App\Utilities\Currency\CurrencyConverter;
@@ -26,7 +27,6 @@ use Filament\Tables;
26 27
 use Filament\Tables\Table;
27 28
 use Illuminate\Database\Eloquent\Builder;
28 29
 use Illuminate\Database\Eloquent\Collection;
29
-use Illuminate\Database\Eloquent\Model;
30 30
 use Illuminate\Support\Facades\Auth;
31 31
 
32 32
 class BillResource extends Resource
@@ -71,129 +71,44 @@ class BillResource extends Resource
71 71
                                         return now()->addDays($company->defaultBill->payment_terms->getDays());
72 72
                                     })
73 73
                                     ->required(),
74
+                                Forms\Components\Select::make('discount_method')
75
+                                    ->label('Discount Method')
76
+                                    ->options(DocumentDiscountMethod::class)
77
+                                    ->selectablePlaceholder(false)
78
+                                    ->default(DocumentDiscountMethod::PerLineItem)
79
+                                    ->afterStateUpdated(function ($state, Forms\Set $set) {
80
+                                        $discountMethod = DocumentDiscountMethod::parse($state);
81
+
82
+                                        if ($discountMethod->isPerDocument()) {
83
+                                            $set('lineItems.*.purchaseDiscounts', []);
84
+                                        }
85
+                                    })
86
+                                    ->live(),
74 87
                             ])->grow(true),
75 88
                         ])->from('md'),
76 89
                         TableRepeater::make('lineItems')
77 90
                             ->relationship()
78
-                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
79
-                                if (! is_array($state)) {
80
-                                    $state = [];
81
-                                }
82
-
83
-                                $relationship = $component->getRelationship();
84
-
85
-                                $existingRecords = $component->getCachedExistingRecords();
86
-
87
-                                $recordsToDelete = [];
88
-
89
-                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
90
-                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
91
-                                        continue;
92
-                                    }
93
-
94
-                                    $recordsToDelete[] = $keyToCheckForDeletion;
95
-                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
91
+                            ->saveRelationshipsUsing(null)
92
+                            ->dehydrated(true)
93
+                            ->headers(function (Forms\Get $get) {
94
+                                $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
95
+
96
+                                $headers = [
97
+                                    Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
98
+                                    Header::make('Description')->width($hasDiscounts ? '25%' : '30%'),  // Increase when no discounts
99
+                                    Header::make('Quantity')->width('10%'),
100
+                                    Header::make('Price')->width('10%'),
101
+                                    Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'),       // Increase when no discounts
102
+                                ];
103
+
104
+                                if ($hasDiscounts) {
105
+                                    $headers[] = Header::make('Discounts')->width('15%');
96 106
                                 }
97 107
 
98
-                                $relationship
99
-                                    ->whereKey($recordsToDelete)
100
-                                    ->get()
101
-                                    ->each(static fn (Model $record) => $record->delete());
102
-
103
-                                $childComponentContainers = $component->getChildComponentContainers(
104
-                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
105
-                                );
106
-
107
-                                $itemOrder = 1;
108
-                                $orderColumn = $component->getOrderColumn();
109
-
110
-                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
111
-
112
-                                foreach ($childComponentContainers as $itemKey => $item) {
113
-                                    $itemData = $item->getState(shouldCallHooksBefore: false);
114
-
115
-                                    if ($orderColumn) {
116
-                                        $itemData[$orderColumn] = $itemOrder;
117
-
118
-                                        $itemOrder++;
119
-                                    }
120
-
121
-                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
122
-                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
123
-
124
-                                        if ($itemData === null) {
125
-                                            continue;
126
-                                        }
127
-
128
-                                        $translatableContentDriver ?
129
-                                            $translatableContentDriver->updateRecord($record, $itemData) :
130
-                                            $record->fill($itemData)->save();
131
-
132
-                                        continue;
133
-                                    }
134
-
135
-                                    $relatedModel = $component->getRelatedModel();
136
-
137
-                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
138
-
139
-                                    if ($itemData === null) {
140
-                                        continue;
141
-                                    }
142
-
143
-                                    if ($translatableContentDriver) {
144
-                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
145
-                                    } else {
146
-                                        $record = new $relatedModel;
147
-                                        $record->fill($itemData);
148
-                                    }
108
+                                $headers[] = Header::make('Amount')->width('10%')->align('right');
149 109
 
150
-                                    $record = $relationship->save($record);
151
-                                    $item->model($record)->saveRelationships();
152
-                                    $existingRecords->push($record);
153
-                                }
154
-
155
-                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
156
-
157
-                                /** @var Bill $bill */
158
-                                $bill = $component->getRecord();
159
-
160
-                                // Recalculate totals for line items
161
-                                $bill->lineItems()->each(function (DocumentLineItem $lineItem) {
162
-                                    $lineItem->updateQuietly([
163
-                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
164
-                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
165
-                                    ]);
166
-                                });
167
-
168
-                                $subtotal = $bill->lineItems()->sum('subtotal') / 100;
169
-                                $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
170
-                                $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
171
-                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
172
-
173
-                                $bill->updateQuietly([
174
-                                    'subtotal' => $subtotal,
175
-                                    'tax_total' => $taxTotal,
176
-                                    'discount_total' => $discountTotal,
177
-                                    'total' => $grandTotal,
178
-                                ]);
179
-
180
-                                $bill->refresh();
181
-
182
-                                if (! $bill->initialTransaction) {
183
-                                    $bill->createInitialTransaction();
184
-                                } else {
185
-                                    $bill->updateInitialTransaction();
186
-                                }
110
+                                return $headers;
187 111
                             })
188
-                            ->headers([
189
-                                Header::make('Items')->width('15%'),
190
-                                Header::make('Description')->width('25%'),
191
-                                Header::make('Quantity')->width('10%'),
192
-                                Header::make('Price')->width('10%'),
193
-                                Header::make('Taxes')->width('15%'),
194
-                                Header::make('Discounts')->width('15%'),
195
-                                Header::make('Amount')->width('10%')->align('right'),
196
-                            ])
197 112
                             ->schema([
198 113
                                 Forms\Components\Select::make('offering_id')
199 114
                                     ->relationship('purchasableOffering', 'name')
@@ -209,7 +124,11 @@ class BillResource extends Resource
209 124
                                             $set('description', $offeringRecord->description);
210 125
                                             $set('unit_price', $offeringRecord->price);
211 126
                                             $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
212
-                                            $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
127
+
128
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
129
+                                            if ($discountMethod->isPerLineItem()) {
130
+                                                $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
131
+                                            }
213 132
                                         }
214 133
                                     }),
215 134
                                 Forms\Components\TextInput::make('description'),
@@ -225,15 +144,24 @@ class BillResource extends Resource
225 144
                                     ->default(0),
226 145
                                 Forms\Components\Select::make('purchaseTaxes')
227 146
                                     ->relationship('purchaseTaxes', 'name')
147
+                                    ->saveRelationshipsUsing(null)
148
+                                    ->dehydrated(true)
228 149
                                     ->preload()
229 150
                                     ->multiple()
230 151
                                     ->live()
231 152
                                     ->searchable(),
232 153
                                 Forms\Components\Select::make('purchaseDiscounts')
233 154
                                     ->relationship('purchaseDiscounts', 'name')
155
+                                    ->saveRelationshipsUsing(null)
156
+                                    ->dehydrated(true)
234 157
                                     ->preload()
235 158
                                     ->multiple()
236 159
                                     ->live()
160
+                                    ->hidden(function (Forms\Get $get) {
161
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
162
+
163
+                                        return $discountMethod->isPerDocument();
164
+                                    })
237 165
                                     ->searchable(),
238 166
                                 Forms\Components\Placeholder::make('total')
239 167
                                     ->hiddenLabel()
@@ -265,13 +193,7 @@ class BillResource extends Resource
265 193
                                         return CurrencyConverter::formatToMoney($total);
266 194
                                     }),
267 195
                             ]),
268
-                        Forms\Components\Grid::make(6)
269
-                            ->schema([
270
-                                Forms\Components\ViewField::make('totals')
271
-                                    ->columnStart(5)
272
-                                    ->columnSpan(2)
273
-                                    ->view('filament.forms.components.bill-totals'),
274
-                            ]),
196
+                        BillTotals::make(),
275 197
                     ]),
276 198
             ]);
277 199
     }
@@ -281,6 +203,11 @@ class BillResource extends Resource
281 203
         return $table
282 204
             ->defaultSort('due_date')
283 205
             ->columns([
206
+                Tables\Columns\TextColumn::make('id')
207
+                    ->label('ID')
208
+                    ->sortable()
209
+                    ->toggleable(isToggledHiddenByDefault: true)
210
+                    ->searchable(),
284 211
                 Tables\Columns\TextColumn::make('status')
285 212
                     ->badge()
286 213
                     ->searchable(),

+ 22
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php 查看文件

@@ -2,13 +2,17 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4 4
 
5
+use App\Concerns\ManagesLineItems;
5 6
 use App\Concerns\RedirectToListPage;
6 7
 use App\Filament\Company\Resources\Purchases\BillResource;
8
+use App\Models\Accounting\Bill;
7 9
 use Filament\Resources\Pages\CreateRecord;
8 10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
9 12
 
10 13
 class CreateBill extends CreateRecord
11 14
 {
15
+    use ManagesLineItems;
12 16
     use RedirectToListPage;
13 17
 
14 18
     protected static string $resource = BillResource::class;
@@ -17,4 +21,22 @@ class CreateBill extends CreateRecord
17 21
     {
18 22
         return MaxWidth::Full;
19 23
     }
24
+
25
+    protected function handleRecordCreation(array $data): Model
26
+    {
27
+        /** @var Bill $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
+        if (! $record->initialTransaction) {
37
+            $record->createInitialTransaction();
38
+        }
39
+
40
+        return $record;
41
+    }
20 42
 }

+ 28
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php 查看文件

@@ -2,14 +2,18 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4 4
 
5
+use App\Concerns\ManagesLineItems;
5 6
 use App\Concerns\RedirectToListPage;
6 7
 use App\Filament\Company\Resources\Purchases\BillResource;
8
+use App\Models\Accounting\Bill;
7 9
 use Filament\Actions;
8 10
 use Filament\Resources\Pages\EditRecord;
9 11
 use Filament\Support\Enums\MaxWidth;
12
+use Illuminate\Database\Eloquent\Model;
10 13
 
11 14
 class EditBill extends EditRecord
12 15
 {
16
+    use ManagesLineItems;
13 17
     use RedirectToListPage;
14 18
 
15 19
     protected static string $resource = BillResource::class;
@@ -25,4 +29,28 @@ class EditBill extends EditRecord
25 29
     {
26 30
         return MaxWidth::Full;
27 31
     }
32
+
33
+    protected function handleRecordUpdate(Model $record, array $data): Model
34
+    {
35
+        /** @var Bill $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->initialTransaction) {
49
+            $record->createInitialTransaction();
50
+        } else {
51
+            $record->updateInitialTransaction();
52
+        }
53
+
54
+        return $record;
55
+    }
28 56
 }

+ 52
- 121
app/Filament/Company/Resources/Sales/InvoiceResource.php 查看文件

@@ -3,15 +3,16 @@
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5 5
 use App\Collections\Accounting\InvoiceCollection;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
6 7
 use App\Enums\Accounting\InvoiceStatus;
7 8
 use App\Enums\Accounting\PaymentMethod;
8 9
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
9 10
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
10 11
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
12
+use App\Filament\Forms\Components\InvoiceTotals;
11 13
 use App\Filament\Tables\Actions\ReplicateBulkAction;
12 14
 use App\Filament\Tables\Filters\DateRangeFilter;
13 15
 use App\Models\Accounting\Adjustment;
14
-use App\Models\Accounting\DocumentLineItem;
15 16
 use App\Models\Accounting\Invoice;
16 17
 use App\Models\Banking\BankAccount;
17 18
 use App\Models\Common\Offering;
@@ -30,7 +31,6 @@ use Filament\Tables;
30 31
 use Filament\Tables\Table;
31 32
 use Illuminate\Database\Eloquent\Builder;
32 33
 use Illuminate\Database\Eloquent\Collection;
33
-use Illuminate\Database\Eloquent\Model;
34 34
 use Illuminate\Support\Facades\Auth;
35 35
 use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
36 36
 
@@ -128,125 +128,44 @@ class InvoiceResource extends Resource
128 128
                                     ->minDate(static function (Forms\Get $get) {
129 129
                                         return $get('date') ?? now();
130 130
                                     }),
131
+                                Forms\Components\Select::make('discount_method')
132
+                                    ->label('Discount Method')
133
+                                    ->options(DocumentDiscountMethod::class)
134
+                                    ->selectablePlaceholder(false)
135
+                                    ->default(DocumentDiscountMethod::PerLineItem)
136
+                                    ->afterStateUpdated(function ($state, Forms\Set $set) {
137
+                                        $discountMethod = DocumentDiscountMethod::parse($state);
138
+
139
+                                        if ($discountMethod->isPerDocument()) {
140
+                                            $set('lineItems.*.salesDiscounts', []);
141
+                                        }
142
+                                    })
143
+                                    ->live(),
131 144
                             ])->grow(true),
132 145
                         ])->from('md'),
133 146
                         TableRepeater::make('lineItems')
134 147
                             ->relationship()
135
-                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
136
-                                if (! is_array($state)) {
137
-                                    $state = [];
138
-                                }
139
-
140
-                                $relationship = $component->getRelationship();
141
-
142
-                                $existingRecords = $component->getCachedExistingRecords();
143
-
144
-                                $recordsToDelete = [];
145
-
146
-                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
147
-                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
148
-                                        continue;
149
-                                    }
150
-
151
-                                    $recordsToDelete[] = $keyToCheckForDeletion;
152
-                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
153
-                                }
154
-
155
-                                $relationship
156
-                                    ->whereKey($recordsToDelete)
157
-                                    ->get()
158
-                                    ->each(static fn (Model $record) => $record->delete());
159
-
160
-                                $childComponentContainers = $component->getChildComponentContainers(
161
-                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
162
-                                );
163
-
164
-                                $itemOrder = 1;
165
-                                $orderColumn = $component->getOrderColumn();
166
-
167
-                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
168
-
169
-                                foreach ($childComponentContainers as $itemKey => $item) {
170
-                                    $itemData = $item->getState(shouldCallHooksBefore: false);
171
-
172
-                                    if ($orderColumn) {
173
-                                        $itemData[$orderColumn] = $itemOrder;
174
-
175
-                                        $itemOrder++;
176
-                                    }
177
-
178
-                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
179
-                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
180
-
181
-                                        if ($itemData === null) {
182
-                                            continue;
183
-                                        }
184
-
185
-                                        $translatableContentDriver ?
186
-                                            $translatableContentDriver->updateRecord($record, $itemData) :
187
-                                            $record->fill($itemData)->save();
188
-
189
-                                        continue;
190
-                                    }
191
-
192
-                                    $relatedModel = $component->getRelatedModel();
193
-
194
-                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
195
-
196
-                                    if ($itemData === null) {
197
-                                        continue;
198
-                                    }
199
-
200
-                                    if ($translatableContentDriver) {
201
-                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
202
-                                    } else {
203
-                                        $record = new $relatedModel;
204
-                                        $record->fill($itemData);
205
-                                    }
206
-
207
-                                    $record = $relationship->save($record);
208
-                                    $item->model($record)->saveRelationships();
209
-                                    $existingRecords->push($record);
148
+                            ->saveRelationshipsUsing(null)
149
+                            ->dehydrated(true)
150
+                            ->headers(function (Forms\Get $get) {
151
+                                $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
152
+
153
+                                $headers = [
154
+                                    Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
155
+                                    Header::make('Description')->width($hasDiscounts ? '25%' : '30%'),  // Increase when no discounts
156
+                                    Header::make('Quantity')->width('10%'),
157
+                                    Header::make('Price')->width('10%'),
158
+                                    Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'),       // Increase when no discounts
159
+                                ];
160
+
161
+                                if ($hasDiscounts) {
162
+                                    $headers[] = Header::make('Discounts')->width('15%');
210 163
                                 }
211 164
 
212
-                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
213
-
214
-                                /** @var Invoice $invoice */
215
-                                $invoice = $component->getRecord();
216
-
217
-                                // Recalculate totals for line items
218
-                                $invoice->lineItems()->each(function (DocumentLineItem $lineItem) {
219
-                                    $lineItem->updateQuietly([
220
-                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
221
-                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
222
-                                    ]);
223
-                                });
224
-
225
-                                $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
226
-                                $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
227
-                                $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
228
-                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
229
-
230
-                                $invoice->updateQuietly([
231
-                                    'subtotal' => $subtotal,
232
-                                    'tax_total' => $taxTotal,
233
-                                    'discount_total' => $discountTotal,
234
-                                    'total' => $grandTotal,
235
-                                ]);
165
+                                $headers[] = Header::make('Amount')->width('10%')->align('right');
236 166
 
237
-                                if ($invoice->approved_at && $invoice->approvalTransaction) {
238
-                                    $invoice->updateApprovalTransaction();
239
-                                }
167
+                                return $headers;
240 168
                             })
241
-                            ->headers([
242
-                                Header::make('Items')->width('15%'),
243
-                                Header::make('Description')->width('25%'),
244
-                                Header::make('Quantity')->width('10%'),
245
-                                Header::make('Price')->width('10%'),
246
-                                Header::make('Taxes')->width('15%'),
247
-                                Header::make('Discounts')->width('15%'),
248
-                                Header::make('Amount')->width('10%')->align('right'),
249
-                            ])
250 169
                             ->schema([
251 170
                                 Forms\Components\Select::make('offering_id')
252 171
                                     ->relationship('sellableOffering', 'name')
@@ -262,7 +181,11 @@ class InvoiceResource extends Resource
262 181
                                             $set('description', $offeringRecord->description);
263 182
                                             $set('unit_price', $offeringRecord->price);
264 183
                                             $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
265
-                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
184
+
185
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
186
+                                            if ($discountMethod->isPerLineItem()) {
187
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
188
+                                            }
266 189
                                         }
267 190
                                     }),
268 191
                                 Forms\Components\TextInput::make('description'),
@@ -278,15 +201,24 @@ class InvoiceResource extends Resource
278 201
                                     ->default(0),
279 202
                                 Forms\Components\Select::make('salesTaxes')
280 203
                                     ->relationship('salesTaxes', 'name')
204
+                                    ->saveRelationshipsUsing(null)
205
+                                    ->dehydrated(true)
281 206
                                     ->preload()
282 207
                                     ->multiple()
283 208
                                     ->live()
284 209
                                     ->searchable(),
285 210
                                 Forms\Components\Select::make('salesDiscounts')
286 211
                                     ->relationship('salesDiscounts', 'name')
212
+                                    ->saveRelationshipsUsing(null)
213
+                                    ->dehydrated(true)
287 214
                                     ->preload()
288 215
                                     ->multiple()
289 216
                                     ->live()
217
+                                    ->hidden(function (Forms\Get $get) {
218
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
219
+
220
+                                        return $discountMethod->isPerDocument();
221
+                                    })
290 222
                                     ->searchable(),
291 223
                                 Forms\Components\Placeholder::make('total')
292 224
                                     ->hiddenLabel()
@@ -318,13 +250,7 @@ class InvoiceResource extends Resource
318 250
                                         return CurrencyConverter::formatToMoney($total);
319 251
                                     }),
320 252
                             ]),
321
-                        Forms\Components\Grid::make(6)
322
-                            ->schema([
323
-                                Forms\Components\ViewField::make('totals')
324
-                                    ->columnStart(5)
325
-                                    ->columnSpan(2)
326
-                                    ->view('filament.forms.components.invoice-totals'),
327
-                            ]),
253
+                        InvoiceTotals::make(),
328 254
                         Forms\Components\Textarea::make('terms')
329 255
                             ->columnSpanFull(),
330 256
                     ]),
@@ -342,6 +268,11 @@ class InvoiceResource extends Resource
342 268
         return $table
343 269
             ->defaultSort('due_date')
344 270
             ->columns([
271
+                Tables\Columns\TextColumn::make('id')
272
+                    ->label('ID')
273
+                    ->sortable()
274
+                    ->toggleable(isToggledHiddenByDefault: true)
275
+                    ->searchable(),
345 276
                 Tables\Columns\TextColumn::make('status')
346 277
                     ->badge()
347 278
                     ->searchable(),

+ 18
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php 查看文件

@@ -2,13 +2,17 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5
+use App\Concerns\ManagesLineItems;
5 6
 use App\Concerns\RedirectToListPage;
6 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Models\Accounting\Invoice;
7 9
 use Filament\Resources\Pages\CreateRecord;
8 10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
9 12
 
10 13
 class CreateInvoice extends CreateRecord
11 14
 {
15
+    use ManagesLineItems;
12 16
     use RedirectToListPage;
13 17
 
14 18
     protected static string $resource = InvoiceResource::class;
@@ -17,4 +21,18 @@ class CreateInvoice extends CreateRecord
17 21
     {
18 22
         return MaxWidth::Full;
19 23
     }
24
+
25
+    protected function handleRecordCreation(array $data): Model
26
+    {
27
+        /** @var Invoice $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
+    }
20 38
 }

+ 26
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php 查看文件

@@ -2,14 +2,18 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5
+use App\Concerns\ManagesLineItems;
5 6
 use App\Concerns\RedirectToListPage;
6 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
+use App\Models\Accounting\Invoice;
7 9
 use Filament\Actions;
8 10
 use Filament\Resources\Pages\EditRecord;
9 11
 use Filament\Support\Enums\MaxWidth;
12
+use Illuminate\Database\Eloquent\Model;
10 13
 
11 14
 class EditInvoice extends EditRecord
12 15
 {
16
+    use ManagesLineItems;
13 17
     use RedirectToListPage;
14 18
 
15 19
     protected static string $resource = InvoiceResource::class;
@@ -25,4 +29,26 @@ class EditInvoice extends EditRecord
25 29
     {
26 30
         return MaxWidth::Full;
27 31
     }
32
+
33
+    protected function handleRecordUpdate(Model $record, array $data): Model
34
+    {
35
+        /** @var Invoice $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
+    }
28 54
 }

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

@@ -6,22 +6,32 @@ use App\Filament\Company\Resources\Sales\ClientResource;
6 6
 use App\Filament\Company\Resources\Sales\InvoiceResource;
7 7
 use App\Models\Accounting\Invoice;
8 8
 use Filament\Actions;
9
+use Filament\Infolists\Components\Grid;
9 10
 use Filament\Infolists\Components\Section;
10 11
 use Filament\Infolists\Components\TextEntry;
12
+use Filament\Infolists\Components\ViewEntry;
11 13
 use Filament\Infolists\Infolist;
12 14
 use Filament\Resources\Pages\ViewRecord;
13 15
 use Filament\Support\Enums\FontWeight;
14 16
 use Filament\Support\Enums\IconPosition;
15 17
 use Filament\Support\Enums\IconSize;
18
+use Filament\Support\Enums\MaxWidth;
16 19
 
17 20
 class ViewInvoice extends ViewRecord
18 21
 {
22
+    protected static string $view = 'filament.company.resources.sales.invoices.pages.view-invoice';
23
+
19 24
     protected static string $resource = InvoiceResource::class;
20 25
 
21 26
     protected $listeners = [
22 27
         'refresh' => '$refresh',
23 28
     ];
24 29
 
30
+    public function getMaxContentWidth(): MaxWidth | string | null
31
+    {
32
+        return MaxWidth::SixExtraLarge;
33
+    }
34
+
25 35
     protected function getHeaderActions(): array
26 36
     {
27 37
         return [
@@ -49,39 +59,47 @@ class ViewInvoice extends ViewRecord
49 59
                 Section::make('Invoice Details')
50 60
                     ->columns(4)
51 61
                     ->schema([
52
-                        TextEntry::make('invoice_number')
53
-                            ->label('Invoice #'),
54
-                        TextEntry::make('status')
55
-                            ->badge(),
56
-                        TextEntry::make('client.name')
57
-                            ->label('Client')
58
-                            ->color('primary')
59
-                            ->weight(FontWeight::SemiBold)
60
-                            ->url(static fn (Invoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
61
-                        TextEntry::make('total')
62
-                            ->label('Total')
63
-                            ->money(),
64
-                        TextEntry::make('amount_due')
65
-                            ->label('Amount Due')
66
-                            ->money(),
67
-                        TextEntry::make('date')
68
-                            ->label('Date')
69
-                            ->date(),
70
-                        TextEntry::make('due_date')
71
-                            ->label('Due')
72
-                            ->asRelativeDay(),
73
-                        TextEntry::make('approved_at')
74
-                            ->label('Approved At')
75
-                            ->placeholder('Not Approved')
76
-                            ->date(),
77
-                        TextEntry::make('last_sent')
78
-                            ->label('Last Sent')
79
-                            ->placeholder('Never')
80
-                            ->date(),
81
-                        TextEntry::make('paid_at')
82
-                            ->label('Paid At')
83
-                            ->placeholder('Not Paid')
84
-                            ->date(),
62
+                        Grid::make(1)
63
+                            ->schema([
64
+                                TextEntry::make('invoice_number')
65
+                                    ->label('Invoice #'),
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 (Invoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
73
+                                TextEntry::make('amount_due')
74
+                                    ->label('Amount Due')
75
+                                    ->money(),
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')
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 Invoice')
96
+                                    ->columnSpan(3)
97
+                                    ->view('filament.company.resources.sales.invoices.components.invoice-view')
98
+                                    ->viewData([
99
+                                        'invoice' => $this->record,
100
+                                    ]),
101
+                            ])
102
+                            ->columnSpan(3),
85 103
                     ]),
86 104
             ]);
87 105
     }

+ 2
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php 查看文件

@@ -28,6 +28,8 @@ class PaymentsRelationManager extends RelationManager
28 28
 
29 29
     protected static ?string $modelLabel = 'Payment';
30 30
 
31
+    protected static bool $isLazy = false;
32
+
31 33
     protected $listeners = [
32 34
         'refresh' => '$refresh',
33 35
     ];

+ 3
- 1
app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php 查看文件

@@ -45,7 +45,9 @@ class InvoiceOverview extends EnhancedStatsOverviewWidget
45 45
 
46 46
         $totalValidInvoiceCount = $validInvoices->count();
47 47
 
48
-        $averageInvoiceTotal = $totalValidInvoiceCount > 0 ? $totalValidInvoiceAmount / $totalValidInvoiceCount : 0;
48
+        $averageInvoiceTotal = $totalValidInvoiceCount > 0
49
+            ? (int) round($totalValidInvoiceAmount / $totalValidInvoiceCount)
50
+            : 0;
49 51
 
50 52
         $averagePaymentTime = $this->getPageTableQuery()
51 53
             ->whereNotNull('paid_at')

+ 37
- 0
app/Filament/Forms/Components/BillTotals.php 查看文件

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use Filament\Forms\Components\Grid;
7
+use Filament\Forms\Components\Select;
8
+use Filament\Forms\Components\TextInput;
9
+use Filament\Forms\Get;
10
+
11
+class BillTotals extends Grid
12
+{
13
+    protected string $view = 'filament.forms.components.bill-totals';
14
+
15
+    protected function setUp(): void
16
+    {
17
+        parent::setUp();
18
+
19
+        $this->schema([
20
+            TextInput::make('discount_rate')
21
+                ->label('Discount Rate')
22
+                ->hiddenLabel()
23
+                ->live()
24
+                ->rate(computation: static fn (Get $get) => $get('discount_computation'), showAffix: false),
25
+            Select::make('discount_computation')
26
+                ->label('Discount Computation')
27
+                ->hiddenLabel()
28
+                ->options([
29
+                    'percentage' => '%',
30
+                    'fixed' => '$',
31
+                ])
32
+                ->default(AdjustmentComputation::Percentage)
33
+                ->selectablePlaceholder(false)
34
+                ->live(),
35
+        ]);
36
+    }
37
+}

+ 37
- 0
app/Filament/Forms/Components/InvoiceTotals.php 查看文件

@@ -0,0 +1,37 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use Filament\Forms\Components\Grid;
7
+use Filament\Forms\Components\Select;
8
+use Filament\Forms\Components\TextInput;
9
+use Filament\Forms\Get;
10
+
11
+class InvoiceTotals extends Grid
12
+{
13
+    protected string $view = 'filament.forms.components.invoice-totals';
14
+
15
+    protected function setUp(): void
16
+    {
17
+        parent::setUp();
18
+
19
+        $this->schema([
20
+            TextInput::make('discount_rate')
21
+                ->label('Discount Rate')
22
+                ->hiddenLabel()
23
+                ->live()
24
+                ->rate(computation: static fn (Get $get) => $get('discount_computation'), showAffix: false),
25
+            Select::make('discount_computation')
26
+                ->label('Discount Computation')
27
+                ->hiddenLabel()
28
+                ->options([
29
+                    'percentage' => '%',
30
+                    'fixed' => '$',
31
+                ])
32
+                ->default(AdjustmentComputation::Percentage)
33
+                ->selectablePlaceholder(false)
34
+                ->live(),
35
+        ]);
36
+    }
37
+}

+ 10
- 0
app/Models/Accounting/Account.php 查看文件

@@ -133,6 +133,16 @@ class Account extends Model
133 133
         return self::where('name', 'Accounts Payable')->firstOrFail();
134 134
     }
135 135
 
136
+    public static function getSalesDiscountAccount(): self
137
+    {
138
+        return self::where('name', 'Sales Discount')->firstOrFail();
139
+    }
140
+
141
+    public static function getPurchaseDiscountAccount(): self
142
+    {
143
+        return self::where('name', 'Purchase Discount')->firstOrFail();
144
+    }
145
+
136 146
     protected static function newFactory(): Factory
137 147
     {
138 148
         return AccountFactory::new();

+ 38
- 1
app/Models/Accounting/Bill.php 查看文件

@@ -3,14 +3,18 @@
3 3
 namespace App\Models\Accounting;
4 4
 
5 5
 use App\Casts\MoneyCast;
6
+use App\Casts\RateCast;
6 7
 use App\Concerns\Blamable;
7 8
 use App\Concerns\CompanyOwned;
9
+use App\Enums\Accounting\AdjustmentComputation;
8 10
 use App\Enums\Accounting\BillStatus;
11
+use App\Enums\Accounting\DocumentDiscountMethod;
9 12
 use App\Enums\Accounting\JournalEntryType;
10 13
 use App\Enums\Accounting\TransactionType;
11 14
 use App\Filament\Company\Resources\Purchases\BillResource;
12 15
 use App\Models\Common\Vendor;
13 16
 use App\Observers\BillObserver;
17
+use App\Utilities\Currency\CurrencyConverter;
14 18
 use Filament\Actions\MountableAction;
15 19
 use Filament\Actions\ReplicateAction;
16 20
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
@@ -42,6 +46,9 @@ class Bill extends Model
42 46
         'paid_at',
43 47
         'status',
44 48
         'currency_code',
49
+        'discount_method',
50
+        'discount_computation',
51
+        'discount_rate',
45 52
         'subtotal',
46 53
         'tax_total',
47 54
         'discount_total',
@@ -57,6 +64,9 @@ class Bill extends Model
57 64
         'due_date' => 'date',
58 65
         'paid_at' => 'datetime',
59 66
         'status' => BillStatus::class,
67
+        'discount_method' => DocumentDiscountMethod::class,
68
+        'discount_computation' => AdjustmentComputation::class,
69
+        'discount_rate' => RateCast::class,
60 70
         'subtotal' => MoneyCast::class,
61 71
         'tax_total' => MoneyCast::class,
62 72
         'discount_total' => MoneyCast::class,
@@ -215,7 +225,11 @@ class Bill extends Model
215 225
             'description' => $baseDescription,
216 226
         ]);
217 227
 
218
-        foreach ($this->lineItems as $lineItem) {
228
+        $totalLineItemSubtotal = (int) $this->lineItems()->sum('subtotal');
229
+        $billDiscountTotalCents = (int) $this->getRawOriginal('discount_total');
230
+        $remainingDiscountCents = $billDiscountTotalCents;
231
+
232
+        foreach ($this->lineItems as $index => $lineItem) {
219 233
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
220 234
 
221 235
             $transaction->journalEntries()->create([
@@ -245,6 +259,29 @@ class Bill extends Model
245 259
                     ]);
246 260
                 }
247 261
             }
262
+
263
+            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotal > 0) {
264
+                $lineItemSubtotalCents = (int) $lineItem->getRawOriginal('subtotal');
265
+
266
+                if ($index === $this->lineItems->count() - 1) {
267
+                    $lineItemDiscount = $remainingDiscountCents;
268
+                } else {
269
+                    $lineItemDiscount = (int) round(
270
+                        ($lineItemSubtotalCents / $totalLineItemSubtotal) * $billDiscountTotalCents
271
+                    );
272
+                    $remainingDiscountCents -= $lineItemDiscount;
273
+                }
274
+
275
+                if ($lineItemDiscount > 0) {
276
+                    $transaction->journalEntries()->create([
277
+                        'company_id' => $this->company_id,
278
+                        'type' => JournalEntryType::Credit,
279
+                        'account_id' => Account::getPurchaseDiscountAccount()->id,
280
+                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
281
+                        'description' => "{$lineItemDescription} (Proportional Discount)",
282
+                    ]);
283
+                }
284
+            }
248 285
         }
249 286
     }
250 287
 

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

@@ -3,15 +3,19 @@
3 3
 namespace App\Models\Accounting;
4 4
 
5 5
 use App\Casts\MoneyCast;
6
+use App\Casts\RateCast;
6 7
 use App\Collections\Accounting\InvoiceCollection;
7 8
 use App\Concerns\Blamable;
8 9
 use App\Concerns\CompanyOwned;
10
+use App\Enums\Accounting\AdjustmentComputation;
11
+use App\Enums\Accounting\DocumentDiscountMethod;
9 12
 use App\Enums\Accounting\InvoiceStatus;
10 13
 use App\Enums\Accounting\JournalEntryType;
11 14
 use App\Enums\Accounting\TransactionType;
12 15
 use App\Filament\Company\Resources\Sales\InvoiceResource;
13 16
 use App\Models\Common\Client;
14 17
 use App\Observers\InvoiceObserver;
18
+use App\Utilities\Currency\CurrencyConverter;
15 19
 use Filament\Actions\Action;
16 20
 use Filament\Actions\MountableAction;
17 21
 use Filament\Actions\ReplicateAction;
@@ -51,6 +55,9 @@ class Invoice extends Model
51 55
         'last_sent',
52 56
         'status',
53 57
         'currency_code',
58
+        'discount_method',
59
+        'discount_computation',
60
+        'discount_rate',
54 61
         'subtotal',
55 62
         'tax_total',
56 63
         'discount_total',
@@ -69,6 +76,9 @@ class Invoice extends Model
69 76
         'paid_at' => 'datetime',
70 77
         'last_sent' => 'datetime',
71 78
         'status' => InvoiceStatus::class,
79
+        'discount_method' => DocumentDiscountMethod::class,
80
+        'discount_computation' => AdjustmentComputation::class,
81
+        'discount_rate' => RateCast::class,
72 82
         'subtotal' => MoneyCast::class,
73 83
         'tax_total' => MoneyCast::class,
74 84
         'discount_total' => MoneyCast::class,
@@ -260,7 +270,11 @@ class Invoice extends Model
260 270
             'description' => $baseDescription,
261 271
         ]);
262 272
 
263
-        foreach ($this->lineItems as $lineItem) {
273
+        $totalLineItemSubtotal = (int) $this->lineItems()->sum('subtotal');
274
+        $invoiceDiscountTotalCents = (int) $this->getRawOriginal('discount_total');
275
+        $remainingDiscountCents = $invoiceDiscountTotalCents;
276
+
277
+        foreach ($this->lineItems as $index => $lineItem) {
264 278
             $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
265 279
 
266 280
             $transaction->journalEntries()->create([
@@ -280,6 +294,29 @@ class Invoice extends Model
280 294
                     'description' => $lineItemDescription,
281 295
                 ]);
282 296
             }
297
+
298
+            if ($this->discount_method->isPerDocument() && $totalLineItemSubtotal > 0) {
299
+                $lineItemSubtotalCents = (int) $lineItem->getRawOriginal('subtotal');
300
+
301
+                if ($index === $this->lineItems->count() - 1) {
302
+                    $lineItemDiscount = $remainingDiscountCents;
303
+                } else {
304
+                    $lineItemDiscount = (int) round(
305
+                        ($lineItemSubtotalCents / $totalLineItemSubtotal) * $invoiceDiscountTotalCents
306
+                    );
307
+                    $remainingDiscountCents -= $lineItemDiscount;
308
+                }
309
+
310
+                if ($lineItemDiscount > 0) {
311
+                    $transaction->journalEntries()->create([
312
+                        'company_id' => $this->company_id,
313
+                        'type' => JournalEntryType::Debit,
314
+                        'account_id' => Account::getSalesDiscountAccount()->id,
315
+                        'amount' => CurrencyConverter::convertCentsToFormatSimple($lineItemDiscount),
316
+                        'description' => "{$lineItemDescription} (Proportional Discount)",
317
+                    ]);
318
+                }
319
+            }
283 320
         }
284 321
     }
285 322
 

+ 0
- 82
app/Models/Setting/Discount.php 查看文件

@@ -1,82 +0,0 @@
1
-<?php
2
-
3
-namespace App\Models\Setting;
4
-
5
-use App\Casts\RateCast;
6
-use App\Concerns\Blamable;
7
-use App\Concerns\CompanyOwned;
8
-use App\Concerns\HasDefault;
9
-use App\Concerns\SyncsWithCompanyDefaults;
10
-use App\Enums\Setting\DiscountComputation;
11
-use App\Enums\Setting\DiscountScope;
12
-use App\Enums\Setting\DiscountType;
13
-use App\Models\Accounting\Account;
14
-use Database\Factories\Setting\DiscountFactory;
15
-use Illuminate\Database\Eloquent\Factories\Factory;
16
-use Illuminate\Database\Eloquent\Factories\HasFactory;
17
-use Illuminate\Database\Eloquent\Model;
18
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
-use Illuminate\Database\Eloquent\Relations\HasOne;
20
-use Illuminate\Database\Eloquent\Relations\MorphTo;
21
-
22
-class Discount extends Model
23
-{
24
-    use Blamable;
25
-    use CompanyOwned;
26
-    use HasDefault;
27
-    use HasFactory;
28
-    use SyncsWithCompanyDefaults;
29
-
30
-    protected $table = 'discounts';
31
-
32
-    protected $fillable = [
33
-        'company_id',
34
-        'account_id',
35
-        'rate',
36
-        'computation',
37
-        'type',
38
-        'scope',
39
-        'start_date',
40
-        'end_date',
41
-        'enabled',
42
-        'created_by',
43
-        'updated_by',
44
-    ];
45
-
46
-    protected $casts = [
47
-        'rate' => RateCast::class,
48
-        'computation' => DiscountComputation::class,
49
-        'type' => DiscountType::class,
50
-        'scope' => DiscountScope::class,
51
-        'start_date' => 'datetime',
52
-        'end_date' => 'datetime',
53
-        'enabled' => 'boolean',
54
-    ];
55
-
56
-    protected ?string $evaluatedDefault = 'type';
57
-
58
-    public function account(): BelongsTo
59
-    {
60
-        return $this->belongsTo(Account::class, 'account_id');
61
-    }
62
-
63
-    public function defaultSalesDiscount(): HasOne
64
-    {
65
-        return $this->hasOne(CompanyDefault::class, 'sales_discount_id');
66
-    }
67
-
68
-    public function defaultPurchaseDiscount(): HasOne
69
-    {
70
-        return $this->hasOne(CompanyDefault::class, 'purchase_discount_id');
71
-    }
72
-
73
-    public function adjustmentables(): MorphTo
74
-    {
75
-        return $this->morphTo();
76
-    }
77
-
78
-    protected static function newFactory(): Factory
79
-    {
80
-        return DiscountFactory::new();
81
-    }
82
-}

+ 0
- 78
app/Models/Setting/Tax.php 查看文件

@@ -1,78 +0,0 @@
1
-<?php
2
-
3
-namespace App\Models\Setting;
4
-
5
-use App\Casts\RateCast;
6
-use App\Concerns\Blamable;
7
-use App\Concerns\CompanyOwned;
8
-use App\Concerns\HasDefault;
9
-use App\Concerns\SyncsWithCompanyDefaults;
10
-use App\Enums\Setting\TaxComputation;
11
-use App\Enums\Setting\TaxScope;
12
-use App\Enums\Setting\TaxType;
13
-use App\Models\Accounting\Account;
14
-use Database\Factories\Setting\TaxFactory;
15
-use Illuminate\Database\Eloquent\Factories\Factory;
16
-use Illuminate\Database\Eloquent\Factories\HasFactory;
17
-use Illuminate\Database\Eloquent\Model;
18
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
-use Illuminate\Database\Eloquent\Relations\HasOne;
20
-use Illuminate\Database\Eloquent\Relations\MorphTo;
21
-
22
-class Tax extends Model
23
-{
24
-    use Blamable;
25
-    use CompanyOwned;
26
-    use HasDefault;
27
-    use HasFactory;
28
-    use SyncsWithCompanyDefaults;
29
-
30
-    protected $table = 'taxes';
31
-
32
-    protected $fillable = [
33
-        'company_id',
34
-        'account_id',
35
-        'rate',
36
-        'computation',
37
-        'type',
38
-        'scope',
39
-        'enabled',
40
-        'created_by',
41
-        'updated_by',
42
-    ];
43
-
44
-    protected $casts = [
45
-        'rate' => RateCast::class,
46
-        'computation' => TaxComputation::class,
47
-        'type' => TaxType::class,
48
-        'scope' => TaxScope::class,
49
-        'enabled' => 'boolean',
50
-    ];
51
-
52
-    protected ?string $evaluatedDefault = 'type';
53
-
54
-    public function account(): BelongsTo
55
-    {
56
-        return $this->belongsTo(Account::class, 'account_id');
57
-    }
58
-
59
-    public function defaultSalesTax(): HasOne
60
-    {
61
-        return $this->hasOne(CompanyDefault::class, 'sales_tax_id');
62
-    }
63
-
64
-    public function defaultPurchaseTax(): HasOne
65
-    {
66
-        return $this->hasOne(CompanyDefault::class, 'purchase_tax_id');
67
-    }
68
-
69
-    public function adjustmentables(): MorphTo
70
-    {
71
-        return $this->morphTo();
72
-    }
73
-
74
-    protected static function newFactory(): Factory
75
-    {
76
-        return TaxFactory::new();
77
-    }
78
-}

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

@@ -36,8 +36,6 @@ class AuthServiceProvider extends ServiceProvider
36 36
     {
37 37
         $models = [
38 38
             Setting\Currency::class,
39
-            Setting\Discount::class,
40
-            Setting\Tax::class,
41 39
             Banking\BankAccount::class,
42 40
         ];
43 41
 

+ 16
- 13
app/Providers/MacroServiceProvider.php 查看文件

@@ -19,7 +19,6 @@ use Filament\Infolists\Components\TextEntry;
19 19
 use Filament\Tables\Columns\TextColumn;
20 20
 use Illuminate\Support\Carbon;
21 21
 use Illuminate\Support\ServiceProvider;
22
-use Illuminate\Support\Str;
23 22
 
24 23
 class MacroServiceProvider extends ServiceProvider
25 24
 {
@@ -103,18 +102,22 @@ class MacroServiceProvider extends ServiceProvider
103 102
             return $this;
104 103
         });
105 104
 
106
-        TextInput::macro('rate', function (string | Closure | null $computation = null): static {
107
-            $this->extraAttributes(['wire:key' => Str::random()])
108
-                ->prefix(static function (TextInput $component) use ($computation) {
109
-                    $computation = $component->evaluate($computation);
110
-
111
-                    return ratePrefix(computation: $computation);
112
-                })
113
-                ->suffix(static function (TextInput $component) use ($computation) {
114
-                    $computation = $component->evaluate($computation);
115
-
116
-                    return rateSuffix(computation: $computation);
117
-                })
105
+        TextInput::macro('rate', function (string | Closure | null $computation = null, bool $showAffix = true): static {
106
+            $this
107
+                ->when(
108
+                    $showAffix,
109
+                    fn (TextInput $component) => $component
110
+                        ->prefix(static function (TextInput $component) use ($computation) {
111
+                            $computation = $component->evaluate($computation);
112
+
113
+                            return ratePrefix(computation: $computation);
114
+                        })
115
+                        ->suffix(static function (TextInput $component) use ($computation) {
116
+                            $computation = $component->evaluate($computation);
117
+
118
+                            return rateSuffix(computation: $computation);
119
+                        })
120
+                )
118 121
                 ->mask(static function (TextInput $component) use ($computation) {
119 122
                     $computation = $component->evaluate($computation);
120 123
 

+ 11
- 2
app/Services/ReportService.php 查看文件

@@ -177,6 +177,8 @@ class ReportService
177 177
 
178 178
             $accountTransactions = [];
179 179
             $currentBalance = $account->starting_balance;
180
+            $periodDebitTotal = 0;
181
+            $periodCreditTotal = 0;
180 182
 
181 183
             $accountTransactions[] = new AccountTransactionDTO(
182 184
                 id: null,
@@ -192,6 +194,13 @@ class ReportService
192 194
             foreach ($account->journalEntries as $journalEntry) {
193 195
                 $transaction = $journalEntry->transaction;
194 196
                 $signedAmount = $journalEntry->signed_amount;
197
+                $amount = $journalEntry->getRawOriginal('amount');
198
+
199
+                if ($journalEntry->type->isDebit()) {
200
+                    $periodDebitTotal += $amount;
201
+                } else {
202
+                    $periodCreditTotal += $amount;
203
+                }
195 204
 
196 205
                 if ($account->category->isNormalDebitBalance()) {
197 206
                     $currentBalance += $signedAmount;
@@ -219,8 +228,8 @@ class ReportService
219 228
                 id: null,
220 229
                 date: 'Totals and Ending Balance',
221 230
                 description: '',
222
-                debit: money($account->total_debit, $defaultCurrency)->format(),
223
-                credit: money($account->total_credit, $defaultCurrency)->format(),
231
+                debit: money($periodDebitTotal, $defaultCurrency)->format(),
232
+                credit: money($periodCreditTotal, $defaultCurrency)->format(),
224 233
                 balance: money($currentBalance, $defaultCurrency)->format(),
225 234
                 type: null,
226 235
                 tableAction: null

+ 27
- 11
app/View/Models/BillTotalViewModel.php 查看文件

@@ -2,6 +2,8 @@
2 2
 
3 3
 namespace App\View\Models;
4 4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
5 7
 use App\Models\Accounting\Adjustment;
6 8
 use App\Models\Accounting\Bill;
7 9
 use App\Utilities\Currency\CurrencyConverter;
@@ -17,7 +19,7 @@ class BillTotalViewModel
17 19
     {
18 20
         $lineItems = collect($this->data['lineItems'] ?? []);
19 21
 
20
-        $subtotal = $lineItems->sum(function ($item) {
22
+        $subtotal = $lineItems->sum(static function ($item) {
21 23
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
22 24
             $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
23 25
 
@@ -37,18 +39,32 @@ class BillTotalViewModel
37 39
             return $carry + $taxAmount;
38 40
         }, 0);
39 41
 
40
-        $discountTotal = $lineItems->reduce(function ($carry, $item) {
41
-            $quantity = max((float) ($item['quantity'] ?? 0), 0);
42
-            $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
43
-            $purchaseDiscounts = $item['purchaseDiscounts'] ?? [];
44
-            $lineTotal = $quantity * $unitPrice;
42
+        // Calculate discount based on method
43
+        $discountMethod = DocumentDiscountMethod::parse($this->data['discount_method']) ?? DocumentDiscountMethod::PerLineItem;
45 44
 
46
-            $discountAmount = Adjustment::whereIn('id', $purchaseDiscounts)
47
-                ->pluck('rate')
48
-                ->sum(fn ($rate) => $lineTotal * ($rate / 100));
45
+        if ($discountMethod->isPerLineItem()) {
46
+            $discountTotal = $lineItems->reduce(function ($carry, $item) {
47
+                $quantity = max((float) ($item['quantity'] ?? 0), 0);
48
+                $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
49
+                $purchaseDiscounts = $item['purchaseDiscounts'] ?? [];
50
+                $lineTotal = $quantity * $unitPrice;
49 51
 
50
-            return $carry + $discountAmount;
51
-        }, 0);
52
+                $discountAmount = Adjustment::whereIn('id', $purchaseDiscounts)
53
+                    ->pluck('rate')
54
+                    ->sum(fn ($rate) => $lineTotal * ($rate / 100));
55
+
56
+                return $carry + $discountAmount;
57
+            }, 0);
58
+        } else {
59
+            $discountComputation = AdjustmentComputation::parse($this->data['discount_computation']) ?? AdjustmentComputation::Percentage;
60
+            $discountRate = (float) ($this->data['discount_rate'] ?? 0);
61
+
62
+            if ($discountComputation->isPercentage()) {
63
+                $discountTotal = $subtotal * ($discountRate / 100);
64
+            } else {
65
+                $discountTotal = $discountRate;
66
+            }
67
+        }
52 68
 
53 69
         $grandTotal = $subtotal + ($taxTotal - $discountTotal);
54 70
 

+ 27
- 11
app/View/Models/InvoiceTotalViewModel.php 查看文件

@@ -2,6 +2,8 @@
2 2
 
3 3
 namespace App\View\Models;
4 4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
5 7
 use App\Models\Accounting\Adjustment;
6 8
 use App\Models\Accounting\Invoice;
7 9
 use App\Utilities\Currency\CurrencyConverter;
@@ -17,7 +19,7 @@ class InvoiceTotalViewModel
17 19
     {
18 20
         $lineItems = collect($this->data['lineItems'] ?? []);
19 21
 
20
-        $subtotal = $lineItems->sum(function ($item) {
22
+        $subtotal = $lineItems->sum(static function ($item) {
21 23
             $quantity = max((float) ($item['quantity'] ?? 0), 0);
22 24
             $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
23 25
 
@@ -37,18 +39,32 @@ class InvoiceTotalViewModel
37 39
             return $carry + $taxAmount;
38 40
         }, 0);
39 41
 
40
-        $discountTotal = $lineItems->reduce(function ($carry, $item) {
41
-            $quantity = max((float) ($item['quantity'] ?? 0), 0);
42
-            $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
43
-            $salesDiscounts = $item['salesDiscounts'] ?? [];
44
-            $lineTotal = $quantity * $unitPrice;
42
+        // Calculate discount based on method
43
+        $discountMethod = DocumentDiscountMethod::parse($this->data['discount_method']) ?? DocumentDiscountMethod::PerLineItem;
45 44
 
46
-            $discountAmount = Adjustment::whereIn('id', $salesDiscounts)
47
-                ->pluck('rate')
48
-                ->sum(fn ($rate) => $lineTotal * ($rate / 100));
45
+        if ($discountMethod->isPerLineItem()) {
46
+            $discountTotal = $lineItems->reduce(function ($carry, $item) {
47
+                $quantity = max((float) ($item['quantity'] ?? 0), 0);
48
+                $unitPrice = max((float) ($item['unit_price'] ?? 0), 0);
49
+                $salesDiscounts = $item['salesDiscounts'] ?? [];
50
+                $lineTotal = $quantity * $unitPrice;
49 51
 
50
-            return $carry + $discountAmount;
51
-        }, 0);
52
+                $discountAmount = Adjustment::whereIn('id', $salesDiscounts)
53
+                    ->pluck('rate')
54
+                    ->sum(fn ($rate) => $lineTotal * ($rate / 100));
55
+
56
+                return $carry + $discountAmount;
57
+            }, 0);
58
+        } else {
59
+            $discountComputation = AdjustmentComputation::parse($this->data['discount_computation']) ?? AdjustmentComputation::Percentage;
60
+            $discountRate = (float) ($this->data['discount_rate'] ?? 0);
61
+
62
+            if ($discountComputation->isPercentage()) {
63
+                $discountTotal = $subtotal * ($discountRate / 100);
64
+            } else {
65
+                $discountTotal = $discountRate;
66
+            }
67
+        }
52 68
 
53 69
         $grandTotal = $subtotal + ($taxTotal - $discountTotal);
54 70
 

+ 1
- 1
composer.json 查看文件

@@ -17,7 +17,7 @@
17 17
         "andrewdwallo/transmatic": "^1.1",
18 18
         "awcodes/filament-table-repeater": "^3.0",
19 19
         "barryvdh/laravel-snappy": "^1.0",
20
-        "filament/filament": "^3.2.115",
20
+        "filament/filament": "v3.2.129",
21 21
         "guava/filament-clusters": "^1.1",
22 22
         "guzzlehttp/guzzle": "^7.8",
23 23
         "jaocero/radio-deck": "^1.2",

+ 313
- 376
composer.lock
文件差异内容过多而无法显示
查看文件


+ 3
- 1
database/factories/Accounting/AdjustmentFactory.php 查看文件

@@ -30,9 +30,11 @@ class AdjustmentFactory extends Factory
30 30
     {
31 31
         $startDate = $this->faker->dateTimeBetween('now', '+1 year');
32 32
         $endDate = $this->faker->dateTimeBetween($startDate, Carbon::parse($startDate)->addYear());
33
+
34
+        /** @var AdjustmentComputation $computation */
33 35
         $computation = $this->faker->randomElement(AdjustmentComputation::class);
34 36
 
35
-        $rate = $computation === AdjustmentComputation::Fixed
37
+        $rate = $computation->isFixed()
36 38
             ? $this->faker->numberBetween(5, 100) * 100 // $5 - $100 for fixed amounts
37 39
             : $this->faker->numberBetween(3, 25) * 10000; // 3% - 25% for percentages
38 40
 

+ 3
- 0
database/migrations/2024_11_27_221657_create_bills_table.php 查看文件

@@ -22,6 +22,9 @@ return new class extends Migration
22 22
             $table->timestamp('paid_at')->nullable();
23 23
             $table->string('status')->default('unpaid');
24 24
             $table->string('currency_code')->nullable();
25
+            $table->string('discount_method')->default('per_line_item');
26
+            $table->string('discount_computation')->default('percentage');
27
+            $table->integer('discount_rate')->default(0);
25 28
             $table->integer('subtotal')->default(0);
26 29
             $table->integer('tax_total')->default(0);
27 30
             $table->integer('discount_total')->default(0);

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

@@ -27,6 +27,9 @@ return new class extends Migration
27 27
             $table->timestamp('last_sent')->nullable();
28 28
             $table->string('status')->default('draft');
29 29
             $table->string('currency_code')->nullable();
30
+            $table->string('discount_method')->default('per_line_item');
31
+            $table->string('discount_computation')->default('percentage');
32
+            $table->integer('discount_rate')->default(0);
30 33
             $table->integer('subtotal')->default(0);
31 34
             $table->integer('tax_total')->default(0);
32 35
             $table->integer('discount_total')->default(0);

+ 25
- 25
package-lock.json 查看文件

@@ -456,9 +456,9 @@
456 456
             }
457 457
         },
458 458
         "node_modules/@jridgewell/gen-mapping": {
459
-            "version": "0.3.5",
460
-            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz",
461
-            "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==",
459
+            "version": "0.3.8",
460
+            "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
461
+            "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
462 462
             "dev": true,
463 463
             "license": "MIT",
464 464
             "dependencies": {
@@ -1014,9 +1014,9 @@
1014 1014
             }
1015 1015
         },
1016 1016
         "node_modules/browserslist": {
1017
-            "version": "4.24.2",
1018
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz",
1019
-            "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==",
1017
+            "version": "4.24.3",
1018
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz",
1019
+            "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==",
1020 1020
             "dev": true,
1021 1021
             "funding": [
1022 1022
                 {
@@ -1034,9 +1034,9 @@
1034 1034
             ],
1035 1035
             "license": "MIT",
1036 1036
             "dependencies": {
1037
-                "caniuse-lite": "^1.0.30001669",
1038
-                "electron-to-chromium": "^1.5.41",
1039
-                "node-releases": "^2.0.18",
1037
+                "caniuse-lite": "^1.0.30001688",
1038
+                "electron-to-chromium": "^1.5.73",
1039
+                "node-releases": "^2.0.19",
1040 1040
                 "update-browserslist-db": "^1.1.1"
1041 1041
             },
1042 1042
             "bin": {
@@ -1057,9 +1057,9 @@
1057 1057
             }
1058 1058
         },
1059 1059
         "node_modules/caniuse-lite": {
1060
-            "version": "1.0.30001687",
1061
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001687.tgz",
1062
-            "integrity": "sha512-0S/FDhf4ZiqrTUiQ39dKeUjYRjkv7lOZU1Dgif2rIqrTzX/1wV2hfKu9TOm1IHkdSijfLswxTFzl/cvir+SLSQ==",
1060
+            "version": "1.0.30001688",
1061
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001688.tgz",
1062
+            "integrity": "sha512-Nmqpru91cuABu/DTCXbM2NSRHzM2uVHfPnhJ/1zEAJx/ILBRVmz3pzH4N7DZqbdG0gWClsCC05Oj0mJ/1AWMbA==",
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.71",
1222
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.71.tgz",
1223
-            "integrity": "sha512-dB68l59BI75W1BUGVTAEJy45CEVuEGy9qPVVQ8pnHyHMn36PLPPoE1mjLH+lo9rKulO3HC2OhbACI/8tCqJBcA==",
1221
+            "version": "1.5.73",
1222
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.73.tgz",
1223
+            "integrity": "sha512-8wGNxG9tAG5KhGd3eeA0o6ixhiNdgr0DcHWm85XPCphwZgD1lIEoi6t3VERayWao7SF7AAZTw6oARGJeVjH8Kg==",
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.15.1",
1491
-            "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
1492
-            "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
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==",
1493 1493
             "dev": true,
1494 1494
             "license": "MIT",
1495 1495
             "dependencies": {
@@ -1761,9 +1761,9 @@
1761 1761
             }
1762 1762
         },
1763 1763
         "node_modules/node-releases": {
1764
-            "version": "2.0.18",
1765
-            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz",
1766
-            "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==",
1764
+            "version": "2.0.19",
1765
+            "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
1766
+            "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
1767 1767
             "dev": true,
1768 1768
             "license": "MIT"
1769 1769
         },
@@ -2192,13 +2192,13 @@
2192 2192
             }
2193 2193
         },
2194 2194
         "node_modules/resolve": {
2195
-            "version": "1.22.8",
2196
-            "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
2197
-            "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
2195
+            "version": "1.22.9",
2196
+            "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.9.tgz",
2197
+            "integrity": "sha512-QxrmX1DzraFIi9PxdG5VkRfRwIgjwyud+z/iBwfRRrVmHc+P9Q7u2lSSpQ6bjr2gy5lrqIiU9vb6iAeGf2400A==",
2198 2198
             "dev": true,
2199 2199
             "license": "MIT",
2200 2200
             "dependencies": {
2201
-                "is-core-module": "^2.13.0",
2201
+                "is-core-module": "^2.16.0",
2202 2202
                 "path-parse": "^1.0.7",
2203 2203
                 "supports-preserve-symlinks-flag": "^1.0.0"
2204 2204
             },

+ 11
- 1
resources/views/components/company/invoice/container.blade.php 查看文件

@@ -1,5 +1,15 @@
1
+@props([
2
+    'preview' => false,
3
+])
4
+
1 5
 <div class="inv-container flex justify-center p-6">
2
-    <div class="inv-paper bg-[#ffffff] dark:bg-gray-800 rounded-sm shadow-xl w-[38.25rem] h-[49.5rem] overflow-hidden">
6
+    <div
7
+        @class([
8
+            'inv-paper bg-[#ffffff] dark:bg-gray-800 rounded-sm shadow-xl',
9
+            'w-full max-w-[820px] min-h-[1024px]' => $preview === false,
10
+            'w-[38.25rem] h-[49.5rem] overflow-hidden' => $preview === true,
11
+        ])
12
+    >
3 13
         {{ $slot }}
4 14
     </div>
5 15
 </div>

+ 1
- 1
resources/views/components/company/invoice/footer.blade.php 查看文件

@@ -1,3 +1,3 @@
1
-<footer {{ $attributes->class(['inv-footer text-xs text-gray-600 dark:text-gray-300']) }}>
1
+<footer {{ $attributes->class(['inv-footer text-xs text-gray-600 dark:text-gray-300 min-h-60']) }}>
2 2
     {{ $slot }}
3 3
 </footer>

+ 1
- 1
resources/views/filament/company/components/invoice-layouts/classic.blade.php 查看文件

@@ -13,7 +13,7 @@
13 13
     }
14 14
 </style>
15 15
 
16
-<x-company.invoice.container class="classic-template-container">
16
+<x-company.invoice.container class="classic-template-container" preview>
17 17
     <!-- Header Section -->
18 18
     <x-company.invoice.header class="default-template-header">
19 19
         <div class="w-2/3 text-left ml-6">

+ 1
- 1
resources/views/filament/company/components/invoice-layouts/default.blade.php 查看文件

@@ -13,7 +13,7 @@
13 13
     }
14 14
 </style>
15 15
 
16
-<x-company.invoice.container class="default-template-container">
16
+<x-company.invoice.container class="default-template-container" preview>
17 17
 
18 18
     <x-company.invoice.header class="default-template-header border-b-2 p-6 pb-4">
19 19
         <div class="w-2/3">

+ 1
- 1
resources/views/filament/company/components/invoice-layouts/modern.blade.php 查看文件

@@ -13,7 +13,7 @@
13 13
     }
14 14
 </style>
15 15
 
16
-<x-company.invoice.container class="modern-template-container">
16
+<x-company.invoice.container class="modern-template-container" preview>
17 17
 
18 18
     <!-- Colored Header with Logo -->
19 19
     <x-company.invoice.header class="bg-gray-800 h-20">

+ 169
- 0
resources/views/filament/company/resources/sales/invoices/components/invoice-view.blade.php 查看文件

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

+ 47
- 0
resources/views/filament/company/resources/sales/invoices/pages/view-invoice.blade.php 查看文件

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

+ 45
- 18
resources/views/filament/forms/components/bill-totals.blade.php 查看文件

@@ -1,34 +1,61 @@
1
-@use('App\Utilities\Currency\CurrencyAccessor')
2
-
3 1
 @php
2
+    use App\Enums\Accounting\DocumentDiscountMethod;
3
+    use App\Utilities\Currency\CurrencyAccessor;
4
+    use App\View\Models\BillTotalViewModel;
5
+
4 6
     $data = $this->form->getRawState();
5
-    $viewModel = new \App\View\Models\BillTotalViewModel($this->record, $data);
6
-    extract($viewModel->buildViewData(), \EXTR_SKIP);
7
+    $viewModel = new BillTotalViewModel($this->record, $data);
8
+    extract($viewModel->buildViewData(), EXTR_SKIP);
9
+
10
+    $discountMethod = DocumentDiscountMethod::parse($data['discount_method']);
11
+    $isPerDocumentDiscount = $discountMethod->isPerDocument();
7 12
 @endphp
8 13
 
9 14
 <div class="totals-summary w-full pr-14">
10 15
     <table class="w-full text-right table-fixed">
16
+        <colgroup>
17
+            <col class="w-[20%]"> {{-- Items --}}
18
+            <col class="w-[30%]"> {{-- Description --}}
19
+            <col class="w-[10%]"> {{-- Quantity --}}
20
+            <col class="w-[10%]"> {{-- Price --}}
21
+            <col class="w-[20%]"> {{-- Taxes --}}
22
+            <col class="w-[10%]"> {{-- Amount --}}
23
+        </colgroup>
11 24
         <tbody>
12 25
             <tr>
13
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Subtotal:</td>
14
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $subtotal }}</td>
15
-            </tr>
16
-            <tr>
17
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Taxes:</td>
18
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $taxTotal }}</td>
26
+                <td colspan="4"></td>
27
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Subtotal:</td>
28
+                <td class="text-sm pl-4 py-2 leading-6">{{ $subtotal }}</td>
19 29
             </tr>
20 30
             <tr>
21
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Discounts:</td>
22
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
31
+                <td colspan="4"></td>
32
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Taxes:</td>
33
+                <td class="text-sm pl-4 py-2 leading-6">{{ $taxTotal }}</td>
23 34
             </tr>
35
+            @if($isPerDocumentDiscount)
36
+                <tr>
37
+                    <td colspan="4" class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white text-right">Discount:</td>
38
+                    <td class="text-sm px-4 py-2">
39
+                        <div class="flex justify-between space-x-2">
40
+                            @foreach($getChildComponentContainer()->getComponents() as $component)
41
+                                <div class="flex-1">{{ $component }}</div>
42
+                            @endforeach
43
+                        </div>
44
+                    </td>
45
+                    <td class="text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
46
+                </tr>
47
+            @else
48
+                <tr>
49
+                    <td colspan="4"></td>
50
+                    <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Discounts:</td>
51
+                    <td class="text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
52
+                </tr>
53
+            @endif
24 54
             <tr class="font-semibold">
25
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Total:</td>
26
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $grandTotal }}</td>
55
+                <td colspan="4"></td>
56
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Total:</td>
57
+                <td class="text-sm pl-4 py-2 leading-6">{{ $grandTotal }}</td>
27 58
             </tr>
28 59
         </tbody>
29 60
     </table>
30 61
 </div>
31
-
32
-
33
-
34
-

+ 45
- 18
resources/views/filament/forms/components/invoice-totals.blade.php 查看文件

@@ -1,34 +1,61 @@
1
-@use('App\Utilities\Currency\CurrencyAccessor')
2
-
3 1
 @php
2
+    use App\Enums\Accounting\DocumentDiscountMethod;
3
+    use App\Utilities\Currency\CurrencyAccessor;
4
+    use App\View\Models\InvoiceTotalViewModel;
5
+
4 6
     $data = $this->form->getRawState();
5
-    $viewModel = new \App\View\Models\InvoiceTotalViewModel($this->record, $data);
6
-    extract($viewModel->buildViewData(), \EXTR_SKIP);
7
+    $viewModel = new InvoiceTotalViewModel($this->record, $data);
8
+    extract($viewModel->buildViewData(), EXTR_SKIP);
9
+
10
+    $discountMethod = DocumentDiscountMethod::parse($data['discount_method']);
11
+    $isPerDocumentDiscount = $discountMethod->isPerDocument();
7 12
 @endphp
8 13
 
9 14
 <div class="totals-summary w-full pr-14">
10 15
     <table class="w-full text-right table-fixed">
16
+        <colgroup>
17
+            <col class="w-[20%]"> {{-- Items --}}
18
+            <col class="w-[30%]"> {{-- Description --}}
19
+            <col class="w-[10%]"> {{-- Quantity --}}
20
+            <col class="w-[10%]"> {{-- Price --}}
21
+            <col class="w-[20%]"> {{-- Taxes --}}
22
+            <col class="w-[10%]"> {{-- Amount --}}
23
+        </colgroup>
11 24
         <tbody>
12 25
             <tr>
13
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Subtotal:</td>
14
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $subtotal }}</td>
15
-            </tr>
16
-            <tr>
17
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Taxes:</td>
18
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $taxTotal }}</td>
26
+                <td colspan="4"></td>
27
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Subtotal:</td>
28
+                <td class="text-sm pl-4 py-2 leading-6">{{ $subtotal }}</td>
19 29
             </tr>
20 30
             <tr>
21
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Discounts:</td>
22
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
31
+                <td colspan="4"></td>
32
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Taxes:</td>
33
+                <td class="text-sm pl-4 py-2 leading-6">{{ $taxTotal }}</td>
23 34
             </tr>
35
+            @if($isPerDocumentDiscount)
36
+                <tr>
37
+                    <td colspan="4" class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white text-right">Discount:</td>
38
+                    <td class="text-sm px-4 py-2">
39
+                        <div class="flex justify-between space-x-2">
40
+                            @foreach($getChildComponentContainer()->getComponents() as $component)
41
+                                <div class="flex-1">{{ $component }}</div>
42
+                            @endforeach
43
+                        </div>
44
+                    </td>
45
+                    <td class="text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
46
+                </tr>
47
+            @else
48
+                <tr>
49
+                    <td colspan="4"></td>
50
+                    <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Discounts:</td>
51
+                    <td class="text-sm pl-4 py-2 leading-6">({{ $discountTotal }})</td>
52
+                </tr>
53
+            @endif
24 54
             <tr class="font-semibold">
25
-                <td class="w-2/3 text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Total:</td>
26
-                <td class="w-1/3 text-sm pl-4 py-2 leading-6">{{ $grandTotal }}</td>
55
+                <td colspan="4"></td>
56
+                <td class="text-sm px-4 py-2 font-medium leading-6 text-gray-950 dark:text-white">Total:</td>
57
+                <td class="text-sm pl-4 py-2 leading-6">{{ $grandTotal }}</td>
27 58
             </tr>
28 59
         </tbody>
29 60
     </table>
30 61
 </div>
31
-
32
-
33
-
34
-

正在加载...
取消
保存