Browse Source

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

Development 3.x
3.x
Andrew Wallo 5 months ago
parent
commit
b359e9dde5
No account linked to committer's email address
30 changed files with 1142 additions and 533 deletions
  1. 2
    1
      app/Concerns/ManagesLineItems.php
  2. 13
    4
      app/DTO/DocumentDTO.php
  3. 4
    0
      app/Filament/Company/Clusters/Settings/Resources/DocumentDefaultResource.php
  4. 100
    81
      app/Filament/Company/Resources/Purchases/BillResource.php
  5. 2
    2
      app/Filament/Company/Resources/Sales/ClientResource.php
  6. 100
    81
      app/Filament/Company/Resources/Sales/EstimateResource.php
  7. 100
    81
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  8. 101
    82
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  9. 86
    2
      app/Filament/Forms/Components/CustomTableRepeater.php
  10. 1
    1
      app/Models/Accounting/Document.php
  11. 1
    0
      app/Models/Accounting/DocumentLineItem.php
  12. 3
    0
      app/Models/Setting/DocumentDefault.php
  13. 1
    10
      app/Providers/Filament/CompanyPanelProvider.php
  14. 5
    1
      app/Utilities/RateCalculator.php
  15. 113
    112
      composer.lock
  16. 27
    1
      database/factories/Accounting/BillFactory.php
  17. 15
    3
      database/factories/Accounting/DocumentLineItemFactory.php
  18. 27
    1
      database/factories/Accounting/EstimateFactory.php
  19. 27
    1
      database/factories/Accounting/InvoiceFactory.php
  20. 27
    1
      database/factories/Accounting/RecurringInvoiceFactory.php
  21. 28
    0
      database/migrations/2025_05_03_150845_add_discount_method_to_document_defaults_table.php
  22. 37
    0
      database/migrations/2025_05_03_152233_update_discount_method_defaults_on_document_tables.php
  23. 28
    0
      database/migrations/2025_05_05_211551_add_sort_order_to_document_line_items.php
  24. 20
    51
      resources/css/filament/company/form-fields.css
  25. 1
    1
      resources/views/components/company/document-template/container.blade.php
  26. 1
    1
      resources/views/filament/company/components/document-templates/default.blade.php
  27. 251
    0
      resources/views/filament/forms/components/custom-table-repeater.blade.php
  28. 7
    5
      resources/views/filament/infolists/components/document-templates/classic.blade.php
  29. 7
    5
      resources/views/filament/infolists/components/document-templates/default.blade.php
  30. 7
    5
      resources/views/filament/infolists/components/document-templates/modern.blade.php

+ 2
- 1
app/Concerns/ManagesLineItems.php View File

16
 {
16
 {
17
     protected function handleLineItems(Model $record, Collection $lineItems): void
17
     protected function handleLineItems(Model $record, Collection $lineItems): void
18
     {
18
     {
19
-        foreach ($lineItems as $itemData) {
19
+        foreach ($lineItems as $index => $itemData) {
20
             $lineItem = isset($itemData['id'])
20
             $lineItem = isset($itemData['id'])
21
                 ? $record->lineItems->find($itemData['id'])
21
                 ? $record->lineItems->find($itemData['id'])
22
                 : $record->lineItems()->make();
22
                 : $record->lineItems()->make();
26
                 'description' => $itemData['description'],
26
                 'description' => $itemData['description'],
27
                 'quantity' => $itemData['quantity'],
27
                 'quantity' => $itemData['quantity'],
28
                 'unit_price' => $itemData['unit_price'],
28
                 'unit_price' => $itemData['unit_price'],
29
+                'line_number' => $index + 1,
29
             ]);
30
             ]);
30
 
31
 
31
             if (! $lineItem->exists) {
32
             if (! $lineItem->exists) {

+ 13
- 4
app/DTO/DocumentDTO.php View File

26
         public string $date,
26
         public string $date,
27
         public string $dueDate,
27
         public string $dueDate,
28
         public string $currencyCode,
28
         public string $currencyCode,
29
-        public string $subtotal,
29
+        public ?string $subtotal,
30
         public ?string $discount,
30
         public ?string $discount,
31
         public ?string $tax,
31
         public ?string $tax,
32
         public string $total,
32
         public string $total,
50
 
50
 
51
         $currencyCode = $document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
51
         $currencyCode = $document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
52
 
52
 
53
+        $discount = $document->discount_total > 0 ? self::formatToMoney($document->discount_total, $currencyCode) : null;
54
+        $tax = $document->tax_total > 0 ? self::formatToMoney($document->tax_total, $currencyCode) : null;
55
+
56
+        if (! $discount && ! $tax) {
57
+            $subtotal = null;
58
+        } else {
59
+            $subtotal = self::formatToMoney($document->subtotal, $currencyCode);
60
+        }
61
+
53
         return new self(
62
         return new self(
54
             header: $document->header,
63
             header: $document->header,
55
             subheader: $document->subheader,
64
             subheader: $document->subheader,
61
             date: $document->documentDate(),
70
             date: $document->documentDate(),
62
             dueDate: $document->dueDate(),
71
             dueDate: $document->dueDate(),
63
             currencyCode: $currencyCode,
72
             currencyCode: $currencyCode,
64
-            subtotal: self::formatToMoney($document->subtotal, $currencyCode),
65
-            discount: self::formatToMoney($document->discount_total, $currencyCode),
66
-            tax: self::formatToMoney($document->tax_total, $currencyCode),
73
+            subtotal: $subtotal,
74
+            discount: $discount,
75
+            tax: $tax,
67
             total: self::formatToMoney($document->total, $currencyCode),
76
             total: self::formatToMoney($document->total, $currencyCode),
68
             amountDue: self::formatToMoney($document->amountDue(), $currencyCode),
77
             amountDue: self::formatToMoney($document->amountDue(), $currencyCode),
69
             company: CompanyDTO::fromModel($document->company),
78
             company: CompanyDTO::fromModel($document->company),

+ 4
- 0
app/Filament/Company/Clusters/Settings/Resources/DocumentDefaultResource.php View File

2
 
2
 
3
 namespace App\Filament\Company\Clusters\Settings\Resources;
3
 namespace App\Filament\Company\Clusters\Settings\Resources;
4
 
4
 
5
+use App\Enums\Accounting\DocumentDiscountMethod;
5
 use App\Enums\Accounting\DocumentType;
6
 use App\Enums\Accounting\DocumentType;
6
 use App\Enums\Setting\Font;
7
 use App\Enums\Setting\Font;
7
 use App\Enums\Setting\PaymentTerms;
8
 use App\Enums\Setting\PaymentTerms;
51
                     ->softRequired()
52
                     ->softRequired()
52
                     ->localizeLabel()
53
                     ->localizeLabel()
53
                     ->options(PaymentTerms::class),
54
                     ->options(PaymentTerms::class),
55
+                Forms\Components\Select::make('discount_method')
56
+                    ->softRequired()
57
+                    ->options(DocumentDiscountMethod::class),
54
             ])->columns();
58
             ])->columns();
55
     }
59
     }
56
 
60
 

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

16
 use App\Filament\Forms\Components\CreateCurrencySelect;
16
 use App\Filament\Forms\Components\CreateCurrencySelect;
17
 use App\Filament\Forms\Components\CreateOfferingSelect;
17
 use App\Filament\Forms\Components\CreateOfferingSelect;
18
 use App\Filament\Forms\Components\CreateVendorSelect;
18
 use App\Filament\Forms\Components\CreateVendorSelect;
19
+use App\Filament\Forms\Components\CustomTableRepeater;
19
 use App\Filament\Forms\Components\DocumentTotals;
20
 use App\Filament\Forms\Components\DocumentTotals;
20
 use App\Filament\Tables\Actions\ReplicateBulkAction;
21
 use App\Filament\Tables\Actions\ReplicateBulkAction;
21
 use App\Filament\Tables\Columns;
22
 use App\Filament\Tables\Columns;
29
 use App\Utilities\Currency\CurrencyAccessor;
30
 use App\Utilities\Currency\CurrencyAccessor;
30
 use App\Utilities\Currency\CurrencyConverter;
31
 use App\Utilities\Currency\CurrencyConverter;
31
 use App\Utilities\RateCalculator;
32
 use App\Utilities\RateCalculator;
32
-use Awcodes\TableRepeater\Components\TableRepeater;
33
 use Awcodes\TableRepeater\Header;
33
 use Awcodes\TableRepeater\Header;
34
 use Closure;
34
 use Closure;
35
 use Filament\Forms;
35
 use Filament\Forms;
166
                                 Forms\Components\Select::make('discount_method')
166
                                 Forms\Components\Select::make('discount_method')
167
                                     ->label('Discount method')
167
                                     ->label('Discount method')
168
                                     ->options(DocumentDiscountMethod::class)
168
                                     ->options(DocumentDiscountMethod::class)
169
-                                    ->selectablePlaceholder(false)
170
-                                    ->default(DocumentDiscountMethod::PerLineItem)
169
+                                    ->softRequired()
170
+                                    ->default($settings->discount_method)
171
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
171
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
172
                                         $discountMethod = DocumentDiscountMethod::parse($state);
172
                                         $discountMethod = DocumentDiscountMethod::parse($state);
173
 
173
 
178
                                     ->live(),
178
                                     ->live(),
179
                             ])->grow(true),
179
                             ])->grow(true),
180
                         ])->from('md'),
180
                         ])->from('md'),
181
-                        TableRepeater::make('lineItems')
181
+                        CustomTableRepeater::make('lineItems')
182
+                            ->hiddenLabel()
182
                             ->relationship()
183
                             ->relationship()
183
                             ->saveRelationshipsUsing(null)
184
                             ->saveRelationshipsUsing(null)
184
                             ->dehydrated(true)
185
                             ->dehydrated(true)
186
+                            ->reorderable()
187
+                            ->orderColumn('line_number')
188
+                            ->reorderAtStart()
189
+                            ->cloneable()
190
+                            ->addActionLabel('Add an item')
185
                             ->headers(function (Forms\Get $get) use ($settings) {
191
                             ->headers(function (Forms\Get $get) use ($settings) {
186
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
192
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
187
 
193
 
188
                                 $headers = [
194
                                 $headers = [
189
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
195
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
190
-                                        ->width($hasDiscounts ? '15%' : '20%'),
191
-                                    Header::make('Description')
192
-                                        ->width($hasDiscounts ? '15%' : '20%'),
196
+                                        ->width('30%'),
193
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
197
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
194
                                         ->width('10%'),
198
                                         ->width('10%'),
195
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
199
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
196
                                         ->width('10%'),
200
                                         ->width('10%'),
197
-                                    Header::make('Taxes')
198
-                                        ->width($hasDiscounts ? '20%' : '30%'),
199
                                 ];
201
                                 ];
200
 
202
 
201
                                 if ($hasDiscounts) {
203
                                 if ($hasDiscounts) {
202
-                                    $headers[] = Header::make('Discounts')->width('20%');
204
+                                    $headers[] = Header::make('Adjustments')->width('30%');
205
+                                } else {
206
+                                    $headers[] = Header::make('Taxes')->width('30%');
203
                                 }
207
                                 }
204
 
208
 
205
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
209
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
209
                                 return $headers;
213
                                 return $headers;
210
                             })
214
                             })
211
                             ->schema([
215
                             ->schema([
212
-                                CreateOfferingSelect::make('offering_id')
213
-                                    ->label('Item')
214
-                                    ->required()
215
-                                    ->live()
216
-                                    ->purchasable()
217
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
218
-                                        $offeringId = $state;
219
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
220
-                                        $isPerLineItem = $discountMethod->isPerLineItem();
216
+                                Forms\Components\Group::make([
217
+                                    CreateOfferingSelect::make('offering_id')
218
+                                        ->label('Item')
219
+                                        ->hiddenLabel()
220
+                                        ->placeholder('Select item')
221
+                                        ->required()
222
+                                        ->live()
223
+                                        ->inlineSuffix()
224
+                                        ->purchasable()
225
+                                        ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
226
+                                            $offeringId = $state;
227
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
228
+                                            $isPerLineItem = $discountMethod->isPerLineItem();
229
+
230
+                                            $existingTaxIds = [];
231
+                                            $existingDiscountIds = [];
232
+
233
+                                            if ($record) {
234
+                                                $existingTaxIds = $record->purchaseTaxes()->pluck('adjustments.id')->toArray();
235
+                                                if ($isPerLineItem) {
236
+                                                    $existingDiscountIds = $record->purchaseDiscounts()->pluck('adjustments.id')->toArray();
237
+                                                }
238
+                                            }
221
 
239
 
222
-                                        $existingTaxIds = [];
223
-                                        $existingDiscountIds = [];
240
+                                            $with = [
241
+                                                'purchaseTaxes' => static function ($query) use ($existingTaxIds) {
242
+                                                    $query->where(static function ($query) use ($existingTaxIds) {
243
+                                                        $query->where('status', AdjustmentStatus::Active)
244
+                                                            ->orWhereIn('adjustments.id', $existingTaxIds);
245
+                                                    });
246
+                                                },
247
+                                            ];
224
 
248
 
225
-                                        if ($record) {
226
-                                            $existingTaxIds = $record->purchaseTaxes()->pluck('adjustments.id')->toArray();
227
                                             if ($isPerLineItem) {
249
                                             if ($isPerLineItem) {
228
-                                                $existingDiscountIds = $record->purchaseDiscounts()->pluck('adjustments.id')->toArray();
250
+                                                $with['purchaseDiscounts'] = static function ($query) use ($existingDiscountIds) {
251
+                                                    $query->where(static function ($query) use ($existingDiscountIds) {
252
+                                                        $query->where('status', AdjustmentStatus::Active)
253
+                                                            ->orWhereIn('adjustments.id', $existingDiscountIds);
254
+                                                    });
255
+                                                };
229
                                             }
256
                                             }
230
-                                        }
231
 
257
 
232
-                                        $with = [
233
-                                            'purchaseTaxes' => static function ($query) use ($existingTaxIds) {
234
-                                                $query->where(static function ($query) use ($existingTaxIds) {
235
-                                                    $query->where('status', AdjustmentStatus::Active)
236
-                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
237
-                                                });
238
-                                            },
239
-                                        ];
240
-
241
-                                        if ($isPerLineItem) {
242
-                                            $with['purchaseDiscounts'] = static function ($query) use ($existingDiscountIds) {
243
-                                                $query->where(static function ($query) use ($existingDiscountIds) {
244
-                                                    $query->where('status', AdjustmentStatus::Active)
245
-                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
246
-                                                });
247
-                                            };
248
-                                        }
258
+                                            $offeringRecord = Offering::with($with)->find($offeringId);
249
 
259
 
250
-                                        $offeringRecord = Offering::with($with)->find($offeringId);
251
-
252
-                                        if (! $offeringRecord) {
253
-                                            return;
254
-                                        }
260
+                                            if (! $offeringRecord) {
261
+                                                return;
262
+                                            }
255
 
263
 
256
-                                        $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
264
+                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
257
 
265
 
258
-                                        $set('description', $offeringRecord->description);
259
-                                        $set('unit_price', $unitPrice);
260
-                                        $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
266
+                                            $set('description', $offeringRecord->description);
267
+                                            $set('unit_price', $unitPrice);
268
+                                            $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
261
 
269
 
262
-                                        if ($isPerLineItem) {
263
-                                            $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
264
-                                        }
265
-                                    }),
266
-                                Forms\Components\TextInput::make('description'),
270
+                                            if ($isPerLineItem) {
271
+                                                $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
272
+                                            }
273
+                                        }),
274
+                                    Forms\Components\TextInput::make('description')
275
+                                        ->placeholder('Enter item description')
276
+                                        ->hiddenLabel(),
277
+                                ])->columnSpan(1),
267
                                 Forms\Components\TextInput::make('quantity')
278
                                 Forms\Components\TextInput::make('quantity')
268
                                     ->required()
279
                                     ->required()
269
                                     ->numeric()
280
                                     ->numeric()
277
                                     ->live()
288
                                     ->live()
278
                                     ->maxValue(9999999999.99)
289
                                     ->maxValue(9999999999.99)
279
                                     ->default(0),
290
                                     ->default(0),
280
-                                CreateAdjustmentSelect::make('purchaseTaxes')
281
-                                    ->label('Taxes')
282
-                                    ->category(AdjustmentCategory::Tax)
283
-                                    ->type(AdjustmentType::Purchase)
284
-                                    ->adjustmentsRelationship('purchaseTaxes')
285
-                                    ->saveRelationshipsUsing(null)
286
-                                    ->dehydrated(true)
287
-                                    ->preload()
288
-                                    ->multiple()
289
-                                    ->live()
290
-                                    ->searchable(),
291
-                                CreateAdjustmentSelect::make('purchaseDiscounts')
292
-                                    ->label('Discounts')
293
-                                    ->category(AdjustmentCategory::Discount)
294
-                                    ->type(AdjustmentType::Purchase)
295
-                                    ->adjustmentsRelationship('purchaseDiscounts')
296
-                                    ->saveRelationshipsUsing(null)
297
-                                    ->dehydrated(true)
298
-                                    ->multiple()
299
-                                    ->live()
300
-                                    ->hidden(function (Forms\Get $get) {
301
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
291
+                                Forms\Components\Group::make([
292
+                                    CreateAdjustmentSelect::make('purchaseTaxes')
293
+                                        ->label('Taxes')
294
+                                        ->hiddenLabel()
295
+                                        ->placeholder('Select taxes')
296
+                                        ->category(AdjustmentCategory::Tax)
297
+                                        ->type(AdjustmentType::Purchase)
298
+                                        ->adjustmentsRelationship('purchaseTaxes')
299
+                                        ->saveRelationshipsUsing(null)
300
+                                        ->dehydrated(true)
301
+                                        ->inlineSuffix()
302
+                                        ->preload()
303
+                                        ->multiple()
304
+                                        ->live()
305
+                                        ->searchable(),
306
+                                    CreateAdjustmentSelect::make('purchaseDiscounts')
307
+                                        ->label('Discounts')
308
+                                        ->hiddenLabel()
309
+                                        ->placeholder('Select discounts')
310
+                                        ->category(AdjustmentCategory::Discount)
311
+                                        ->type(AdjustmentType::Purchase)
312
+                                        ->adjustmentsRelationship('purchaseDiscounts')
313
+                                        ->saveRelationshipsUsing(null)
314
+                                        ->dehydrated(true)
315
+                                        ->inlineSuffix()
316
+                                        ->multiple()
317
+                                        ->live()
318
+                                        ->hidden(function (Forms\Get $get) {
319
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
302
 
320
 
303
-                                        return $discountMethod->isPerDocument();
304
-                                    })
305
-                                    ->searchable(),
321
+                                            return $discountMethod->isPerDocument();
322
+                                        })
323
+                                        ->searchable(),
324
+                                ])->columnSpan(1),
306
                                 Forms\Components\Placeholder::make('total')
325
                                 Forms\Components\Placeholder::make('total')
307
                                     ->hiddenLabel()
326
                                     ->hiddenLabel()
308
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
327
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])

+ 2
- 2
app/Filament/Company/Resources/Sales/ClientResource.php View File

252
                 Tables\Columns\TextColumn::make('name')
252
                 Tables\Columns\TextColumn::make('name')
253
                     ->searchable()
253
                     ->searchable()
254
                     ->sortable()
254
                     ->sortable()
255
-                    ->description(static fn (Client $client) => $client->primaryContact->full_name),
255
+                    ->description(static fn (Client $client) => $client->primaryContact?->full_name),
256
                 Tables\Columns\TextColumn::make('primaryContact.email')
256
                 Tables\Columns\TextColumn::make('primaryContact.email')
257
                     ->label('Email')
257
                     ->label('Email')
258
                     ->searchable()
258
                     ->searchable()
260
                 Tables\Columns\TextColumn::make('primaryContact.phones')
260
                 Tables\Columns\TextColumn::make('primaryContact.phones')
261
                     ->label('Phone')
261
                     ->label('Phone')
262
                     ->toggleable()
262
                     ->toggleable()
263
-                    ->state(static fn (Client $client) => $client->primaryContact->first_available_phone),
263
+                    ->state(static fn (Client $client) => $client->primaryContact?->first_available_phone),
264
                 Tables\Columns\TextColumn::make('billingAddress.address_string')
264
                 Tables\Columns\TextColumn::make('billingAddress.address_string')
265
                     ->label('Billing address')
265
                     ->label('Billing address')
266
                     ->searchable()
266
                     ->searchable()

+ 100
- 81
app/Filament/Company/Resources/Sales/EstimateResource.php View File

16
 use App\Filament\Forms\Components\CreateClientSelect;
16
 use App\Filament\Forms\Components\CreateClientSelect;
17
 use App\Filament\Forms\Components\CreateCurrencySelect;
17
 use App\Filament\Forms\Components\CreateCurrencySelect;
18
 use App\Filament\Forms\Components\CreateOfferingSelect;
18
 use App\Filament\Forms\Components\CreateOfferingSelect;
19
+use App\Filament\Forms\Components\CustomTableRepeater;
19
 use App\Filament\Forms\Components\DocumentFooterSection;
20
 use App\Filament\Forms\Components\DocumentFooterSection;
20
 use App\Filament\Forms\Components\DocumentHeaderSection;
21
 use App\Filament\Forms\Components\DocumentHeaderSection;
21
 use App\Filament\Forms\Components\DocumentTotals;
22
 use App\Filament\Forms\Components\DocumentTotals;
30
 use App\Utilities\Currency\CurrencyAccessor;
31
 use App\Utilities\Currency\CurrencyAccessor;
31
 use App\Utilities\Currency\CurrencyConverter;
32
 use App\Utilities\Currency\CurrencyConverter;
32
 use App\Utilities\RateCalculator;
33
 use App\Utilities\RateCalculator;
33
-use Awcodes\TableRepeater\Components\TableRepeater;
34
 use Awcodes\TableRepeater\Header;
34
 use Awcodes\TableRepeater\Header;
35
 use Filament\Forms;
35
 use Filament\Forms;
36
 use Filament\Forms\Form;
36
 use Filament\Forms\Form;
164
                                 Forms\Components\Select::make('discount_method')
164
                                 Forms\Components\Select::make('discount_method')
165
                                     ->label('Discount method')
165
                                     ->label('Discount method')
166
                                     ->options(DocumentDiscountMethod::class)
166
                                     ->options(DocumentDiscountMethod::class)
167
-                                    ->selectablePlaceholder(false)
168
-                                    ->default(DocumentDiscountMethod::PerLineItem)
167
+                                    ->softRequired()
168
+                                    ->default($settings->discount_method)
169
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
169
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
170
                                         $discountMethod = DocumentDiscountMethod::parse($state);
170
                                         $discountMethod = DocumentDiscountMethod::parse($state);
171
 
171
 
176
                                     ->live(),
176
                                     ->live(),
177
                             ])->grow(true),
177
                             ])->grow(true),
178
                         ])->from('md'),
178
                         ])->from('md'),
179
-                        TableRepeater::make('lineItems')
179
+                        CustomTableRepeater::make('lineItems')
180
+                            ->hiddenLabel()
180
                             ->relationship()
181
                             ->relationship()
181
                             ->saveRelationshipsUsing(null)
182
                             ->saveRelationshipsUsing(null)
182
                             ->dehydrated(true)
183
                             ->dehydrated(true)
184
+                            ->reorderable()
185
+                            ->orderColumn('line_number')
186
+                            ->reorderAtStart()
187
+                            ->cloneable()
188
+                            ->addActionLabel('Add an item')
183
                             ->headers(function (Forms\Get $get) use ($settings) {
189
                             ->headers(function (Forms\Get $get) use ($settings) {
184
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
190
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
185
 
191
 
186
                                 $headers = [
192
                                 $headers = [
187
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
193
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
188
-                                        ->width($hasDiscounts ? '15%' : '20%'),
189
-                                    Header::make('Description')
190
-                                        ->width($hasDiscounts ? '15%' : '20%'),
194
+                                        ->width('30%'),
191
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
195
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
192
                                         ->width('10%'),
196
                                         ->width('10%'),
193
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
197
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
194
                                         ->width('10%'),
198
                                         ->width('10%'),
195
-                                    Header::make('Taxes')
196
-                                        ->width($hasDiscounts ? '20%' : '30%'),
197
                                 ];
199
                                 ];
198
 
200
 
199
                                 if ($hasDiscounts) {
201
                                 if ($hasDiscounts) {
200
-                                    $headers[] = Header::make('Discounts')->width('20%');
202
+                                    $headers[] = Header::make('Adjustments')->width('30%');
203
+                                } else {
204
+                                    $headers[] = Header::make('Taxes')->width('30%');
201
                                 }
205
                                 }
202
 
206
 
203
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
207
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
207
                                 return $headers;
211
                                 return $headers;
208
                             })
212
                             })
209
                             ->schema([
213
                             ->schema([
210
-                                CreateOfferingSelect::make('offering_id')
211
-                                    ->label('Item')
212
-                                    ->required()
213
-                                    ->live()
214
-                                    ->sellable()
215
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
216
-                                        $offeringId = $state;
217
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
218
-                                        $isPerLineItem = $discountMethod->isPerLineItem();
214
+                                Forms\Components\Group::make([
215
+                                    CreateOfferingSelect::make('offering_id')
216
+                                        ->label('Item')
217
+                                        ->hiddenLabel()
218
+                                        ->placeholder('Select item')
219
+                                        ->required()
220
+                                        ->live()
221
+                                        ->inlineSuffix()
222
+                                        ->sellable()
223
+                                        ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
224
+                                            $offeringId = $state;
225
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
226
+                                            $isPerLineItem = $discountMethod->isPerLineItem();
227
+
228
+                                            $existingTaxIds = [];
229
+                                            $existingDiscountIds = [];
230
+
231
+                                            if ($record) {
232
+                                                $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
233
+                                                if ($isPerLineItem) {
234
+                                                    $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
235
+                                                }
236
+                                            }
219
 
237
 
220
-                                        $existingTaxIds = [];
221
-                                        $existingDiscountIds = [];
238
+                                            $with = [
239
+                                                'salesTaxes' => static function ($query) use ($existingTaxIds) {
240
+                                                    $query->where(static function ($query) use ($existingTaxIds) {
241
+                                                        $query->where('status', AdjustmentStatus::Active)
242
+                                                            ->orWhereIn('adjustments.id', $existingTaxIds);
243
+                                                    });
244
+                                                },
245
+                                            ];
222
 
246
 
223
-                                        if ($record) {
224
-                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
225
                                             if ($isPerLineItem) {
247
                                             if ($isPerLineItem) {
226
-                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
248
+                                                $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
249
+                                                    $query->where(static function ($query) use ($existingDiscountIds) {
250
+                                                        $query->where('status', AdjustmentStatus::Active)
251
+                                                            ->orWhereIn('adjustments.id', $existingDiscountIds);
252
+                                                    });
253
+                                                };
227
                                             }
254
                                             }
228
-                                        }
229
 
255
 
230
-                                        $with = [
231
-                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
232
-                                                $query->where(static function ($query) use ($existingTaxIds) {
233
-                                                    $query->where('status', AdjustmentStatus::Active)
234
-                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
235
-                                                });
236
-                                            },
237
-                                        ];
238
-
239
-                                        if ($isPerLineItem) {
240
-                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
241
-                                                $query->where(static function ($query) use ($existingDiscountIds) {
242
-                                                    $query->where('status', AdjustmentStatus::Active)
243
-                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
244
-                                                });
245
-                                            };
246
-                                        }
247
-
248
-                                        $offeringRecord = Offering::with($with)->find($offeringId);
256
+                                            $offeringRecord = Offering::with($with)->find($offeringId);
249
 
257
 
250
-                                        if (! $offeringRecord) {
251
-                                            return;
252
-                                        }
258
+                                            if (! $offeringRecord) {
259
+                                                return;
260
+                                            }
253
 
261
 
254
-                                        $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
262
+                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
255
 
263
 
256
-                                        $set('description', $offeringRecord->description);
257
-                                        $set('unit_price', $unitPrice);
258
-                                        $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
264
+                                            $set('description', $offeringRecord->description);
265
+                                            $set('unit_price', $unitPrice);
266
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
259
 
267
 
260
-                                        if ($isPerLineItem) {
261
-                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
262
-                                        }
263
-                                    }),
264
-                                Forms\Components\TextInput::make('description'),
268
+                                            if ($isPerLineItem) {
269
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
270
+                                            }
271
+                                        }),
272
+                                    Forms\Components\TextInput::make('description')
273
+                                        ->placeholder('Enter item description')
274
+                                        ->hiddenLabel(),
275
+                                ])->columnSpan(1),
265
                                 Forms\Components\TextInput::make('quantity')
276
                                 Forms\Components\TextInput::make('quantity')
266
                                     ->required()
277
                                     ->required()
267
                                     ->numeric()
278
                                     ->numeric()
274
                                     ->live()
285
                                     ->live()
275
                                     ->maxValue(9999999999.99)
286
                                     ->maxValue(9999999999.99)
276
                                     ->default(0),
287
                                     ->default(0),
277
-                                CreateAdjustmentSelect::make('salesTaxes')
278
-                                    ->label('Taxes')
279
-                                    ->category(AdjustmentCategory::Tax)
280
-                                    ->type(AdjustmentType::Sales)
281
-                                    ->adjustmentsRelationship('salesTaxes')
282
-                                    ->saveRelationshipsUsing(null)
283
-                                    ->dehydrated(true)
284
-                                    ->preload()
285
-                                    ->multiple()
286
-                                    ->live()
287
-                                    ->searchable(),
288
-                                CreateAdjustmentSelect::make('salesDiscounts')
289
-                                    ->label('Discounts')
290
-                                    ->category(AdjustmentCategory::Discount)
291
-                                    ->type(AdjustmentType::Sales)
292
-                                    ->adjustmentsRelationship('salesDiscounts')
293
-                                    ->saveRelationshipsUsing(null)
294
-                                    ->dehydrated(true)
295
-                                    ->multiple()
296
-                                    ->live()
297
-                                    ->hidden(function (Forms\Get $get) {
298
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
288
+                                Forms\Components\Group::make([
289
+                                    CreateAdjustmentSelect::make('salesTaxes')
290
+                                        ->label('Taxes')
291
+                                        ->hiddenLabel()
292
+                                        ->placeholder('Select taxes')
293
+                                        ->category(AdjustmentCategory::Tax)
294
+                                        ->type(AdjustmentType::Sales)
295
+                                        ->adjustmentsRelationship('salesTaxes')
296
+                                        ->saveRelationshipsUsing(null)
297
+                                        ->dehydrated(true)
298
+                                        ->inlineSuffix()
299
+                                        ->preload()
300
+                                        ->multiple()
301
+                                        ->live()
302
+                                        ->searchable(),
303
+                                    CreateAdjustmentSelect::make('salesDiscounts')
304
+                                        ->label('Discounts')
305
+                                        ->hiddenLabel()
306
+                                        ->placeholder('Select discounts')
307
+                                        ->category(AdjustmentCategory::Discount)
308
+                                        ->type(AdjustmentType::Sales)
309
+                                        ->adjustmentsRelationship('salesDiscounts')
310
+                                        ->saveRelationshipsUsing(null)
311
+                                        ->dehydrated(true)
312
+                                        ->inlineSuffix()
313
+                                        ->multiple()
314
+                                        ->live()
315
+                                        ->hidden(function (Forms\Get $get) {
316
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
299
 
317
 
300
-                                        return $discountMethod->isPerDocument();
301
-                                    })
302
-                                    ->searchable(),
318
+                                            return $discountMethod->isPerDocument();
319
+                                        })
320
+                                        ->searchable(),
321
+                                ])->columnSpan(1),
303
                                 Forms\Components\Placeholder::make('total')
322
                                 Forms\Components\Placeholder::make('total')
304
                                     ->hiddenLabel()
323
                                     ->hiddenLabel()
305
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
324
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])

+ 100
- 81
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

18
 use App\Filament\Forms\Components\CreateClientSelect;
18
 use App\Filament\Forms\Components\CreateClientSelect;
19
 use App\Filament\Forms\Components\CreateCurrencySelect;
19
 use App\Filament\Forms\Components\CreateCurrencySelect;
20
 use App\Filament\Forms\Components\CreateOfferingSelect;
20
 use App\Filament\Forms\Components\CreateOfferingSelect;
21
+use App\Filament\Forms\Components\CustomTableRepeater;
21
 use App\Filament\Forms\Components\DocumentFooterSection;
22
 use App\Filament\Forms\Components\DocumentFooterSection;
22
 use App\Filament\Forms\Components\DocumentHeaderSection;
23
 use App\Filament\Forms\Components\DocumentHeaderSection;
23
 use App\Filament\Forms\Components\DocumentTotals;
24
 use App\Filament\Forms\Components\DocumentTotals;
33
 use App\Utilities\Currency\CurrencyAccessor;
34
 use App\Utilities\Currency\CurrencyAccessor;
34
 use App\Utilities\Currency\CurrencyConverter;
35
 use App\Utilities\Currency\CurrencyConverter;
35
 use App\Utilities\RateCalculator;
36
 use App\Utilities\RateCalculator;
36
-use Awcodes\TableRepeater\Components\TableRepeater;
37
 use Awcodes\TableRepeater\Header;
37
 use Awcodes\TableRepeater\Header;
38
 use Closure;
38
 use Closure;
39
 use Filament\Forms;
39
 use Filament\Forms;
177
                                 Forms\Components\Select::make('discount_method')
177
                                 Forms\Components\Select::make('discount_method')
178
                                     ->label('Discount method')
178
                                     ->label('Discount method')
179
                                     ->options(DocumentDiscountMethod::class)
179
                                     ->options(DocumentDiscountMethod::class)
180
-                                    ->selectablePlaceholder(false)
181
-                                    ->default(DocumentDiscountMethod::PerLineItem)
180
+                                    ->softRequired()
181
+                                    ->default($settings->discount_method)
182
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
182
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
183
                                         $discountMethod = DocumentDiscountMethod::parse($state);
183
                                         $discountMethod = DocumentDiscountMethod::parse($state);
184
 
184
 
189
                                     ->live(),
189
                                     ->live(),
190
                             ])->grow(true),
190
                             ])->grow(true),
191
                         ])->from('md'),
191
                         ])->from('md'),
192
-                        TableRepeater::make('lineItems')
192
+                        CustomTableRepeater::make('lineItems')
193
+                            ->hiddenLabel()
193
                             ->relationship()
194
                             ->relationship()
194
                             ->saveRelationshipsUsing(null)
195
                             ->saveRelationshipsUsing(null)
195
                             ->dehydrated(true)
196
                             ->dehydrated(true)
197
+                            ->reorderable()
198
+                            ->orderColumn('line_number')
199
+                            ->reorderAtStart()
200
+                            ->cloneable()
201
+                            ->addActionLabel('Add an item')
196
                             ->headers(function (Forms\Get $get) use ($settings) {
202
                             ->headers(function (Forms\Get $get) use ($settings) {
197
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
203
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
198
 
204
 
199
                                 $headers = [
205
                                 $headers = [
200
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
206
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
201
-                                        ->width($hasDiscounts ? '15%' : '20%'),
202
-                                    Header::make('Description')
203
-                                        ->width($hasDiscounts ? '15%' : '20%'),
207
+                                        ->width('30%'),
204
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
208
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
205
                                         ->width('10%'),
209
                                         ->width('10%'),
206
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
210
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
207
                                         ->width('10%'),
211
                                         ->width('10%'),
208
-                                    Header::make('Taxes')
209
-                                        ->width($hasDiscounts ? '20%' : '30%'),
210
                                 ];
212
                                 ];
211
 
213
 
212
                                 if ($hasDiscounts) {
214
                                 if ($hasDiscounts) {
213
-                                    $headers[] = Header::make('Discounts')->width('20%');
215
+                                    $headers[] = Header::make('Adjustments')->width('30%');
216
+                                } else {
217
+                                    $headers[] = Header::make('Taxes')->width('30%');
214
                                 }
218
                                 }
215
 
219
 
216
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
220
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
220
                                 return $headers;
224
                                 return $headers;
221
                             })
225
                             })
222
                             ->schema([
226
                             ->schema([
223
-                                CreateOfferingSelect::make('offering_id')
224
-                                    ->label('Item')
225
-                                    ->required()
226
-                                    ->live()
227
-                                    ->sellable()
228
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
229
-                                        $offeringId = $state;
230
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
231
-                                        $isPerLineItem = $discountMethod->isPerLineItem();
227
+                                Forms\Components\Group::make([
228
+                                    CreateOfferingSelect::make('offering_id')
229
+                                        ->label('Item')
230
+                                        ->hiddenLabel()
231
+                                        ->placeholder('Select item')
232
+                                        ->required()
233
+                                        ->live()
234
+                                        ->inlineSuffix()
235
+                                        ->sellable()
236
+                                        ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
237
+                                            $offeringId = $state;
238
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
239
+                                            $isPerLineItem = $discountMethod->isPerLineItem();
240
+
241
+                                            $existingTaxIds = [];
242
+                                            $existingDiscountIds = [];
243
+
244
+                                            if ($record) {
245
+                                                $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
246
+                                                if ($isPerLineItem) {
247
+                                                    $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
248
+                                                }
249
+                                            }
232
 
250
 
233
-                                        $existingTaxIds = [];
234
-                                        $existingDiscountIds = [];
251
+                                            $with = [
252
+                                                'salesTaxes' => static function ($query) use ($existingTaxIds) {
253
+                                                    $query->where(static function ($query) use ($existingTaxIds) {
254
+                                                        $query->where('status', AdjustmentStatus::Active)
255
+                                                            ->orWhereIn('adjustments.id', $existingTaxIds);
256
+                                                    });
257
+                                                },
258
+                                            ];
235
 
259
 
236
-                                        if ($record) {
237
-                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
238
                                             if ($isPerLineItem) {
260
                                             if ($isPerLineItem) {
239
-                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
261
+                                                $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
262
+                                                    $query->where(static function ($query) use ($existingDiscountIds) {
263
+                                                        $query->where('status', AdjustmentStatus::Active)
264
+                                                            ->orWhereIn('adjustments.id', $existingDiscountIds);
265
+                                                    });
266
+                                                };
240
                                             }
267
                                             }
241
-                                        }
242
 
268
 
243
-                                        $with = [
244
-                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
245
-                                                $query->where(static function ($query) use ($existingTaxIds) {
246
-                                                    $query->where('status', AdjustmentStatus::Active)
247
-                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
248
-                                                });
249
-                                            },
250
-                                        ];
251
-
252
-                                        if ($isPerLineItem) {
253
-                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
254
-                                                $query->where(static function ($query) use ($existingDiscountIds) {
255
-                                                    $query->where('status', AdjustmentStatus::Active)
256
-                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
257
-                                                });
258
-                                            };
259
-                                        }
269
+                                            $offeringRecord = Offering::with($with)->find($offeringId);
260
 
270
 
261
-                                        $offeringRecord = Offering::with($with)->find($offeringId);
262
-
263
-                                        if (! $offeringRecord) {
264
-                                            return;
265
-                                        }
271
+                                            if (! $offeringRecord) {
272
+                                                return;
273
+                                            }
266
 
274
 
267
-                                        $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
275
+                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
268
 
276
 
269
-                                        $set('description', $offeringRecord->description);
270
-                                        $set('unit_price', $unitPrice);
271
-                                        $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
277
+                                            $set('description', $offeringRecord->description);
278
+                                            $set('unit_price', $unitPrice);
279
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
272
 
280
 
273
-                                        if ($isPerLineItem) {
274
-                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
275
-                                        }
276
-                                    }),
277
-                                Forms\Components\TextInput::make('description'),
281
+                                            if ($isPerLineItem) {
282
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
283
+                                            }
284
+                                        }),
285
+                                    Forms\Components\TextInput::make('description')
286
+                                        ->placeholder('Enter item description')
287
+                                        ->hiddenLabel(),
288
+                                ])->columnSpan(1),
278
                                 Forms\Components\TextInput::make('quantity')
289
                                 Forms\Components\TextInput::make('quantity')
279
                                     ->required()
290
                                     ->required()
280
                                     ->numeric()
291
                                     ->numeric()
287
                                     ->live()
298
                                     ->live()
288
                                     ->maxValue(9999999999.99)
299
                                     ->maxValue(9999999999.99)
289
                                     ->default(0),
300
                                     ->default(0),
290
-                                CreateAdjustmentSelect::make('salesTaxes')
291
-                                    ->label('Taxes')
292
-                                    ->category(AdjustmentCategory::Tax)
293
-                                    ->type(AdjustmentType::Sales)
294
-                                    ->adjustmentsRelationship('salesTaxes')
295
-                                    ->saveRelationshipsUsing(null)
296
-                                    ->dehydrated(true)
297
-                                    ->preload()
298
-                                    ->multiple()
299
-                                    ->live()
300
-                                    ->searchable(),
301
-                                CreateAdjustmentSelect::make('salesDiscounts')
302
-                                    ->label('Discounts')
303
-                                    ->category(AdjustmentCategory::Discount)
304
-                                    ->type(AdjustmentType::Sales)
305
-                                    ->adjustmentsRelationship('salesDiscounts')
306
-                                    ->saveRelationshipsUsing(null)
307
-                                    ->dehydrated(true)
308
-                                    ->multiple()
309
-                                    ->live()
310
-                                    ->hidden(function (Forms\Get $get) {
311
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
301
+                                Forms\Components\Group::make([
302
+                                    CreateAdjustmentSelect::make('salesTaxes')
303
+                                        ->label('Taxes')
304
+                                        ->hiddenLabel()
305
+                                        ->placeholder('Select taxes')
306
+                                        ->category(AdjustmentCategory::Tax)
307
+                                        ->type(AdjustmentType::Sales)
308
+                                        ->adjustmentsRelationship('salesTaxes')
309
+                                        ->saveRelationshipsUsing(null)
310
+                                        ->dehydrated(true)
311
+                                        ->inlineSuffix()
312
+                                        ->preload()
313
+                                        ->multiple()
314
+                                        ->live()
315
+                                        ->searchable(),
316
+                                    CreateAdjustmentSelect::make('salesDiscounts')
317
+                                        ->label('Discounts')
318
+                                        ->hiddenLabel()
319
+                                        ->placeholder('Select discounts')
320
+                                        ->category(AdjustmentCategory::Discount)
321
+                                        ->type(AdjustmentType::Sales)
322
+                                        ->adjustmentsRelationship('salesDiscounts')
323
+                                        ->saveRelationshipsUsing(null)
324
+                                        ->dehydrated(true)
325
+                                        ->inlineSuffix()
326
+                                        ->multiple()
327
+                                        ->live()
328
+                                        ->hidden(function (Forms\Get $get) {
329
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
312
 
330
 
313
-                                        return $discountMethod->isPerDocument();
314
-                                    })
315
-                                    ->searchable(),
331
+                                            return $discountMethod->isPerDocument();
332
+                                        })
333
+                                        ->searchable(),
334
+                                ])->columnSpan(1),
316
                                 Forms\Components\Placeholder::make('total')
335
                                 Forms\Components\Placeholder::make('total')
317
                                     ->hiddenLabel()
336
                                     ->hiddenLabel()
318
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
337
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])

+ 101
- 82
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php View File

15
 use App\Filament\Forms\Components\CreateClientSelect;
15
 use App\Filament\Forms\Components\CreateClientSelect;
16
 use App\Filament\Forms\Components\CreateCurrencySelect;
16
 use App\Filament\Forms\Components\CreateCurrencySelect;
17
 use App\Filament\Forms\Components\CreateOfferingSelect;
17
 use App\Filament\Forms\Components\CreateOfferingSelect;
18
+use App\Filament\Forms\Components\CustomTableRepeater;
18
 use App\Filament\Forms\Components\DocumentFooterSection;
19
 use App\Filament\Forms\Components\DocumentFooterSection;
19
 use App\Filament\Forms\Components\DocumentHeaderSection;
20
 use App\Filament\Forms\Components\DocumentHeaderSection;
20
 use App\Filament\Forms\Components\DocumentTotals;
21
 use App\Filament\Forms\Components\DocumentTotals;
27
 use App\Utilities\Currency\CurrencyAccessor;
28
 use App\Utilities\Currency\CurrencyAccessor;
28
 use App\Utilities\Currency\CurrencyConverter;
29
 use App\Utilities\Currency\CurrencyConverter;
29
 use App\Utilities\RateCalculator;
30
 use App\Utilities\RateCalculator;
30
-use Awcodes\TableRepeater\Components\TableRepeater;
31
 use Awcodes\TableRepeater\Header;
31
 use Awcodes\TableRepeater\Header;
32
 use Filament\Forms;
32
 use Filament\Forms;
33
 use Filament\Forms\Form;
33
 use Filament\Forms\Form;
90
                                 Forms\Components\Select::make('discount_method')
90
                                 Forms\Components\Select::make('discount_method')
91
                                     ->label('Discount method')
91
                                     ->label('Discount method')
92
                                     ->options(DocumentDiscountMethod::class)
92
                                     ->options(DocumentDiscountMethod::class)
93
-                                    ->selectablePlaceholder(false)
94
-                                    ->default(DocumentDiscountMethod::PerLineItem)
93
+                                    ->softRequired()
94
+                                    ->default($settings->discount_method)
95
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
95
                                     ->afterStateUpdated(function ($state, Forms\Set $set) {
96
                                         $discountMethod = DocumentDiscountMethod::parse($state);
96
                                         $discountMethod = DocumentDiscountMethod::parse($state);
97
 
97
 
102
                                     ->live(),
102
                                     ->live(),
103
                             ])->grow(true),
103
                             ])->grow(true),
104
                         ])->from('md'),
104
                         ])->from('md'),
105
-                        TableRepeater::make('lineItems')
105
+                        CustomTableRepeater::make('lineItems')
106
+                            ->hiddenLabel()
106
                             ->relationship()
107
                             ->relationship()
107
                             ->saveRelationshipsUsing(null)
108
                             ->saveRelationshipsUsing(null)
108
                             ->dehydrated(true)
109
                             ->dehydrated(true)
110
+                            ->reorderable()
111
+                            ->orderColumn('line_number')
112
+                            ->reorderAtStart()
113
+                            ->cloneable()
114
+                            ->addActionLabel('Add an item')
109
                             ->headers(function (Forms\Get $get) use ($settings) {
115
                             ->headers(function (Forms\Get $get) use ($settings) {
110
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
116
                                 $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
111
 
117
 
112
                                 $headers = [
118
                                 $headers = [
113
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
119
                                     Header::make($settings->resolveColumnLabel('item_name', 'Items'))
114
-                                        ->width($hasDiscounts ? '15%' : '20%'),
115
-                                    Header::make('Description')
116
-                                        ->width($hasDiscounts ? '15%' : '20%'),
120
+                                        ->width('30%'),
117
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
121
                                     Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
118
                                         ->width('10%'),
122
                                         ->width('10%'),
119
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
123
                                     Header::make($settings->resolveColumnLabel('price_name', 'Price'))
120
                                         ->width('10%'),
124
                                         ->width('10%'),
121
-                                    Header::make('Taxes')
122
-                                        ->width($hasDiscounts ? '20%' : '30%'),
123
                                 ];
125
                                 ];
124
 
126
 
125
                                 if ($hasDiscounts) {
127
                                 if ($hasDiscounts) {
126
-                                    $headers[] = Header::make('Discounts')->width('20%');
128
+                                    $headers[] = Header::make('Adjustments')->width('30%');
129
+                                } else {
130
+                                    $headers[] = Header::make('Taxes')->width('30%');
127
                                 }
131
                                 }
128
 
132
 
129
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
133
                                 $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
133
                                 return $headers;
137
                                 return $headers;
134
                             })
138
                             })
135
                             ->schema([
139
                             ->schema([
136
-                                CreateOfferingSelect::make('offering_id')
137
-                                    ->label('Item')
138
-                                    ->required()
139
-                                    ->live()
140
-                                    ->sellable()
141
-                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
142
-                                        $offeringId = $state;
143
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
144
-                                        $isPerLineItem = $discountMethod->isPerLineItem();
140
+                                Forms\Components\Group::make([
141
+                                    CreateOfferingSelect::make('offering_id')
142
+                                        ->label('Item')
143
+                                        ->hiddenLabel()
144
+                                        ->placeholder('Select item')
145
+                                        ->required()
146
+                                        ->live()
147
+                                        ->inlineSuffix()
148
+                                        ->sellable()
149
+                                        ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
150
+                                            $offeringId = $state;
151
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
152
+                                            $isPerLineItem = $discountMethod->isPerLineItem();
153
+
154
+                                            $existingTaxIds = [];
155
+                                            $existingDiscountIds = [];
156
+
157
+                                            if ($record) {
158
+                                                $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
159
+                                                if ($isPerLineItem) {
160
+                                                    $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
161
+                                                }
162
+                                            }
145
 
163
 
146
-                                        $existingTaxIds = [];
147
-                                        $existingDiscountIds = [];
164
+                                            $with = [
165
+                                                'salesTaxes' => static function ($query) use ($existingTaxIds) {
166
+                                                    $query->where(static function ($query) use ($existingTaxIds) {
167
+                                                        $query->where('status', AdjustmentStatus::Active)
168
+                                                            ->orWhereIn('adjustments.id', $existingTaxIds);
169
+                                                    });
170
+                                                },
171
+                                            ];
148
 
172
 
149
-                                        if ($record) {
150
-                                            $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
151
                                             if ($isPerLineItem) {
173
                                             if ($isPerLineItem) {
152
-                                                $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
174
+                                                $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
175
+                                                    $query->where(static function ($query) use ($existingDiscountIds) {
176
+                                                        $query->where('status', AdjustmentStatus::Active)
177
+                                                            ->orWhereIn('adjustments.id', $existingDiscountIds);
178
+                                                    });
179
+                                                };
153
                                             }
180
                                             }
154
-                                        }
155
-
156
-                                        $with = [
157
-                                            'salesTaxes' => static function ($query) use ($existingTaxIds) {
158
-                                                $query->where(static function ($query) use ($existingTaxIds) {
159
-                                                    $query->where('status', AdjustmentStatus::Active)
160
-                                                        ->orWhereIn('adjustments.id', $existingTaxIds);
161
-                                                });
162
-                                            },
163
-                                        ];
164
-
165
-                                        if ($isPerLineItem) {
166
-                                            $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
167
-                                                $query->where(static function ($query) use ($existingDiscountIds) {
168
-                                                    $query->where('status', AdjustmentStatus::Active)
169
-                                                        ->orWhereIn('adjustments.id', $existingDiscountIds);
170
-                                                });
171
-                                            };
172
-                                        }
173
 
181
 
174
-                                        $offeringRecord = Offering::with($with)->find($offeringId);
182
+                                            $offeringRecord = Offering::with($with)->find($offeringId);
175
 
183
 
176
-                                        if (! $offeringRecord) {
177
-                                            return;
178
-                                        }
184
+                                            if (! $offeringRecord) {
185
+                                                return;
186
+                                            }
179
 
187
 
180
-                                        $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
188
+                                            $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
181
 
189
 
182
-                                        $set('description', $offeringRecord->description);
183
-                                        $set('unit_price', $unitPrice);
184
-                                        $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
190
+                                            $set('description', $offeringRecord->description);
191
+                                            $set('unit_price', $unitPrice);
192
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
185
 
193
 
186
-                                        if ($isPerLineItem) {
187
-                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
188
-                                        }
189
-                                    }),
190
-                                Forms\Components\TextInput::make('description'),
194
+                                            if ($isPerLineItem) {
195
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
196
+                                            }
197
+                                        }),
198
+                                    Forms\Components\TextInput::make('description')
199
+                                        ->placeholder('Enter item description')
200
+                                        ->hiddenLabel(),
201
+                                ])->columnSpan(1),
191
                                 Forms\Components\TextInput::make('quantity')
202
                                 Forms\Components\TextInput::make('quantity')
192
                                     ->required()
203
                                     ->required()
193
                                     ->numeric()
204
                                     ->numeric()
200
                                     ->live()
211
                                     ->live()
201
                                     ->maxValue(9999999999.99)
212
                                     ->maxValue(9999999999.99)
202
                                     ->default(0),
213
                                     ->default(0),
203
-                                CreateAdjustmentSelect::make('salesTaxes')
204
-                                    ->label('Taxes')
205
-                                    ->category(AdjustmentCategory::Tax)
206
-                                    ->type(AdjustmentType::Sales)
207
-                                    ->adjustmentsRelationship('salesTaxes')
208
-                                    ->saveRelationshipsUsing(null)
209
-                                    ->dehydrated(true)
210
-                                    ->preload()
211
-                                    ->multiple()
212
-                                    ->live()
213
-                                    ->searchable(),
214
-                                CreateAdjustmentSelect::make('salesDiscounts')
215
-                                    ->label('Discounts')
216
-                                    ->category(AdjustmentCategory::Discount)
217
-                                    ->type(AdjustmentType::Sales)
218
-                                    ->adjustmentsRelationship('salesDiscounts')
219
-                                    ->saveRelationshipsUsing(null)
220
-                                    ->dehydrated(true)
221
-                                    ->multiple()
222
-                                    ->live()
223
-                                    ->hidden(function (Forms\Get $get) {
224
-                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
225
-
226
-                                        return $discountMethod->isPerDocument();
227
-                                    })
228
-                                    ->searchable(),
214
+                                Forms\Components\Group::make([
215
+                                    CreateAdjustmentSelect::make('salesTaxes')
216
+                                        ->label('Taxes')
217
+                                        ->hiddenLabel()
218
+                                        ->placeholder('Select taxes')
219
+                                        ->category(AdjustmentCategory::Tax)
220
+                                        ->type(AdjustmentType::Sales)
221
+                                        ->adjustmentsRelationship('salesTaxes')
222
+                                        ->saveRelationshipsUsing(null)
223
+                                        ->dehydrated(true)
224
+                                        ->inlineSuffix()
225
+                                        ->preload()
226
+                                        ->multiple()
227
+                                        ->live()
228
+                                        ->searchable(),
229
+                                    CreateAdjustmentSelect::make('salesDiscounts')
230
+                                        ->label('Discounts')
231
+                                        ->hiddenLabel()
232
+                                        ->placeholder('Select discounts')
233
+                                        ->category(AdjustmentCategory::Discount)
234
+                                        ->type(AdjustmentType::Sales)
235
+                                        ->adjustmentsRelationship('salesDiscounts')
236
+                                        ->saveRelationshipsUsing(null)
237
+                                        ->dehydrated(true)
238
+                                        ->inlineSuffix()
239
+                                        ->multiple()
240
+                                        ->live()
241
+                                        ->hidden(function (Forms\Get $get) {
242
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
243
+
244
+                                            return $discountMethod->isPerDocument();
245
+                                        })
246
+                                        ->searchable(),
247
+                                ])->columnSpan(1),
229
                                 Forms\Components\Placeholder::make('total')
248
                                 Forms\Components\Placeholder::make('total')
230
                                     ->hiddenLabel()
249
                                     ->hiddenLabel()
231
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])
250
                                     ->extraAttributes(['class' => 'text-left sm:text-right'])

+ 86
- 2
app/Filament/Forms/Components/CustomTableRepeater.php View File

4
 
4
 
5
 use Awcodes\TableRepeater\Components\TableRepeater;
5
 use Awcodes\TableRepeater\Components\TableRepeater;
6
 use Closure;
6
 use Closure;
7
+use Filament\Forms\Components\Actions\Action;
7
 
8
 
8
 class CustomTableRepeater extends TableRepeater
9
 class CustomTableRepeater extends TableRepeater
9
 {
10
 {
10
-    protected bool | Closure | null $spreadsheet = null;
11
+    protected bool | Closure $spreadsheet = false;
12
+
13
+    protected bool | Closure $reorderAtStart = false;
14
+
15
+    /**
16
+     * @var array<string> | Closure | null
17
+     */
18
+    protected array | Closure | null $excludedAttributesForCloning = [
19
+        'id',
20
+        'line_number',
21
+        'created_by',
22
+        'updated_by',
23
+        'created_at',
24
+        'updated_at',
25
+    ];
11
 
26
 
12
     public function spreadsheet(bool | Closure $condition = true): static
27
     public function spreadsheet(bool | Closure $condition = true): static
13
     {
28
     {
18
 
33
 
19
     public function isSpreadsheet(): bool
34
     public function isSpreadsheet(): bool
20
     {
35
     {
21
-        return $this->evaluate($this->spreadsheet) ?? false;
36
+        return (bool) $this->evaluate($this->spreadsheet);
37
+    }
38
+
39
+    public function reorderAtStart(bool | Closure $condition = true): static
40
+    {
41
+        $this->reorderAtStart = $condition;
42
+
43
+        return $this;
44
+    }
45
+
46
+    public function isReorderAtStart(): bool
47
+    {
48
+        return $this->evaluate($this->reorderAtStart) && $this->isReorderable();
49
+    }
50
+
51
+    /**
52
+     * @param  array<string> | Closure | null  $attributes
53
+     */
54
+    public function excludeAttributesForCloning(array | Closure | null $attributes): static
55
+    {
56
+        $this->excludedAttributesForCloning = $attributes;
57
+
58
+        return $this;
59
+    }
60
+
61
+    /**
62
+     * @return array<string> | null
63
+     */
64
+    public function getExcludedAttributesForCloning(): ?array
65
+    {
66
+        return $this->evaluate($this->excludedAttributesForCloning);
22
     }
67
     }
23
 
68
 
24
     protected function setUp(): void
69
     protected function setUp(): void
25
     {
70
     {
26
         parent::setUp();
71
         parent::setUp();
27
 
72
 
73
+        $this->minItems(1);
74
+
28
         $this->extraAttributes(function (): array {
75
         $this->extraAttributes(function (): array {
29
             $attributes = [];
76
             $attributes = [];
30
 
77
 
34
 
81
 
35
             return $attributes;
82
             return $attributes;
36
         });
83
         });
84
+
85
+        $this->reorderAction(function (Action $action) {
86
+            if ($this->isReorderAtStart()) {
87
+                $action->icon('heroicon-m-bars-3');
88
+            }
89
+
90
+            return $action;
91
+        });
92
+
93
+        $this->cloneAction(function (Action $action) {
94
+            return $action
95
+                ->action(function (array $arguments, CustomTableRepeater $component): void {
96
+                    $newUuid = $component->generateUuid();
97
+                    $items = $component->getState();
98
+
99
+                    $clone = $items[$arguments['item']];
100
+
101
+                    foreach ($component->getExcludedAttributesForCloning() as $attribute) {
102
+                        unset($clone[$attribute]);
103
+                    }
104
+
105
+                    if ($newUuid) {
106
+                        $items[$newUuid] = $clone;
107
+                    } else {
108
+                        $items[] = $clone;
109
+                    }
110
+
111
+                    $component->state($items);
112
+                    $component->collapsed(false, shouldMakeComponentCollapsible: false);
113
+                    $component->callAfterStateUpdated();
114
+                });
115
+        });
116
+    }
117
+
118
+    public function getView(): string
119
+    {
120
+        return 'filament.forms.components.custom-table-repeater';
37
     }
121
     }
38
 }
122
 }

+ 1
- 1
app/Models/Accounting/Document.php View File

27
 
27
 
28
     public function lineItems(): MorphMany
28
     public function lineItems(): MorphMany
29
     {
29
     {
30
-        return $this->morphMany(DocumentLineItem::class, 'documentable');
30
+        return $this->morphMany(DocumentLineItem::class, 'documentable')->orderBy('line_number');
31
     }
31
     }
32
 
32
 
33
     public function hasLineItems(): bool
33
     public function hasLineItems(): bool

+ 1
- 0
app/Models/Accounting/DocumentLineItem.php View File

36
         'unit_price',
36
         'unit_price',
37
         'tax_total',
37
         'tax_total',
38
         'discount_total',
38
         'discount_total',
39
+        'line_number',
39
         'created_by',
40
         'created_by',
40
         'updated_by',
41
         'updated_by',
41
     ];
42
     ];

+ 3
- 0
app/Models/Setting/DocumentDefault.php View File

4
 
4
 
5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
+use App\Enums\Accounting\DocumentDiscountMethod;
7
 use App\Enums\Accounting\DocumentType;
8
 use App\Enums\Accounting\DocumentType;
8
 use App\Enums\Setting\Font;
9
 use App\Enums\Setting\Font;
9
 use App\Enums\Setting\PaymentTerms;
10
 use App\Enums\Setting\PaymentTerms;
32
         'show_logo',
33
         'show_logo',
33
         'number_prefix',
34
         'number_prefix',
34
         'payment_terms',
35
         'payment_terms',
36
+        'discount_method',
35
         'header',
37
         'header',
36
         'subheader',
38
         'subheader',
37
         'terms',
39
         'terms',
51
         'type' => DocumentType::class,
53
         'type' => DocumentType::class,
52
         'show_logo' => 'boolean',
54
         'show_logo' => 'boolean',
53
         'payment_terms' => PaymentTerms::class,
55
         'payment_terms' => PaymentTerms::class,
56
+        'discount_method' => DocumentDiscountMethod::class,
54
         'font' => Font::class,
57
         'font' => Font::class,
55
         'template' => Template::class,
58
         'template' => Template::class,
56
         'item_name' => AsArrayObject::class,
59
         'item_name' => AsArrayObject::class,

+ 1
- 10
app/Providers/Filament/CompanyPanelProvider.php View File

298
     protected function configureSelect(): void
298
     protected function configureSelect(): void
299
     {
299
     {
300
         Select::configureUsing(function (Select $select): void {
300
         Select::configureUsing(function (Select $select): void {
301
-            $isSelectable = fn (): bool => ! $this->hasRequiredRule($select);
302
-
303
             $select
301
             $select
304
                 ->native(false)
302
                 ->native(false)
305
-                ->selectablePlaceholder($isSelectable);
303
+                ->selectablePlaceholder(fn (Select $component) => ! $component->isRequired());
306
         });
304
         });
307
     }
305
     }
308
-
309
-    protected function hasRequiredRule(Select $component): bool
310
-    {
311
-        $rules = $component->getValidationRules();
312
-
313
-        return in_array('required', $rules, true);
314
-    }
315
 }
306
 }

+ 5
- 1
app/Utilities/RateCalculator.php View File

28
         return (int) round($decimalRate * self::PERCENTAGE_SCALING_FACTOR);
28
         return (int) round($decimalRate * self::PERCENTAGE_SCALING_FACTOR);
29
     }
29
     }
30
 
30
 
31
-    public static function parseLocalizedRate(string $value): int
31
+    public static function parseLocalizedRate(?string $value): int
32
     {
32
     {
33
+        if (! $value) {
34
+            return 0;
35
+        }
36
+
33
         $format = Localization::firstOrFail()->number_format->value;
37
         $format = Localization::firstOrFail()->number_format->value;
34
         [$decimalMark, $thousandsSeparator] = NumberFormat::from($format)->getFormattingParameters();
38
         [$decimalMark, $thousandsSeparator] = NumberFormat::from($format)->getFormattingParameters();
35
 
39
 

+ 113
- 112
composer.lock View File

368
         },
368
         },
369
         {
369
         {
370
             "name": "awcodes/filament-table-repeater",
370
             "name": "awcodes/filament-table-repeater",
371
-            "version": "v3.1.2",
371
+            "version": "v3.1.3",
372
             "source": {
372
             "source": {
373
                 "type": "git",
373
                 "type": "git",
374
                 "url": "https://github.com/awcodes/filament-table-repeater.git",
374
                 "url": "https://github.com/awcodes/filament-table-repeater.git",
375
-                "reference": "1cdfdd0fefbcc183960b4623cab17f6db880029e"
375
+                "reference": "fd8df8fbb94a41d0a031a75ef739538290a14a8c"
376
             },
376
             },
377
             "dist": {
377
             "dist": {
378
                 "type": "zip",
378
                 "type": "zip",
379
-                "url": "https://api.github.com/repos/awcodes/filament-table-repeater/zipball/1cdfdd0fefbcc183960b4623cab17f6db880029e",
380
-                "reference": "1cdfdd0fefbcc183960b4623cab17f6db880029e",
379
+                "url": "https://api.github.com/repos/awcodes/filament-table-repeater/zipball/fd8df8fbb94a41d0a031a75ef739538290a14a8c",
380
+                "reference": "fd8df8fbb94a41d0a031a75ef739538290a14a8c",
381
                 "shasum": ""
381
                 "shasum": ""
382
             },
382
             },
383
             "require": {
383
             "require": {
431
             ],
431
             ],
432
             "support": {
432
             "support": {
433
                 "issues": "https://github.com/awcodes/filament-table-repeater/issues",
433
                 "issues": "https://github.com/awcodes/filament-table-repeater/issues",
434
-                "source": "https://github.com/awcodes/filament-table-repeater/tree/v3.1.2"
434
+                "source": "https://github.com/awcodes/filament-table-repeater/tree/v3.1.3"
435
             },
435
             },
436
             "funding": [
436
             "funding": [
437
                 {
437
                 {
439
                     "type": "github"
439
                     "type": "github"
440
                 }
440
                 }
441
             ],
441
             ],
442
-            "time": "2025-04-21T14:01:12+00:00"
442
+            "time": "2025-05-03T14:59:55+00:00"
443
         },
443
         },
444
         {
444
         {
445
             "name": "aws/aws-crt-php",
445
             "name": "aws/aws-crt-php",
497
         },
497
         },
498
         {
498
         {
499
             "name": "aws/aws-sdk-php",
499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.343.2",
500
+            "version": "3.343.3",
501
             "source": {
501
             "source": {
502
                 "type": "git",
502
                 "type": "git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "95d43e71d3395622394b36079f2fb2289d3284b3"
504
+                "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979"
505
             },
505
             },
506
             "dist": {
506
             "dist": {
507
                 "type": "zip",
507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/95d43e71d3395622394b36079f2fb2289d3284b3",
509
-                "reference": "95d43e71d3395622394b36079f2fb2289d3284b3",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979",
509
+                "reference": "d7ad5f6bdee792a16d2c9d5a3d9b56acfaaaf979",
510
                 "shasum": ""
510
                 "shasum": ""
511
             },
511
             },
512
             "require": {
512
             "require": {
588
             "support": {
588
             "support": {
589
                 "forum": "https://github.com/aws/aws-sdk-php/discussions",
589
                 "forum": "https://github.com/aws/aws-sdk-php/discussions",
590
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
590
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
591
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.343.2"
591
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.343.3"
592
             },
592
             },
593
-            "time": "2025-05-01T18:05:02+00:00"
593
+            "time": "2025-05-02T18:04:58+00:00"
594
         },
594
         },
595
         {
595
         {
596
             "name": "aws/aws-sdk-php-laravel",
596
             "name": "aws/aws-sdk-php-laravel",
6794
         },
6794
         },
6795
         {
6795
         {
6796
             "name": "symfony/console",
6796
             "name": "symfony/console",
6797
-            "version": "v7.2.5",
6797
+            "version": "v7.2.6",
6798
             "source": {
6798
             "source": {
6799
                 "type": "git",
6799
                 "type": "git",
6800
                 "url": "https://github.com/symfony/console.git",
6800
                 "url": "https://github.com/symfony/console.git",
6801
-                "reference": "e51498ea18570c062e7df29d05a7003585b19b88"
6801
+                "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218"
6802
             },
6802
             },
6803
             "dist": {
6803
             "dist": {
6804
                 "type": "zip",
6804
                 "type": "zip",
6805
-                "url": "https://api.github.com/repos/symfony/console/zipball/e51498ea18570c062e7df29d05a7003585b19b88",
6806
-                "reference": "e51498ea18570c062e7df29d05a7003585b19b88",
6805
+                "url": "https://api.github.com/repos/symfony/console/zipball/0e2e3f38c192e93e622e41ec37f4ca70cfedf218",
6806
+                "reference": "0e2e3f38c192e93e622e41ec37f4ca70cfedf218",
6807
                 "shasum": ""
6807
                 "shasum": ""
6808
             },
6808
             },
6809
             "require": {
6809
             "require": {
6867
                 "terminal"
6867
                 "terminal"
6868
             ],
6868
             ],
6869
             "support": {
6869
             "support": {
6870
-                "source": "https://github.com/symfony/console/tree/v7.2.5"
6870
+                "source": "https://github.com/symfony/console/tree/v7.2.6"
6871
             },
6871
             },
6872
             "funding": [
6872
             "funding": [
6873
                 {
6873
                 {
6883
                     "type": "tidelift"
6883
                     "type": "tidelift"
6884
                 }
6884
                 }
6885
             ],
6885
             ],
6886
-            "time": "2025-03-12T08:11:12+00:00"
6886
+            "time": "2025-04-07T19:09:28+00:00"
6887
         },
6887
         },
6888
         {
6888
         {
6889
             "name": "symfony/css-selector",
6889
             "name": "symfony/css-selector",
7314
         },
7314
         },
7315
         {
7315
         {
7316
             "name": "symfony/html-sanitizer",
7316
             "name": "symfony/html-sanitizer",
7317
-            "version": "v7.2.3",
7317
+            "version": "v7.2.6",
7318
             "source": {
7318
             "source": {
7319
                 "type": "git",
7319
                 "type": "git",
7320
                 "url": "https://github.com/symfony/html-sanitizer.git",
7320
                 "url": "https://github.com/symfony/html-sanitizer.git",
7321
-                "reference": "91443febe34cfa5e8e00425f892e6316db95bc23"
7321
+                "reference": "1bd0c8fd5938d9af3f081a7c43d360ddefd494ca"
7322
             },
7322
             },
7323
             "dist": {
7323
             "dist": {
7324
                 "type": "zip",
7324
                 "type": "zip",
7325
-                "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/91443febe34cfa5e8e00425f892e6316db95bc23",
7326
-                "reference": "91443febe34cfa5e8e00425f892e6316db95bc23",
7325
+                "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/1bd0c8fd5938d9af3f081a7c43d360ddefd494ca",
7326
+                "reference": "1bd0c8fd5938d9af3f081a7c43d360ddefd494ca",
7327
                 "shasum": ""
7327
                 "shasum": ""
7328
             },
7328
             },
7329
             "require": {
7329
             "require": {
7363
                 "sanitizer"
7363
                 "sanitizer"
7364
             ],
7364
             ],
7365
             "support": {
7365
             "support": {
7366
-                "source": "https://github.com/symfony/html-sanitizer/tree/v7.2.3"
7366
+                "source": "https://github.com/symfony/html-sanitizer/tree/v7.2.6"
7367
             },
7367
             },
7368
             "funding": [
7368
             "funding": [
7369
                 {
7369
                 {
7379
                     "type": "tidelift"
7379
                     "type": "tidelift"
7380
                 }
7380
                 }
7381
             ],
7381
             ],
7382
-            "time": "2025-01-27T11:08:17+00:00"
7382
+            "time": "2025-03-31T08:29:03+00:00"
7383
         },
7383
         },
7384
         {
7384
         {
7385
             "name": "symfony/http-foundation",
7385
             "name": "symfony/http-foundation",
7386
-            "version": "v7.2.5",
7386
+            "version": "v7.2.6",
7387
             "source": {
7387
             "source": {
7388
                 "type": "git",
7388
                 "type": "git",
7389
                 "url": "https://github.com/symfony/http-foundation.git",
7389
                 "url": "https://github.com/symfony/http-foundation.git",
7390
-                "reference": "371272aeb6286f8135e028ca535f8e4d6f114126"
7390
+                "reference": "6023ec7607254c87c5e69fb3558255aca440d72b"
7391
             },
7391
             },
7392
             "dist": {
7392
             "dist": {
7393
                 "type": "zip",
7393
                 "type": "zip",
7394
-                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/371272aeb6286f8135e028ca535f8e4d6f114126",
7395
-                "reference": "371272aeb6286f8135e028ca535f8e4d6f114126",
7394
+                "url": "https://api.github.com/repos/symfony/http-foundation/zipball/6023ec7607254c87c5e69fb3558255aca440d72b",
7395
+                "reference": "6023ec7607254c87c5e69fb3558255aca440d72b",
7396
                 "shasum": ""
7396
                 "shasum": ""
7397
             },
7397
             },
7398
             "require": {
7398
             "require": {
7441
             "description": "Defines an object-oriented layer for the HTTP specification",
7441
             "description": "Defines an object-oriented layer for the HTTP specification",
7442
             "homepage": "https://symfony.com",
7442
             "homepage": "https://symfony.com",
7443
             "support": {
7443
             "support": {
7444
-                "source": "https://github.com/symfony/http-foundation/tree/v7.2.5"
7444
+                "source": "https://github.com/symfony/http-foundation/tree/v7.2.6"
7445
             },
7445
             },
7446
             "funding": [
7446
             "funding": [
7447
                 {
7447
                 {
7457
                     "type": "tidelift"
7457
                     "type": "tidelift"
7458
                 }
7458
                 }
7459
             ],
7459
             ],
7460
-            "time": "2025-03-25T15:54:33+00:00"
7460
+            "time": "2025-04-09T08:14:01+00:00"
7461
         },
7461
         },
7462
         {
7462
         {
7463
             "name": "symfony/http-kernel",
7463
             "name": "symfony/http-kernel",
7464
-            "version": "v7.2.5",
7464
+            "version": "v7.2.6",
7465
             "source": {
7465
             "source": {
7466
                 "type": "git",
7466
                 "type": "git",
7467
                 "url": "https://github.com/symfony/http-kernel.git",
7467
                 "url": "https://github.com/symfony/http-kernel.git",
7468
-                "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54"
7468
+                "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec"
7469
             },
7469
             },
7470
             "dist": {
7470
             "dist": {
7471
                 "type": "zip",
7471
                 "type": "zip",
7472
-                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/b1fe91bc1fa454a806d3f98db4ba826eb9941a54",
7473
-                "reference": "b1fe91bc1fa454a806d3f98db4ba826eb9941a54",
7472
+                "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f9dec01e6094a063e738f8945ef69c0cfcf792ec",
7473
+                "reference": "f9dec01e6094a063e738f8945ef69c0cfcf792ec",
7474
                 "shasum": ""
7474
                 "shasum": ""
7475
             },
7475
             },
7476
             "require": {
7476
             "require": {
7555
             "description": "Provides a structured process for converting a Request into a Response",
7555
             "description": "Provides a structured process for converting a Request into a Response",
7556
             "homepage": "https://symfony.com",
7556
             "homepage": "https://symfony.com",
7557
             "support": {
7557
             "support": {
7558
-                "source": "https://github.com/symfony/http-kernel/tree/v7.2.5"
7558
+                "source": "https://github.com/symfony/http-kernel/tree/v7.2.6"
7559
             },
7559
             },
7560
             "funding": [
7560
             "funding": [
7561
                 {
7561
                 {
7571
                     "type": "tidelift"
7571
                     "type": "tidelift"
7572
                 }
7572
                 }
7573
             ],
7573
             ],
7574
-            "time": "2025-03-28T13:32:50+00:00"
7574
+            "time": "2025-05-02T09:04:03+00:00"
7575
         },
7575
         },
7576
         {
7576
         {
7577
             "name": "symfony/intl",
7577
             "name": "symfony/intl",
7578
-            "version": "v6.4.15",
7578
+            "version": "v6.4.21",
7579
             "source": {
7579
             "source": {
7580
                 "type": "git",
7580
                 "type": "git",
7581
                 "url": "https://github.com/symfony/intl.git",
7581
                 "url": "https://github.com/symfony/intl.git",
7582
-                "reference": "b1d5e8d82615b60f229216edfee0b59e2ef66da6"
7582
+                "reference": "b248d227fa10fd6345efd4c1c74efaa1c1de6f76"
7583
             },
7583
             },
7584
             "dist": {
7584
             "dist": {
7585
                 "type": "zip",
7585
                 "type": "zip",
7586
-                "url": "https://api.github.com/repos/symfony/intl/zipball/b1d5e8d82615b60f229216edfee0b59e2ef66da6",
7587
-                "reference": "b1d5e8d82615b60f229216edfee0b59e2ef66da6",
7586
+                "url": "https://api.github.com/repos/symfony/intl/zipball/b248d227fa10fd6345efd4c1c74efaa1c1de6f76",
7587
+                "reference": "b248d227fa10fd6345efd4c1c74efaa1c1de6f76",
7588
                 "shasum": ""
7588
                 "shasum": ""
7589
             },
7589
             },
7590
             "require": {
7590
             "require": {
7638
                 "localization"
7638
                 "localization"
7639
             ],
7639
             ],
7640
             "support": {
7640
             "support": {
7641
-                "source": "https://github.com/symfony/intl/tree/v6.4.15"
7641
+                "source": "https://github.com/symfony/intl/tree/v6.4.21"
7642
             },
7642
             },
7643
             "funding": [
7643
             "funding": [
7644
                 {
7644
                 {
7654
                     "type": "tidelift"
7654
                     "type": "tidelift"
7655
                 }
7655
                 }
7656
             ],
7656
             ],
7657
-            "time": "2024-11-08T15:28:48+00:00"
7657
+            "time": "2025-04-07T19:02:30+00:00"
7658
         },
7658
         },
7659
         {
7659
         {
7660
             "name": "symfony/mailer",
7660
             "name": "symfony/mailer",
7661
-            "version": "v7.2.3",
7661
+            "version": "v7.2.6",
7662
             "source": {
7662
             "source": {
7663
                 "type": "git",
7663
                 "type": "git",
7664
                 "url": "https://github.com/symfony/mailer.git",
7664
                 "url": "https://github.com/symfony/mailer.git",
7665
-                "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3"
7665
+                "reference": "998692469d6e698c6eadc7ef37a6530a9eabb356"
7666
             },
7666
             },
7667
             "dist": {
7667
             "dist": {
7668
                 "type": "zip",
7668
                 "type": "zip",
7669
-                "url": "https://api.github.com/repos/symfony/mailer/zipball/f3871b182c44997cf039f3b462af4a48fb85f9d3",
7670
-                "reference": "f3871b182c44997cf039f3b462af4a48fb85f9d3",
7669
+                "url": "https://api.github.com/repos/symfony/mailer/zipball/998692469d6e698c6eadc7ef37a6530a9eabb356",
7670
+                "reference": "998692469d6e698c6eadc7ef37a6530a9eabb356",
7671
                 "shasum": ""
7671
                 "shasum": ""
7672
             },
7672
             },
7673
             "require": {
7673
             "require": {
7718
             "description": "Helps sending emails",
7718
             "description": "Helps sending emails",
7719
             "homepage": "https://symfony.com",
7719
             "homepage": "https://symfony.com",
7720
             "support": {
7720
             "support": {
7721
-                "source": "https://github.com/symfony/mailer/tree/v7.2.3"
7721
+                "source": "https://github.com/symfony/mailer/tree/v7.2.6"
7722
             },
7722
             },
7723
             "funding": [
7723
             "funding": [
7724
                 {
7724
                 {
7734
                     "type": "tidelift"
7734
                     "type": "tidelift"
7735
                 }
7735
                 }
7736
             ],
7736
             ],
7737
-            "time": "2025-01-27T11:08:17+00:00"
7737
+            "time": "2025-04-04T09:50:51+00:00"
7738
         },
7738
         },
7739
         {
7739
         {
7740
             "name": "symfony/mime",
7740
             "name": "symfony/mime",
7741
-            "version": "v7.2.4",
7741
+            "version": "v7.2.6",
7742
             "source": {
7742
             "source": {
7743
                 "type": "git",
7743
                 "type": "git",
7744
                 "url": "https://github.com/symfony/mime.git",
7744
                 "url": "https://github.com/symfony/mime.git",
7745
-                "reference": "87ca22046b78c3feaff04b337f33b38510fd686b"
7745
+                "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1"
7746
             },
7746
             },
7747
             "dist": {
7747
             "dist": {
7748
                 "type": "zip",
7748
                 "type": "zip",
7749
-                "url": "https://api.github.com/repos/symfony/mime/zipball/87ca22046b78c3feaff04b337f33b38510fd686b",
7750
-                "reference": "87ca22046b78c3feaff04b337f33b38510fd686b",
7749
+                "url": "https://api.github.com/repos/symfony/mime/zipball/706e65c72d402539a072d0d6ad105fff6c161ef1",
7750
+                "reference": "706e65c72d402539a072d0d6ad105fff6c161ef1",
7751
                 "shasum": ""
7751
                 "shasum": ""
7752
             },
7752
             },
7753
             "require": {
7753
             "require": {
7802
                 "mime-type"
7802
                 "mime-type"
7803
             ],
7803
             ],
7804
             "support": {
7804
             "support": {
7805
-                "source": "https://github.com/symfony/mime/tree/v7.2.4"
7805
+                "source": "https://github.com/symfony/mime/tree/v7.2.6"
7806
             },
7806
             },
7807
             "funding": [
7807
             "funding": [
7808
                 {
7808
                 {
7818
                     "type": "tidelift"
7818
                     "type": "tidelift"
7819
                 }
7819
                 }
7820
             ],
7820
             ],
7821
-            "time": "2025-02-19T08:51:20+00:00"
7821
+            "time": "2025-04-27T13:34:41+00:00"
7822
         },
7822
         },
7823
         {
7823
         {
7824
             "name": "symfony/polyfill-ctype",
7824
             "name": "symfony/polyfill-ctype",
7825
-            "version": "v1.31.0",
7825
+            "version": "v1.32.0",
7826
             "source": {
7826
             "source": {
7827
                 "type": "git",
7827
                 "type": "git",
7828
                 "url": "https://github.com/symfony/polyfill-ctype.git",
7828
                 "url": "https://github.com/symfony/polyfill-ctype.git",
7881
                 "portable"
7881
                 "portable"
7882
             ],
7882
             ],
7883
             "support": {
7883
             "support": {
7884
-                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.31.0"
7884
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
7885
             },
7885
             },
7886
             "funding": [
7886
             "funding": [
7887
                 {
7887
                 {
7901
         },
7901
         },
7902
         {
7902
         {
7903
             "name": "symfony/polyfill-intl-grapheme",
7903
             "name": "symfony/polyfill-intl-grapheme",
7904
-            "version": "v1.31.0",
7904
+            "version": "v1.32.0",
7905
             "source": {
7905
             "source": {
7906
                 "type": "git",
7906
                 "type": "git",
7907
                 "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
7907
                 "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
7959
                 "shim"
7959
                 "shim"
7960
             ],
7960
             ],
7961
             "support": {
7961
             "support": {
7962
-                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0"
7962
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0"
7963
             },
7963
             },
7964
             "funding": [
7964
             "funding": [
7965
                 {
7965
                 {
7979
         },
7979
         },
7980
         {
7980
         {
7981
             "name": "symfony/polyfill-intl-idn",
7981
             "name": "symfony/polyfill-intl-idn",
7982
-            "version": "v1.31.0",
7982
+            "version": "v1.32.0",
7983
             "source": {
7983
             "source": {
7984
                 "type": "git",
7984
                 "type": "git",
7985
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
7985
                 "url": "https://github.com/symfony/polyfill-intl-idn.git",
7986
-                "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773"
7986
+                "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3"
7987
             },
7987
             },
7988
             "dist": {
7988
             "dist": {
7989
                 "type": "zip",
7989
                 "type": "zip",
7990
-                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/c36586dcf89a12315939e00ec9b4474adcb1d773",
7991
-                "reference": "c36586dcf89a12315939e00ec9b4474adcb1d773",
7990
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3",
7991
+                "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3",
7992
                 "shasum": ""
7992
                 "shasum": ""
7993
             },
7993
             },
7994
             "require": {
7994
             "require": {
8042
                 "shim"
8042
                 "shim"
8043
             ],
8043
             ],
8044
             "support": {
8044
             "support": {
8045
-                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.31.0"
8045
+                "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0"
8046
             },
8046
             },
8047
             "funding": [
8047
             "funding": [
8048
                 {
8048
                 {
8058
                     "type": "tidelift"
8058
                     "type": "tidelift"
8059
                 }
8059
                 }
8060
             ],
8060
             ],
8061
-            "time": "2024-09-09T11:45:10+00:00"
8061
+            "time": "2024-09-10T14:38:51+00:00"
8062
         },
8062
         },
8063
         {
8063
         {
8064
             "name": "symfony/polyfill-intl-normalizer",
8064
             "name": "symfony/polyfill-intl-normalizer",
8065
-            "version": "v1.31.0",
8065
+            "version": "v1.32.0",
8066
             "source": {
8066
             "source": {
8067
                 "type": "git",
8067
                 "type": "git",
8068
                 "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
8068
                 "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
8123
                 "shim"
8123
                 "shim"
8124
             ],
8124
             ],
8125
             "support": {
8125
             "support": {
8126
-                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0"
8126
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
8127
             },
8127
             },
8128
             "funding": [
8128
             "funding": [
8129
                 {
8129
                 {
8143
         },
8143
         },
8144
         {
8144
         {
8145
             "name": "symfony/polyfill-mbstring",
8145
             "name": "symfony/polyfill-mbstring",
8146
-            "version": "v1.31.0",
8146
+            "version": "v1.32.0",
8147
             "source": {
8147
             "source": {
8148
                 "type": "git",
8148
                 "type": "git",
8149
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
8149
                 "url": "https://github.com/symfony/polyfill-mbstring.git",
8150
-                "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341"
8150
+                "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493"
8151
             },
8151
             },
8152
             "dist": {
8152
             "dist": {
8153
                 "type": "zip",
8153
                 "type": "zip",
8154
-                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341",
8155
-                "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341",
8154
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6d857f4d76bd4b343eac26d6b539585d2bc56493",
8155
+                "reference": "6d857f4d76bd4b343eac26d6b539585d2bc56493",
8156
                 "shasum": ""
8156
                 "shasum": ""
8157
             },
8157
             },
8158
             "require": {
8158
             "require": {
8159
+                "ext-iconv": "*",
8159
                 "php": ">=7.2"
8160
                 "php": ">=7.2"
8160
             },
8161
             },
8161
             "provide": {
8162
             "provide": {
8203
                 "shim"
8204
                 "shim"
8204
             ],
8205
             ],
8205
             "support": {
8206
             "support": {
8206
-                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0"
8207
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
8207
             },
8208
             },
8208
             "funding": [
8209
             "funding": [
8209
                 {
8210
                 {
8219
                     "type": "tidelift"
8220
                     "type": "tidelift"
8220
                 }
8221
                 }
8221
             ],
8222
             ],
8222
-            "time": "2024-09-09T11:45:10+00:00"
8223
+            "time": "2024-12-23T08:48:59+00:00"
8223
         },
8224
         },
8224
         {
8225
         {
8225
             "name": "symfony/polyfill-php80",
8226
             "name": "symfony/polyfill-php80",
8226
-            "version": "v1.31.0",
8227
+            "version": "v1.32.0",
8227
             "source": {
8228
             "source": {
8228
                 "type": "git",
8229
                 "type": "git",
8229
                 "url": "https://github.com/symfony/polyfill-php80.git",
8230
                 "url": "https://github.com/symfony/polyfill-php80.git",
8230
-                "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8"
8231
+                "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608"
8231
             },
8232
             },
8232
             "dist": {
8233
             "dist": {
8233
                 "type": "zip",
8234
                 "type": "zip",
8234
-                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
8235
-                "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8",
8235
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
8236
+                "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608",
8236
                 "shasum": ""
8237
                 "shasum": ""
8237
             },
8238
             },
8238
             "require": {
8239
             "require": {
8283
                 "shim"
8284
                 "shim"
8284
             ],
8285
             ],
8285
             "support": {
8286
             "support": {
8286
-                "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0"
8287
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
8287
             },
8288
             },
8288
             "funding": [
8289
             "funding": [
8289
                 {
8290
                 {
8299
                     "type": "tidelift"
8300
                     "type": "tidelift"
8300
                 }
8301
                 }
8301
             ],
8302
             ],
8302
-            "time": "2024-09-09T11:45:10+00:00"
8303
+            "time": "2025-01-02T08:10:11+00:00"
8303
         },
8304
         },
8304
         {
8305
         {
8305
             "name": "symfony/polyfill-php83",
8306
             "name": "symfony/polyfill-php83",
8306
-            "version": "v1.31.0",
8307
+            "version": "v1.32.0",
8307
             "source": {
8308
             "source": {
8308
                 "type": "git",
8309
                 "type": "git",
8309
                 "url": "https://github.com/symfony/polyfill-php83.git",
8310
                 "url": "https://github.com/symfony/polyfill-php83.git",
8359
                 "shim"
8360
                 "shim"
8360
             ],
8361
             ],
8361
             "support": {
8362
             "support": {
8362
-                "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0"
8363
+                "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0"
8363
             },
8364
             },
8364
             "funding": [
8365
             "funding": [
8365
                 {
8366
                 {
8379
         },
8380
         },
8380
         {
8381
         {
8381
             "name": "symfony/polyfill-uuid",
8382
             "name": "symfony/polyfill-uuid",
8382
-            "version": "v1.31.0",
8383
+            "version": "v1.32.0",
8383
             "source": {
8384
             "source": {
8384
                 "type": "git",
8385
                 "type": "git",
8385
                 "url": "https://github.com/symfony/polyfill-uuid.git",
8386
                 "url": "https://github.com/symfony/polyfill-uuid.git",
8438
                 "uuid"
8439
                 "uuid"
8439
             ],
8440
             ],
8440
             "support": {
8441
             "support": {
8441
-                "source": "https://github.com/symfony/polyfill-uuid/tree/v1.31.0"
8442
+                "source": "https://github.com/symfony/polyfill-uuid/tree/v1.32.0"
8442
             },
8443
             },
8443
             "funding": [
8444
             "funding": [
8444
                 {
8445
                 {
8683
         },
8684
         },
8684
         {
8685
         {
8685
             "name": "symfony/string",
8686
             "name": "symfony/string",
8686
-            "version": "v7.2.0",
8687
+            "version": "v7.2.6",
8687
             "source": {
8688
             "source": {
8688
                 "type": "git",
8689
                 "type": "git",
8689
                 "url": "https://github.com/symfony/string.git",
8690
                 "url": "https://github.com/symfony/string.git",
8690
-                "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82"
8691
+                "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931"
8691
             },
8692
             },
8692
             "dist": {
8693
             "dist": {
8693
                 "type": "zip",
8694
                 "type": "zip",
8694
-                "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82",
8695
-                "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82",
8695
+                "url": "https://api.github.com/repos/symfony/string/zipball/a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
8696
+                "reference": "a214fe7d62bd4df2a76447c67c6b26e1d5e74931",
8696
                 "shasum": ""
8697
                 "shasum": ""
8697
             },
8698
             },
8698
             "require": {
8699
             "require": {
8750
                 "utf8"
8751
                 "utf8"
8751
             ],
8752
             ],
8752
             "support": {
8753
             "support": {
8753
-                "source": "https://github.com/symfony/string/tree/v7.2.0"
8754
+                "source": "https://github.com/symfony/string/tree/v7.2.6"
8754
             },
8755
             },
8755
             "funding": [
8756
             "funding": [
8756
                 {
8757
                 {
8766
                     "type": "tidelift"
8767
                     "type": "tidelift"
8767
                 }
8768
                 }
8768
             ],
8769
             ],
8769
-            "time": "2024-11-13T13:31:26+00:00"
8770
+            "time": "2025-04-20T20:18:16+00:00"
8770
         },
8771
         },
8771
         {
8772
         {
8772
             "name": "symfony/translation",
8773
             "name": "symfony/translation",
8773
-            "version": "v7.2.4",
8774
+            "version": "v7.2.6",
8774
             "source": {
8775
             "source": {
8775
                 "type": "git",
8776
                 "type": "git",
8776
                 "url": "https://github.com/symfony/translation.git",
8777
                 "url": "https://github.com/symfony/translation.git",
8777
-                "reference": "283856e6981286cc0d800b53bd5703e8e363f05a"
8778
+                "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6"
8778
             },
8779
             },
8779
             "dist": {
8780
             "dist": {
8780
                 "type": "zip",
8781
                 "type": "zip",
8781
-                "url": "https://api.github.com/repos/symfony/translation/zipball/283856e6981286cc0d800b53bd5703e8e363f05a",
8782
-                "reference": "283856e6981286cc0d800b53bd5703e8e363f05a",
8782
+                "url": "https://api.github.com/repos/symfony/translation/zipball/e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
8783
+                "reference": "e7fd8e2a4239b79a0fd9fb1fef3e0e7f969c6dc6",
8783
                 "shasum": ""
8784
                 "shasum": ""
8784
             },
8785
             },
8785
             "require": {
8786
             "require": {
8845
             "description": "Provides tools to internationalize your application",
8846
             "description": "Provides tools to internationalize your application",
8846
             "homepage": "https://symfony.com",
8847
             "homepage": "https://symfony.com",
8847
             "support": {
8848
             "support": {
8848
-                "source": "https://github.com/symfony/translation/tree/v7.2.4"
8849
+                "source": "https://github.com/symfony/translation/tree/v7.2.6"
8849
             },
8850
             },
8850
             "funding": [
8851
             "funding": [
8851
                 {
8852
                 {
8861
                     "type": "tidelift"
8862
                     "type": "tidelift"
8862
                 }
8863
                 }
8863
             ],
8864
             ],
8864
-            "time": "2025-02-13T10:27:23+00:00"
8865
+            "time": "2025-04-07T19:09:28+00:00"
8865
         },
8866
         },
8866
         {
8867
         {
8867
             "name": "symfony/translation-contracts",
8868
             "name": "symfony/translation-contracts",
9017
         },
9018
         },
9018
         {
9019
         {
9019
             "name": "symfony/var-dumper",
9020
             "name": "symfony/var-dumper",
9020
-            "version": "v7.2.3",
9021
+            "version": "v7.2.6",
9021
             "source": {
9022
             "source": {
9022
                 "type": "git",
9023
                 "type": "git",
9023
                 "url": "https://github.com/symfony/var-dumper.git",
9024
                 "url": "https://github.com/symfony/var-dumper.git",
9024
-                "reference": "82b478c69745d8878eb60f9a049a4d584996f73a"
9025
+                "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb"
9025
             },
9026
             },
9026
             "dist": {
9027
             "dist": {
9027
                 "type": "zip",
9028
                 "type": "zip",
9028
-                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/82b478c69745d8878eb60f9a049a4d584996f73a",
9029
-                "reference": "82b478c69745d8878eb60f9a049a4d584996f73a",
9029
+                "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9c46038cd4ed68952166cf7001b54eb539184ccb",
9030
+                "reference": "9c46038cd4ed68952166cf7001b54eb539184ccb",
9030
                 "shasum": ""
9031
                 "shasum": ""
9031
             },
9032
             },
9032
             "require": {
9033
             "require": {
9080
                 "dump"
9081
                 "dump"
9081
             ],
9082
             ],
9082
             "support": {
9083
             "support": {
9083
-                "source": "https://github.com/symfony/var-dumper/tree/v7.2.3"
9084
+                "source": "https://github.com/symfony/var-dumper/tree/v7.2.6"
9084
             },
9085
             },
9085
             "funding": [
9086
             "funding": [
9086
                 {
9087
                 {
9096
                     "type": "tidelift"
9097
                     "type": "tidelift"
9097
                 }
9098
                 }
9098
             ],
9099
             ],
9099
-            "time": "2025-01-17T11:39:41+00:00"
9100
+            "time": "2025-04-09T08:14:01+00:00"
9100
         },
9101
         },
9101
         {
9102
         {
9102
             "name": "tijsverkoyen/css-to-inline-styles",
9103
             "name": "tijsverkoyen/css-to-inline-styles",
13098
         },
13099
         },
13099
         {
13100
         {
13100
             "name": "symfony/polyfill-iconv",
13101
             "name": "symfony/polyfill-iconv",
13101
-            "version": "v1.31.0",
13102
+            "version": "v1.32.0",
13102
             "source": {
13103
             "source": {
13103
                 "type": "git",
13104
                 "type": "git",
13104
                 "url": "https://github.com/symfony/polyfill-iconv.git",
13105
                 "url": "https://github.com/symfony/polyfill-iconv.git",
13105
-                "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956"
13106
+                "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa"
13106
             },
13107
             },
13107
             "dist": {
13108
             "dist": {
13108
                 "type": "zip",
13109
                 "type": "zip",
13109
-                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/48becf00c920479ca2e910c22a5a39e5d47ca956",
13110
-                "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956",
13110
+                "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa",
13111
+                "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa",
13111
                 "shasum": ""
13112
                 "shasum": ""
13112
             },
13113
             },
13113
             "require": {
13114
             "require": {
13158
                 "shim"
13159
                 "shim"
13159
             ],
13160
             ],
13160
             "support": {
13161
             "support": {
13161
-                "source": "https://github.com/symfony/polyfill-iconv/tree/v1.31.0"
13162
+                "source": "https://github.com/symfony/polyfill-iconv/tree/v1.32.0"
13162
             },
13163
             },
13163
             "funding": [
13164
             "funding": [
13164
                 {
13165
                 {
13174
                     "type": "tidelift"
13175
                     "type": "tidelift"
13175
                 }
13176
                 }
13176
             ],
13177
             ],
13177
-            "time": "2024-09-09T11:45:10+00:00"
13178
+            "time": "2024-09-17T14:58:18+00:00"
13178
         },
13179
         },
13179
         {
13180
         {
13180
             "name": "symfony/stopwatch",
13181
             "name": "symfony/stopwatch",
13240
         },
13241
         },
13241
         {
13242
         {
13242
             "name": "symfony/yaml",
13243
             "name": "symfony/yaml",
13243
-            "version": "v7.2.5",
13244
+            "version": "v7.2.6",
13244
             "source": {
13245
             "source": {
13245
                 "type": "git",
13246
                 "type": "git",
13246
                 "url": "https://github.com/symfony/yaml.git",
13247
                 "url": "https://github.com/symfony/yaml.git",
13247
-                "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912"
13248
+                "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23"
13248
             },
13249
             },
13249
             "dist": {
13250
             "dist": {
13250
                 "type": "zip",
13251
                 "type": "zip",
13251
-                "url": "https://api.github.com/repos/symfony/yaml/zipball/4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912",
13252
-                "reference": "4c4b6f4cfcd7e52053f0c8bfad0f7f30fb924912",
13252
+                "url": "https://api.github.com/repos/symfony/yaml/zipball/0feafffb843860624ddfd13478f481f4c3cd8b23",
13253
+                "reference": "0feafffb843860624ddfd13478f481f4c3cd8b23",
13253
                 "shasum": ""
13254
                 "shasum": ""
13254
             },
13255
             },
13255
             "require": {
13256
             "require": {
13292
             "description": "Loads and dumps YAML files",
13293
             "description": "Loads and dumps YAML files",
13293
             "homepage": "https://symfony.com",
13294
             "homepage": "https://symfony.com",
13294
             "support": {
13295
             "support": {
13295
-                "source": "https://github.com/symfony/yaml/tree/v7.2.5"
13296
+                "source": "https://github.com/symfony/yaml/tree/v7.2.6"
13296
             },
13297
             },
13297
             "funding": [
13298
             "funding": [
13298
                 {
13299
                 {
13308
                     "type": "tidelift"
13309
                     "type": "tidelift"
13309
                 }
13310
                 }
13310
             ],
13311
             ],
13311
-            "time": "2025-03-03T07:12:39+00:00"
13312
+            "time": "2025-04-04T10:10:11+00:00"
13312
         },
13313
         },
13313
         {
13314
         {
13314
             "name": "ta-tikoma/phpunit-architecture-test",
13315
             "name": "ta-tikoma/phpunit-architecture-test",

+ 27
- 1
database/factories/Accounting/BillFactory.php View File

2
 
2
 
3
 namespace Database\Factories\Accounting;
3
 namespace Database\Factories\Accounting;
4
 
4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
5
 use App\Enums\Accounting\BillStatus;
6
 use App\Enums\Accounting\BillStatus;
7
+use App\Enums\Accounting\DocumentDiscountMethod;
6
 use App\Enums\Accounting\PaymentMethod;
8
 use App\Enums\Accounting\PaymentMethod;
7
 use App\Models\Accounting\Bill;
9
 use App\Models\Accounting\Bill;
8
 use App\Models\Accounting\DocumentLineItem;
10
 use App\Models\Accounting\DocumentLineItem;
11
 use App\Models\Company;
13
 use App\Models\Company;
12
 use App\Models\Setting\DocumentDefault;
14
 use App\Models\Setting\DocumentDefault;
13
 use App\Utilities\Currency\CurrencyConverter;
15
 use App\Utilities\Currency\CurrencyConverter;
16
+use App\Utilities\RateCalculator;
14
 use Illuminate\Database\Eloquent\Factories\Factory;
17
 use Illuminate\Database\Eloquent\Factories\Factory;
15
 use Illuminate\Support\Carbon;
18
 use Illuminate\Support\Carbon;
16
 
19
 
49
             'date' => $billDate,
52
             'date' => $billDate,
50
             'due_date' => Carbon::parse($billDate)->addDays($dueDays),
53
             'due_date' => Carbon::parse($billDate)->addDays($dueDays),
51
             'status' => BillStatus::Open,
54
             'status' => BillStatus::Open,
55
+            'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
56
+            'discount_computation' => AdjustmentComputation::Percentage,
57
+            'discount_rate' => function (array $attributes) {
58
+                $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
59
+
60
+                if ($discountMethod?->isPerDocument()) {
61
+                    return $this->faker->numberBetween(50000, 200000); // 5% - 20%
62
+                }
63
+
64
+                return 0;
65
+            },
52
             'currency_code' => function (array $attributes) {
66
             'currency_code' => function (array $attributes) {
53
                 $vendor = Vendor::find($attributes['vendor_id']);
67
                 $vendor = Vendor::find($attributes['vendor_id']);
54
 
68
 
226
 
240
 
227
         $subtotalCents = $bill->lineItems()->sum('subtotal');
241
         $subtotalCents = $bill->lineItems()->sum('subtotal');
228
         $taxTotalCents = $bill->lineItems()->sum('tax_total');
242
         $taxTotalCents = $bill->lineItems()->sum('tax_total');
229
-        $discountTotalCents = $bill->lineItems()->sum('discount_total');
243
+
244
+        $discountTotalCents = 0;
245
+
246
+        if ($bill->discount_method?->isPerLineItem()) {
247
+            $discountTotalCents = $bill->lineItems()->sum('discount_total');
248
+        } elseif ($bill->discount_method?->isPerDocument() && $bill->discount_rate) {
249
+            if ($bill->discount_computation?->isPercentage()) {
250
+                $scaledRate = RateCalculator::parseLocalizedRate($bill->discount_rate);
251
+                $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
252
+            } else {
253
+                $discountTotalCents = CurrencyConverter::convertToCents($bill->discount_rate, $bill->currency_code);
254
+            }
255
+        }
230
 
256
 
231
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
257
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
232
         $currencyCode = $bill->currency_code;
258
         $currencyCode = $bill->currency_code;

+ 15
- 3
database/factories/Accounting/DocumentLineItemFactory.php View File

56
                 ]);
56
                 ]);
57
 
57
 
58
                 $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
58
                 $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
59
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
59
+
60
+                // Only sync discounts if the discount method is per_line_item
61
+                if ($lineItem->documentable->discount_method?->isPerLineItem() ?? true) {
62
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
63
+                }
60
 
64
 
61
                 $lineItem->refresh();
65
                 $lineItem->refresh();
62
 
66
 
88
                 ]);
92
                 ]);
89
 
93
 
90
                 $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
94
                 $lineItem->salesTaxes()->syncWithoutDetaching($offering->salesTaxes->pluck('id')->toArray());
91
-                $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
95
+
96
+                // Only sync discounts if the discount method is per_line_item
97
+                if ($lineItem->documentable->discount_method?->isPerLineItem() ?? true) {
98
+                    $lineItem->salesDiscounts()->syncWithoutDetaching($offering->salesDiscounts->pluck('id')->toArray());
99
+                }
92
 
100
 
93
                 $lineItem->refresh();
101
                 $lineItem->refresh();
94
 
102
 
120
                 ]);
128
                 ]);
121
 
129
 
122
                 $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
130
                 $lineItem->purchaseTaxes()->syncWithoutDetaching($offering->purchaseTaxes->pluck('id')->toArray());
123
-                $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
131
+
132
+                // Only sync discounts if the discount method is per_line_item
133
+                if ($lineItem->documentable->discount_method?->isPerLineItem() ?? true) {
134
+                    $lineItem->purchaseDiscounts()->syncWithoutDetaching($offering->purchaseDiscounts->pluck('id')->toArray());
135
+                }
124
 
136
 
125
                 $lineItem->refresh();
137
                 $lineItem->refresh();
126
 
138
 

+ 27
- 1
database/factories/Accounting/EstimateFactory.php View File

2
 
2
 
3
 namespace Database\Factories\Accounting;
3
 namespace Database\Factories\Accounting;
4
 
4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
5
 use App\Enums\Accounting\EstimateStatus;
7
 use App\Enums\Accounting\EstimateStatus;
6
 use App\Models\Accounting\DocumentLineItem;
8
 use App\Models\Accounting\DocumentLineItem;
7
 use App\Models\Accounting\Estimate;
9
 use App\Models\Accounting\Estimate;
9
 use App\Models\Company;
11
 use App\Models\Company;
10
 use App\Models\Setting\DocumentDefault;
12
 use App\Models\Setting\DocumentDefault;
11
 use App\Utilities\Currency\CurrencyConverter;
13
 use App\Utilities\Currency\CurrencyConverter;
14
+use App\Utilities\RateCalculator;
12
 use Illuminate\Database\Eloquent\Factories\Factory;
15
 use Illuminate\Database\Eloquent\Factories\Factory;
13
 use Illuminate\Support\Carbon;
16
 use Illuminate\Support\Carbon;
14
 
17
 
41
             'date' => $estimateDate,
44
             'date' => $estimateDate,
42
             'expiration_date' => Carbon::parse($estimateDate)->addDays($this->faker->numberBetween(14, 30)),
45
             'expiration_date' => Carbon::parse($estimateDate)->addDays($this->faker->numberBetween(14, 30)),
43
             'status' => EstimateStatus::Draft,
46
             'status' => EstimateStatus::Draft,
47
+            'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
48
+            'discount_computation' => AdjustmentComputation::Percentage,
49
+            'discount_rate' => function (array $attributes) {
50
+                $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
51
+
52
+                if ($discountMethod?->isPerDocument()) {
53
+                    return $this->faker->numberBetween(50000, 200000); // 5% - 20%
54
+                }
55
+
56
+                return 0;
57
+            },
44
             'currency_code' => function (array $attributes) {
58
             'currency_code' => function (array $attributes) {
45
                 $client = Client::find($attributes['client_id']);
59
                 $client = Client::find($attributes['client_id']);
46
 
60
 
207
 
221
 
208
         $subtotalCents = $estimate->lineItems()->sum('subtotal');
222
         $subtotalCents = $estimate->lineItems()->sum('subtotal');
209
         $taxTotalCents = $estimate->lineItems()->sum('tax_total');
223
         $taxTotalCents = $estimate->lineItems()->sum('tax_total');
210
-        $discountTotalCents = $estimate->lineItems()->sum('discount_total');
224
+
225
+        $discountTotalCents = 0;
226
+
227
+        if ($estimate->discount_method?->isPerLineItem()) {
228
+            $discountTotalCents = $estimate->lineItems()->sum('discount_total');
229
+        } elseif ($estimate->discount_method?->isPerDocument() && $estimate->discount_rate) {
230
+            if ($estimate->discount_computation?->isPercentage()) {
231
+                $scaledRate = RateCalculator::parseLocalizedRate($estimate->discount_rate);
232
+                $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
233
+            } else {
234
+                $discountTotalCents = CurrencyConverter::convertToCents($estimate->discount_rate, $estimate->currency_code);
235
+            }
236
+        }
211
 
237
 
212
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
238
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
213
         $currencyCode = $estimate->currency_code;
239
         $currencyCode = $estimate->currency_code;

+ 27
- 1
database/factories/Accounting/InvoiceFactory.php View File

2
 
2
 
3
 namespace Database\Factories\Accounting;
3
 namespace Database\Factories\Accounting;
4
 
4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
6
+use App\Enums\Accounting\DocumentDiscountMethod;
5
 use App\Enums\Accounting\InvoiceStatus;
7
 use App\Enums\Accounting\InvoiceStatus;
6
 use App\Enums\Accounting\PaymentMethod;
8
 use App\Enums\Accounting\PaymentMethod;
7
 use App\Models\Accounting\DocumentLineItem;
9
 use App\Models\Accounting\DocumentLineItem;
11
 use App\Models\Company;
13
 use App\Models\Company;
12
 use App\Models\Setting\DocumentDefault;
14
 use App\Models\Setting\DocumentDefault;
13
 use App\Utilities\Currency\CurrencyConverter;
15
 use App\Utilities\Currency\CurrencyConverter;
16
+use App\Utilities\RateCalculator;
14
 use Illuminate\Database\Eloquent\Factories\Factory;
17
 use Illuminate\Database\Eloquent\Factories\Factory;
15
 use Illuminate\Support\Carbon;
18
 use Illuminate\Support\Carbon;
16
 
19
 
43
             'date' => $invoiceDate,
46
             'date' => $invoiceDate,
44
             'due_date' => Carbon::parse($invoiceDate)->addDays($this->faker->numberBetween(14, 60)),
47
             'due_date' => Carbon::parse($invoiceDate)->addDays($this->faker->numberBetween(14, 60)),
45
             'status' => InvoiceStatus::Draft,
48
             'status' => InvoiceStatus::Draft,
49
+            'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
50
+            'discount_computation' => AdjustmentComputation::Percentage,
51
+            'discount_rate' => function (array $attributes) {
52
+                $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
53
+
54
+                if ($discountMethod?->isPerDocument()) {
55
+                    return $this->faker->numberBetween(50000, 200000); // 5% - 20%
56
+                }
57
+
58
+                return 0;
59
+            },
46
             'currency_code' => function (array $attributes) {
60
             'currency_code' => function (array $attributes) {
47
                 $client = Client::find($attributes['client_id']);
61
                 $client = Client::find($attributes['client_id']);
48
 
62
 
251
 
265
 
252
         $subtotalCents = $invoice->lineItems()->sum('subtotal');
266
         $subtotalCents = $invoice->lineItems()->sum('subtotal');
253
         $taxTotalCents = $invoice->lineItems()->sum('tax_total');
267
         $taxTotalCents = $invoice->lineItems()->sum('tax_total');
254
-        $discountTotalCents = $invoice->lineItems()->sum('discount_total');
268
+
269
+        $discountTotalCents = 0;
270
+
271
+        if ($invoice->discount_method?->isPerLineItem()) {
272
+            $discountTotalCents = $invoice->lineItems()->sum('discount_total');
273
+        } elseif ($invoice->discount_method?->isPerDocument() && $invoice->discount_rate) {
274
+            if ($invoice->discount_computation?->isPercentage()) {
275
+                $scaledRate = RateCalculator::parseLocalizedRate($invoice->discount_rate);
276
+                $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
277
+            } else {
278
+                $discountTotalCents = CurrencyConverter::convertToCents($invoice->discount_rate, $invoice->currency_code);
279
+            }
280
+        }
255
 
281
 
256
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
282
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
257
         $currencyCode = $invoice->currency_code;
283
         $currencyCode = $invoice->currency_code;

+ 27
- 1
database/factories/Accounting/RecurringInvoiceFactory.php View File

2
 
2
 
3
 namespace Database\Factories\Accounting;
3
 namespace Database\Factories\Accounting;
4
 
4
 
5
+use App\Enums\Accounting\AdjustmentComputation;
5
 use App\Enums\Accounting\DayOfMonth;
6
 use App\Enums\Accounting\DayOfMonth;
6
 use App\Enums\Accounting\DayOfWeek;
7
 use App\Enums\Accounting\DayOfWeek;
8
+use App\Enums\Accounting\DocumentDiscountMethod;
7
 use App\Enums\Accounting\EndType;
9
 use App\Enums\Accounting\EndType;
8
 use App\Enums\Accounting\Frequency;
10
 use App\Enums\Accounting\Frequency;
9
 use App\Enums\Accounting\IntervalType;
11
 use App\Enums\Accounting\IntervalType;
15
 use App\Models\Common\Client;
17
 use App\Models\Common\Client;
16
 use App\Models\Company;
18
 use App\Models\Company;
17
 use App\Utilities\Currency\CurrencyConverter;
19
 use App\Utilities\Currency\CurrencyConverter;
20
+use App\Utilities\RateCalculator;
18
 use Illuminate\Database\Eloquent\Factories\Factory;
21
 use Illuminate\Database\Eloquent\Factories\Factory;
19
 use Illuminate\Support\Carbon;
22
 use Illuminate\Support\Carbon;
20
 
23
 
43
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
46
             'order_number' => $this->faker->unique()->numerify('ORD-####'),
44
             'payment_terms' => PaymentTerms::Net30,
47
             'payment_terms' => PaymentTerms::Net30,
45
             'status' => RecurringInvoiceStatus::Draft,
48
             'status' => RecurringInvoiceStatus::Draft,
49
+            'discount_method' => $this->faker->randomElement(DocumentDiscountMethod::class),
50
+            'discount_computation' => AdjustmentComputation::Percentage,
51
+            'discount_rate' => function (array $attributes) {
52
+                $discountMethod = DocumentDiscountMethod::parse($attributes['discount_method']);
53
+
54
+                if ($discountMethod?->isPerDocument()) {
55
+                    return $this->faker->numberBetween(50000, 200000); // 5% - 20%
56
+                }
57
+
58
+                return 0;
59
+            },
46
             'currency_code' => function (array $attributes) {
60
             'currency_code' => function (array $attributes) {
47
                 $client = Client::find($attributes['client_id']);
61
                 $client = Client::find($attributes['client_id']);
48
 
62
 
301
 
315
 
302
         $subtotalCents = $recurringInvoice->lineItems()->sum('subtotal');
316
         $subtotalCents = $recurringInvoice->lineItems()->sum('subtotal');
303
         $taxTotalCents = $recurringInvoice->lineItems()->sum('tax_total');
317
         $taxTotalCents = $recurringInvoice->lineItems()->sum('tax_total');
304
-        $discountTotalCents = $recurringInvoice->lineItems()->sum('discount_total');
318
+
319
+        $discountTotalCents = 0;
320
+
321
+        if ($recurringInvoice->discount_method?->isPerLineItem()) {
322
+            $discountTotalCents = $recurringInvoice->lineItems()->sum('discount_total');
323
+        } elseif ($recurringInvoice->discount_method?->isPerDocument() && $recurringInvoice->discount_rate) {
324
+            if ($recurringInvoice->discount_computation?->isPercentage()) {
325
+                $scaledRate = RateCalculator::parseLocalizedRate($recurringInvoice->discount_rate);
326
+                $discountTotalCents = RateCalculator::calculatePercentage($subtotalCents, $scaledRate);
327
+            } else {
328
+                $discountTotalCents = CurrencyConverter::convertToCents($recurringInvoice->discount_rate, $recurringInvoice->currency_code);
329
+            }
330
+        }
305
 
331
 
306
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
332
         $grandTotalCents = $subtotalCents + $taxTotalCents - $discountTotalCents;
307
         $currencyCode = $recurringInvoice->currency_code;
333
         $currencyCode = $recurringInvoice->currency_code;

+ 28
- 0
database/migrations/2025_05_03_150845_add_discount_method_to_document_defaults_table.php View File

1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::table('document_defaults', function (Blueprint $table) {
15
+            $table->string('discount_method')->default('per_document')->after('payment_terms');
16
+        });
17
+    }
18
+
19
+    /**
20
+     * Reverse the migrations.
21
+     */
22
+    public function down(): void
23
+    {
24
+        Schema::table('document_defaults', function (Blueprint $table) {
25
+            $table->dropColumn('discount_method');
26
+        });
27
+    }
28
+};

+ 37
- 0
database/migrations/2025_05_03_152233_update_discount_method_defaults_on_document_tables.php View File

1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+
6
+return new class extends Migration
7
+{
8
+    /**
9
+     * Run the migrations.
10
+     */
11
+    public function up(): void
12
+    {
13
+        if (Schema::hasColumn('bills', 'discount_method')) {
14
+            Schema::table('bills', function (Blueprint $table) {
15
+                $table->string('discount_method')->default('per_document')->change();
16
+            });
17
+        }
18
+
19
+        if (Schema::hasColumn('estimates', 'discount_method')) {
20
+            Schema::table('estimates', function (Blueprint $table) {
21
+                $table->string('discount_method')->default('per_document')->change();
22
+            });
23
+        }
24
+
25
+        if (Schema::hasColumn('recurring_invoices', 'discount_method')) {
26
+            Schema::table('recurring_invoices', function (Blueprint $table) {
27
+                $table->string('discount_method')->default('per_document')->change();
28
+            });
29
+        }
30
+
31
+        if (Schema::hasColumn('invoices', 'discount_method')) {
32
+            Schema::table('invoices', function (Blueprint $table) {
33
+                $table->string('discount_method')->default('per_document')->change();
34
+            });
35
+        }
36
+    }
37
+};

+ 28
- 0
database/migrations/2025_05_05_211551_add_sort_order_to_document_line_items.php View File

1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::table('document_line_items', function (Blueprint $table) {
15
+            $table->integer('line_number')->nullable()->after('documentable_id');
16
+        });
17
+    }
18
+
19
+    /**
20
+     * Reverse the migrations.
21
+     */
22
+    public function down(): void
23
+    {
24
+        Schema::table('document_line_items', function (Blueprint $table) {
25
+            $table->dropColumn('line_number');
26
+        });
27
+    }
28
+};

+ 20
- 51
resources/css/filament/company/form-fields.css View File

42
 }
42
 }
43
 
43
 
44
 /* Table Repeater Styles */
44
 /* Table Repeater Styles */
45
-:not(.is-spreadsheet) {
46
-    .table-repeater-container {
47
-        @apply rounded-none ring-0;
45
+.table-repeater-component:not(.is-spreadsheet) {
46
+    .table-repeater-row {
47
+        @apply hover:bg-gray-50 dark:hover:bg-white/5 transition-colors;
48
     }
48
     }
49
 
49
 
50
-    .table-repeater-component {
51
-        @apply space-y-10;
50
+    .table-repeater-column {
51
+        @apply p-3;
52
     }
52
     }
53
 
53
 
54
-    .table-repeater-component ul {
55
-        @apply justify-start;
54
+    .table-repeater-header {
55
+        @apply bg-gray-50 dark:bg-white/5 border-b border-gray-200 dark:border-white/5;
56
     }
56
     }
57
 
57
 
58
     .table-repeater-row {
58
     .table-repeater-row {
59
         @apply divide-x-0 !important;
59
         @apply divide-x-0 !important;
60
     }
60
     }
61
 
61
 
62
-    .table-repeater-column {
63
-        @apply py-2 !important;
64
-    }
65
-
66
-    .table-repeater-header {
67
-        @apply rounded-t-none !important;
62
+    .table-repeater-header tr {
63
+        @apply divide-x-0 text-sm;
68
     }
64
     }
69
 
65
 
70
-    .table-repeater-rows-wrapper {
71
-        @apply divide-gray-300 last:border-b last:border-gray-300 dark:divide-white/20 dark:last:border-white/20;
66
+    .table-repeater-header-column {
67
+        @apply p-3 text-sm font-semibold text-gray-950 dark:text-white bg-gray-50 dark:bg-white/5;
72
     }
68
     }
73
 
69
 
74
-    .table-repeater-header tr {
75
-        @apply divide-x-0 text-base sm:text-sm sm:leading-6 !important;
70
+    /* Chrome, Safari, Edge, Opera */
71
+    input::-webkit-outer-spin-button,
72
+    input::-webkit-inner-spin-button {
73
+        -webkit-appearance: none;
74
+        margin: 0;
76
     }
75
     }
77
 
76
 
78
-    .table-repeater-header-column {
79
-        @apply ps-3 pe-3 font-semibold bg-gray-200 dark:bg-gray-800 rounded-none !important;
77
+    /* Firefox */
78
+    input[type=number] {
79
+        -moz-appearance: textfield;
80
     }
80
     }
81
 }
81
 }
82
 
82
 
83
 /* Excel/Spreadsheet styling */
83
 /* Excel/Spreadsheet styling */
84
-.is-spreadsheet {
84
+.table-repeater-component.is-spreadsheet {
85
     .table-repeater-container {
85
     .table-repeater-container {
86
         overflow-x: auto;
86
         overflow-x: auto;
87
         max-width: 100%;
87
         max-width: 100%;
165
     }
165
     }
166
 }
166
 }
167
 
167
 
168
-/* Responsive behavior */
169
-@media (max-width: theme('screens.sm')) {
170
-    .table-repeater-component.break-point-sm .table-repeater-container {
171
-        overflow-x: visible;
172
-    }
173
-}
174
-
175
-@media (max-width: theme('screens.md')) {
176
-    .table-repeater-component.break-point-md .table-repeater-container {
177
-        overflow-x: visible;
178
-    }
179
-}
180
-
181
-@media (max-width: theme('screens.lg')) {
182
-    .table-repeater-component.break-point-lg .table-repeater-container {
183
-        overflow-x: visible;
184
-    }
185
-}
186
-
187
-@media (max-width: theme('screens.xl')) {
188
-    .table-repeater-component.break-point-xl .table-repeater-container {
189
-        overflow-x: visible;
190
-    }
191
-}
192
-
193
-@media (max-width: theme('screens.2xl')) {
194
-    .table-repeater-component.break-point-2xl .table-repeater-container {
195
-        overflow-x: visible;
196
-    }
197
-}
198
-
199
 .is-spreadsheet .table-repeater-column .fi-input-wrp-suffix {
168
 .is-spreadsheet .table-repeater-column .fi-input-wrp-suffix {
200
     padding-right: 0 !important;
169
     padding-right: 0 !important;
201
 }
170
 }

+ 1
- 1
resources/views/components/company/document-template/container.blade.php View File

6
     <div
6
     <div
7
         @class([
7
         @class([
8
             'doc-template-paper bg-[#ffffff] dark:bg-gray-800 rounded-sm shadow-xl',
8
             'doc-template-paper bg-[#ffffff] dark:bg-gray-800 rounded-sm shadow-xl',
9
-            'w-full max-w-[820px] max-h-[1024px] overflow-y-auto' => $preview === false,
9
+            'w-full max-w-[820px] min-h-[1066px] max-h-[1200px] overflow-y-auto' => $preview === false,
10
             'w-[38.25rem] h-[49.5rem] overflow-hidden' => $preview === true,
10
             'w-[38.25rem] h-[49.5rem] overflow-hidden' => $preview === true,
11
         ])
11
         ])
12
         @style([
12
         @style([

+ 1
- 1
resources/views/filament/company/components/document-templates/default.blade.php View File

128
     </x-company.document-template.line-items>
128
     </x-company.document-template.line-items>
129
 
129
 
130
     <!-- Footer Notes -->
130
     <!-- Footer Notes -->
131
-    <x-company.document-template.footer class="classic-template-footer min-h-48 flex flex-col text-xs p-6">
131
+    <x-company.document-template.footer class="default-template-footer min-h-48 flex flex-col text-xs p-6">
132
         <div>
132
         <div>
133
             <h4 class="font-semibold mb-2">Terms & Conditions</h4>
133
             <h4 class="font-semibold mb-2">Terms & Conditions</h4>
134
             <p class="break-words line-clamp-4">{{ $document->terms }}</p>
134
             <p class="break-words line-clamp-4">{{ $document->terms }}</p>

+ 251
- 0
resources/views/filament/forms/components/custom-table-repeater.blade.php View File

1
+@php
2
+    use Filament\Forms\Components\Actions\Action;
3
+    use Filament\Support\Enums\Alignment;
4
+    use Filament\Support\Enums\MaxWidth;
5
+
6
+    $containers = $getChildComponentContainers();
7
+
8
+    $addAction = $getAction($getAddActionName());
9
+    $cloneAction = $getAction($getCloneActionName());
10
+    $deleteAction = $getAction($getDeleteActionName());
11
+    $moveDownAction = $getAction($getMoveDownActionName());
12
+    $moveUpAction = $getAction($getMoveUpActionName());
13
+    $reorderAction = $getAction($getReorderActionName());
14
+    $isReorderableWithButtons = $isReorderableWithButtons();
15
+    $extraItemActions = $getExtraItemActions();
16
+    $extraActions = $getExtraActions();
17
+    $visibleExtraItemActions = [];
18
+    $visibleExtraActions = [];
19
+
20
+    $headers = $getHeaders();
21
+    $renderHeader = $shouldRenderHeader();
22
+    $stackAt = $getStackAt();
23
+    $hasContainers = count($containers) > 0;
24
+    $emptyLabel = $getEmptyLabel();
25
+    $streamlined = $isStreamlined();
26
+
27
+    $reorderAtStart = $isReorderAtStart();
28
+
29
+    $statePath = $getStatePath();
30
+
31
+    foreach ($extraActions as $extraAction) {
32
+        $visibleExtraActions = array_filter(
33
+            $extraActions,
34
+            fn (Action $action): bool => $action->isVisible(),
35
+        );
36
+    }
37
+
38
+    foreach ($extraItemActions as $extraItemAction) {
39
+        $visibleExtraItemActions = array_filter(
40
+            $extraItemActions,
41
+            fn (Action $action): bool => $action->isVisible(),
42
+        );
43
+    }
44
+
45
+    $hasActions = $reorderAction->isVisible()
46
+        || $cloneAction->isVisible()
47
+        || $deleteAction->isVisible()
48
+        || $moveUpAction->isVisible()
49
+        || $moveDownAction->isVisible()
50
+        || filled($visibleExtraItemActions);
51
+@endphp
52
+
53
+<x-dynamic-component :component="$getFieldWrapperView()" :field="$field">
54
+    <div
55
+        x-data="{}"
56
+        {{ $attributes->merge($getExtraAttributes())->class([
57
+            'table-repeater-component space-y-6 relative',
58
+            'streamlined' => $streamlined,
59
+            match ($stackAt) {
60
+                'sm', MaxWidth::Small => 'break-point-sm',
61
+                'lg', MaxWidth::Large => 'break-point-lg',
62
+                'xl', MaxWidth::ExtraLarge => 'break-point-xl',
63
+                '2xl', MaxWidth::TwoExtraLarge => 'break-point-2xl',
64
+                default => 'break-point-md',
65
+            }
66
+        ]) }}
67
+    >
68
+        @if (count($containers) || $emptyLabel !== false)
69
+            <div class="table-repeater-container rounded-sm relative ring-1 ring-gray-950/5 dark:ring-white/20">
70
+                <table class="w-full">
71
+                    <thead @class([
72
+                        'table-repeater-header-hidden sr-only' => ! $renderHeader,
73
+                        'table-repeater-header overflow-hidden border-b border-gray-950/5 dark:border-white/20' => $renderHeader,
74
+                    ])>
75
+                    <tr class="text-xs md:divide-x rtl:divide-x-reverse md:divide-gray-950/5 dark:md:divide-white/20">
76
+                        {{-- Move actions column to start if reorderAtStart is true --}}
77
+                        @if ($hasActions && count($containers) && $reorderAtStart)
78
+                            <th class="table-repeater-header-column w-px first:rounded-tl-sm rtl:first:rounded-tr-sm rtl:first:rounded-tl-none p-2 bg-gray-100 dark:bg-gray-900/60">
79
+                                <span class="sr-only">
80
+                                    {{ trans('table-repeater::components.repeater.row_actions.label') }}
81
+                                </span>
82
+                            </th>
83
+                        @endif
84
+
85
+                        @foreach ($headers as $key => $header)
86
+                            <th
87
+                                @class([
88
+                                    'table-repeater-header-column p-2 font-medium first:rounded-tl-sm rtl:first:rounded-tr-sm rtl:first:rounded-tl-none last:rounded-tr-sm bg-gray-100 dark:text-gray-300 dark:bg-gray-900/60',
89
+                                    match($header->getAlignment()) {
90
+                                      'center', Alignment::Center => 'text-center',
91
+                                      'right', 'end', Alignment::Right, Alignment::End => 'text-end',
92
+                                      default => 'text-start'
93
+                                    }
94
+                                ])
95
+                                style="width: {{ $header->getWidth() }}"
96
+                            >
97
+                                {{ $header->getLabel() }}
98
+                                @if ($header->isRequired())
99
+                                    <span class="whitespace-nowrap">
100
+                                        <sup class="font-medium text-danger-700 dark:text-danger-400">*</sup>
101
+                                    </span>
102
+                                @endif
103
+                            </th>
104
+                        @endforeach
105
+
106
+                        @if ($hasActions && count($containers))
107
+                            <th class="table-repeater-header-column w-px last:rounded-tr-sm rtl:last:rounded-tr-none rtl:last:rounded-tl-sm p-2 bg-gray-100 dark:bg-gray-900/60">
108
+                                <span class="sr-only">
109
+                                    {{ trans('table-repeater::components.repeater.row_actions.label') }}
110
+                                </span>
111
+                            </th>
112
+                        @endif
113
+                    </tr>
114
+                    </thead>
115
+                    <tbody
116
+                        x-sortable
117
+                        wire:end.stop="{{ 'mountFormComponentAction(\'' . $statePath . '\', \'reorder\', { items: $event.target.sortable.toArray() })' }}"
118
+                        class="table-repeater-rows-wrapper divide-y divide-gray-950/5 dark:divide-white/20"
119
+                    >
120
+                    @if (count($containers))
121
+                        @foreach ($containers as $uuid => $row)
122
+                            @php
123
+                                $visibleExtraItemActions = array_filter(
124
+                                    $extraItemActions,
125
+                                    fn (Action $action): bool => $action(['item' => $uuid])->isVisible(),
126
+                                );
127
+                            @endphp
128
+                            <tr
129
+                                wire:key="{{ $this->getId() }}.{{ $row->getStatePath() }}.{{ $field::class }}.item"
130
+                                x-sortable-item="{{ $uuid }}"
131
+                                class="table-repeater-row"
132
+                            >
133
+                                {{-- Add reorder action column at start if reorderAtStart is true --}}
134
+                                @if ($hasActions && $reorderAtStart && $reorderAction->isVisible())
135
+                                    <td class="table-repeater-column p-2 w-px align-top">
136
+                                        <ul class="flex items-center table-repeater-row-actions gap-x-3 px-2">
137
+                                            <li x-sortable-handle class="shrink-0">
138
+                                                {{ $reorderAction }}
139
+                                            </li>
140
+                                        </ul>
141
+                                    </td>
142
+                                @endif
143
+
144
+                                @php($counter = 0)
145
+                                @foreach($row->getComponents() as $cell)
146
+                                    @if($cell instanceof \Filament\Forms\Components\Hidden || $cell->isHidden())
147
+                                        {{ $cell }}
148
+                                    @else
149
+                                        <td
150
+                                            @class([
151
+                                                'table-repeater-column align-top',
152
+                                                'p-2' => ! $streamlined,
153
+                                                'has-hidden-label' => $cell->isLabelHidden(),
154
+                                                match($headers[$counter++]->getAlignment()) {
155
+                                                  'center', Alignment::Center => 'text-center',
156
+                                                  'right', 'end', Alignment::Right, Alignment::End => 'text-end',
157
+                                                  default => 'text-start'
158
+                                                }
159
+                                            ])
160
+                                            style="width: {{ $cell->getMaxWidth() ?? 'auto' }}"
161
+                                        >
162
+                                            {{ $cell }}
163
+                                        </td>
164
+                                    @endif
165
+                                @endforeach
166
+
167
+                                @if ($hasActions)
168
+                                    <td class="table-repeater-column p-2 w-px align-top">
169
+                                        <ul class="flex items-center table-repeater-row-actions gap-x-3 px-2">
170
+                                            @foreach ($visibleExtraItemActions as $extraItemAction)
171
+                                                <li>
172
+                                                    {{ $extraItemAction(['item' => $uuid]) }}
173
+                                                </li>
174
+                                            @endforeach
175
+
176
+                                            @if ($reorderAction->isVisible() && ! $reorderAtStart)
177
+                                                <li x-sortable-handle class="shrink-0">
178
+                                                    {{ $reorderAction }}
179
+                                                </li>
180
+                                            @endif
181
+
182
+                                            @if ($isReorderableWithButtons)
183
+                                                @if (! $loop->first)
184
+                                                    <li>
185
+                                                        {{ $moveUpAction(['item' => $uuid]) }}
186
+                                                    </li>
187
+                                                @endif
188
+
189
+                                                @if (! $loop->last)
190
+                                                    <li>
191
+                                                        {{ $moveDownAction(['item' => $uuid]) }}
192
+                                                    </li>
193
+                                                @endif
194
+                                            @endif
195
+
196
+                                            @if ($cloneAction->isVisible())
197
+                                                <li>
198
+                                                    {{ $cloneAction(['item' => $uuid]) }}
199
+                                                </li>
200
+                                            @endif
201
+
202
+                                            @if ($deleteAction->isVisible())
203
+                                                <li>
204
+                                                    {{ $deleteAction(['item' => $uuid]) }}
205
+                                                </li>
206
+                                            @endif
207
+                                        </ul>
208
+                                    </td>
209
+                                @endif
210
+                            </tr>
211
+                        @endforeach
212
+                    @else
213
+                        <tr class="table-repeater-row table-repeater-empty-row">
214
+                            <td colspan="{{ count($headers) + intval($hasActions) }}"
215
+                                class="table-repeater-column table-repeater-empty-column p-4 w-px text-center italic">
216
+                                {{ $emptyLabel ?: trans('table-repeater::components.repeater.empty.label') }}
217
+                            </td>
218
+                        </tr>
219
+                    @endif
220
+                    </tbody>
221
+                </table>
222
+            </div>
223
+        @endif
224
+
225
+        @if ($addAction->isVisible() || filled($visibleExtraActions))
226
+            <ul
227
+                @class([
228
+                    'relative flex gap-4',
229
+                    match ($getAddActionAlignment()) {
230
+                        Alignment::Start, Alignment::Left => 'justify-start',
231
+                        Alignment::End, Alignment::Right => 'justify-end',
232
+                        default =>  'justify-center',
233
+                    },
234
+                ])
235
+            >
236
+                @if ($addAction->isVisible())
237
+                    <li>
238
+                        {{ $addAction }}
239
+                    </li>
240
+                @endif
241
+                @if (filled($visibleExtraActions))
242
+                    @foreach ($visibleExtraActions as $extraAction)
243
+                        <li>
244
+                            {{ ($extraAction) }}
245
+                        </li>
246
+                    @endforeach
247
+                @endif
248
+            </ul>
249
+        @endif
250
+    </div>
251
+</x-dynamic-component>

+ 7
- 5
resources/views/filament/infolists/components/document-templates/classic.blade.php View File

107
             <div class="w-[40%]">
107
             <div class="w-[40%]">
108
                 <table class="w-full table-fixed whitespace-nowrap">
108
                 <table class="w-full table-fixed whitespace-nowrap">
109
                     <tbody class="text-sm">
109
                     <tbody class="text-sm">
110
-                    <tr>
111
-                        <td class="text-right font-semibold py-2">Subtotal:</td>
112
-                        <td class="text-right py-2">{{ $document->subtotal }}</td>
113
-                    </tr>
110
+                    @if($document->subtotal)
111
+                        <tr>
112
+                            <td class="text-right font-semibold py-2">Subtotal:</td>
113
+                            <td class="text-right py-2">{{ $document->subtotal }}</td>
114
+                        </tr>
115
+                    @endif
114
                     @if($document->discount)
116
                     @if($document->discount)
115
                         <tr class="text-success-800 dark:text-success-600">
117
                         <tr class="text-success-800 dark:text-success-600">
116
                             <td class="text-right py-2">Discount:</td>
118
                             <td class="text-right py-2">Discount:</td>
144
     </x-company.document-template.line-items>
146
     </x-company.document-template.line-items>
145
 
147
 
146
     <!-- Footer -->
148
     <!-- Footer -->
147
-    <x-company.document-template.footer class="classic-template-footer min-h-48 p-6 text-sm">
149
+    <x-company.document-template.footer class="default-template-footer min-h-48 p-6 text-sm">
148
         <h4 class="font-semibold mb-2">Terms & Conditions</h4>
150
         <h4 class="font-semibold mb-2">Terms & Conditions</h4>
149
         <p class="break-words line-clamp-4">{{ $document->terms }}</p>
151
         <p class="break-words line-clamp-4">{{ $document->terms }}</p>
150
     </x-company.document-template.footer>
152
     </x-company.document-template.footer>

+ 7
- 5
resources/views/filament/infolists/components/document-templates/default.blade.php View File

90
             @endforeach
90
             @endforeach
91
             </tbody>
91
             </tbody>
92
             <tfoot class="text-sm summary-section">
92
             <tfoot class="text-sm summary-section">
93
-            <tr>
94
-                <td class="pl-6 py-2" colspan="2"></td>
95
-                <td class="text-right font-semibold py-2">Subtotal:</td>
96
-                <td class="text-right pr-6 py-2">{{ $document->subtotal }}</td>
97
-            </tr>
93
+            @if($document->subtotal)
94
+                <tr>
95
+                    <td class="pl-6 py-2" colspan="2"></td>
96
+                    <td class="text-right font-semibold py-2">Subtotal:</td>
97
+                    <td class="text-right pr-6 py-2">{{ $document->subtotal }}</td>
98
+                </tr>
99
+            @endif
98
             @if($document->discount)
100
             @if($document->discount)
99
                 <tr class="text-success-800 dark:text-success-600">
101
                 <tr class="text-success-800 dark:text-success-600">
100
                     <td class="pl-6 py-2" colspan="2"></td>
102
                     <td class="pl-6 py-2" colspan="2"></td>

+ 7
- 5
resources/views/filament/infolists/components/document-templates/modern.blade.php View File

92
             @endforeach
92
             @endforeach
93
             </tbody>
93
             </tbody>
94
             <tfoot class="text-sm summary-section">
94
             <tfoot class="text-sm summary-section">
95
-            <tr>
96
-                <td class="pl-6 py-2" colspan="2"></td>
97
-                <td class="text-right font-semibold py-2">Subtotal:</td>
98
-                <td class="text-right pr-6 py-2">{{ $document->subtotal }}</td>
99
-            </tr>
95
+            @if($document->subtotal)
96
+                <tr>
97
+                    <td class="pl-6 py-2" colspan="2"></td>
98
+                    <td class="text-right font-semibold py-2">Subtotal:</td>
99
+                    <td class="text-right pr-6 py-2">{{ $document->subtotal }}</td>
100
+                </tr>
101
+            @endif
100
             @if($document->discount)
102
             @if($document->discount)
101
                 <tr class="text-success-800 dark:text-success-600">
103
                 <tr class="text-success-800 dark:text-success-600">
102
                     <td class="pl-6 py-2" colspan="2"></td>
104
                     <td class="pl-6 py-2" colspan="2"></td>

Loading…
Cancel
Save