瀏覽代碼

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

Add Bills and Invoices Management with Full Accounting Integration
3.x
Andrew Wallo 10 月之前
父節點
當前提交
f50cf871ef
沒有連結到貢獻者的電子郵件帳戶。
共有 100 個檔案被更改,包括 5709 行新增758 行删除
  1. 46
    0
      app/Casts/DocumentMoneyCast.php
  2. 36
    0
      app/Collections/Accounting/InvoiceCollection.php
  3. 11
    0
      app/Concerns/RedirectToListPage.php
  4. 31
    0
      app/Console/Commands/UpdateOverdueInvoices.php
  5. 2
    0
      app/Contracts/HasSummaryReport.php
  6. 1
    1
      app/DTO/AccountTransactionDTO.php
  7. 29
    0
      app/Enums/Accounting/AdjustmentCategory.php
  8. 2
    4
      app/Enums/Accounting/AdjustmentComputation.php
  9. 2
    4
      app/Enums/Accounting/AdjustmentScope.php
  10. 15
    7
      app/Enums/Accounting/AdjustmentType.php
  11. 38
    0
      app/Enums/Accounting/BillStatus.php
  12. 48
    0
      app/Enums/Accounting/InvoiceStatus.php
  13. 27
    0
      app/Enums/Accounting/PaymentMethod.php
  14. 17
    0
      app/Enums/Common/AddressType.php
  15. 19
    0
      app/Enums/Common/ContractorType.php
  16. 25
    0
      app/Enums/Common/OfferingType.php
  17. 31
    0
      app/Enums/Common/VendorType.php
  18. 5
    1
      app/Enums/Concerns/ParsesEnum.php
  19. 0
    16
      app/Enums/Setting/DiscountScope.php
  20. 0
    19
      app/Enums/Setting/TaxComputation.php
  21. 0
    39
      app/Enums/Setting/TaxType.php
  22. 2
    18
      app/Filament/Company/Clusters/Settings/Pages/Appearance.php
  23. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/CompanyDefault.php
  24. 2
    0
      app/Filament/Company/Clusters/Settings/Pages/Localization.php
  25. 130
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource.php
  26. 11
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php
  27. 18
    0
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php
  28. 4
    4
      app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/ListAdjustments.php
  29. 0
    189
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource.php
  30. 0
    16
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/CreateDiscount.php
  31. 0
    24
      app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/EditDiscount.php
  32. 0
    158
      app/Filament/Company/Clusters/Settings/Resources/TaxResource.php
  33. 0
    16
      app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/CreateTax.php
  34. 0
    24
      app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/EditTax.php
  35. 2
    0
      app/Filament/Company/Pages/Accounting/AccountChart.php
  36. 22
    15
      app/Filament/Company/Pages/Accounting/Transactions.php
  37. 24
    0
      app/Filament/Company/Pages/Concerns/HasReportTabs.php
  38. 22
    0
      app/Filament/Company/Pages/CreateCompany.php
  39. 28
    8
      app/Filament/Company/Pages/Reports.php
  40. 0
    4
      app/Filament/Company/Pages/Reports/AccountBalances.php
  41. 43
    7
      app/Filament/Company/Pages/Reports/AccountTransactions.php
  42. 5
    8
      app/Filament/Company/Pages/Reports/BalanceSheet.php
  43. 26
    0
      app/Filament/Company/Pages/Reports/BaseReportPage.php
  44. 5
    10
      app/Filament/Company/Pages/Reports/CashFlowStatement.php
  45. 5
    10
      app/Filament/Company/Pages/Reports/IncomeStatement.php
  46. 0
    4
      app/Filament/Company/Pages/Reports/TrialBalance.php
  47. 6
    0
      app/Filament/Company/Resources/Banking/AccountResource/Pages/ListAccounts.php
  48. 195
    0
      app/Filament/Company/Resources/Common/OfferingResource.php
  49. 27
    0
      app/Filament/Company/Resources/Common/OfferingResource/Pages/CreateOffering.php
  50. 45
    0
      app/Filament/Company/Resources/Common/OfferingResource/Pages/EditOffering.php
  51. 5
    5
      app/Filament/Company/Resources/Common/OfferingResource/Pages/ListOfferings.php
  52. 579
    0
      app/Filament/Company/Resources/Purchases/BillResource.php
  53. 20
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php
  54. 28
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/EditBill.php
  55. 61
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ListBills.php
  56. 78
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/ViewBill.php
  57. 182
    0
      app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php
  58. 69
    0
      app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php
  59. 220
    0
      app/Filament/Company/Resources/Purchases/VendorResource.php
  60. 20
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/CreateVendor.php
  61. 28
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/EditVendor.php
  62. 25
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/ListVendors.php
  63. 294
    0
      app/Filament/Company/Resources/Sales/ClientResource.php
  64. 20
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php
  65. 28
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php
  66. 25
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/ListClients.php
  67. 706
    0
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  68. 20
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php
  69. 28
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/EditInvoice.php
  70. 62
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php
  71. 88
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ViewInvoice.php
  72. 187
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/RelationManagers/PaymentsRelationManager.php
  73. 69
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Widgets/InvoiceOverview.php
  74. 13
    0
      app/Filament/Forms/Components/CustomSection.php
  75. 165
    0
      app/Filament/Forms/Components/LineItemRepeater.php
  76. 10
    0
      app/Filament/Forms/Components/PhoneBuilder.php
  77. 31
    5
      app/Filament/Tables/Actions/ReplicateBulkAction.php
  78. 172
    0
      app/Filament/Tables/Filters/DateRangeFilter.php
  79. 12
    0
      app/Filament/Widgets/EnhancedStatsOverviewWidget.php
  80. 47
    0
      app/Filament/Widgets/EnhancedStatsOverviewWidget/EnhancedStat.php
  81. 30
    0
      app/Jobs/ProcessOverdueInvoices.php
  82. 13
    35
      app/Listeners/ConfigureCompanyDefault.php
  83. 0
    4
      app/Listeners/SyncAssociatedModels.php
  84. 0
    29
      app/Listeners/SyncWithCompanyDefaults.php
  85. 18
    3
      app/Models/Accounting/Account.php
  86. 98
    0
      app/Models/Accounting/Adjustment.php
  87. 311
    0
      app/Models/Accounting/Bill.php
  88. 132
    0
      app/Models/Accounting/DocumentLineItem.php
  89. 384
    0
      app/Models/Accounting/Invoice.php
  90. 10
    0
      app/Models/Accounting/Transaction.php
  91. 5
    4
      app/Models/Banking/BankAccount.php
  92. 44
    0
      app/Models/Common/Address.php
  93. 79
    0
      app/Models/Common/Client.php
  94. 72
    22
      app/Models/Common/Contact.php
  95. 114
    0
      app/Models/Common/Offering.php
  96. 66
    0
      app/Models/Common/Vendor.php
  97. 33
    8
      app/Models/Company.php
  98. 0
    6
      app/Models/Setting/Appearance.php
  99. 0
    30
      app/Models/Setting/CompanyDefault.php
  100. 0
    0
      app/Models/Setting/Discount.php

+ 46
- 0
app/Casts/DocumentMoneyCast.php 查看文件

1
+<?php
2
+
3
+namespace App\Casts;
4
+
5
+use App\Utilities\Currency\CurrencyAccessor;
6
+use App\Utilities\Currency\CurrencyConverter;
7
+use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
8
+use Illuminate\Database\Eloquent\Model;
9
+use UnexpectedValueException;
10
+
11
+class DocumentMoneyCast implements CastsAttributes
12
+{
13
+    /**
14
+     * Cast the given value.
15
+     *
16
+     * @param  array<string, mixed>  $attributes
17
+     */
18
+    public function get(Model $model, string $key, mixed $value, array $attributes): mixed
19
+    {
20
+        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
21
+
22
+        if ($value !== null) {
23
+            return CurrencyConverter::convertCentsToFloat($value, $currency_code);
24
+        }
25
+
26
+        return 0.0;
27
+    }
28
+
29
+    /**
30
+     * Prepare the given value for storage.
31
+     *
32
+     * @param  array<string, mixed>  $attributes
33
+     */
34
+    public function set(Model $model, string $key, mixed $value, array $attributes): mixed
35
+    {
36
+        $currency_code = $attributes['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
37
+
38
+        if (is_numeric($value)) {
39
+            $value = (string) $value;
40
+        } elseif (! is_string($value)) {
41
+            throw new UnexpectedValueException('Expected string or numeric value for money cast');
42
+        }
43
+
44
+        return CurrencyConverter::prepareForAccessor($value, $currency_code);
45
+    }
46
+}

+ 36
- 0
app/Collections/Accounting/InvoiceCollection.php 查看文件

1
+<?php
2
+
3
+namespace App\Collections\Accounting;
4
+
5
+use App\Models\Accounting\Invoice;
6
+use App\Utilities\Currency\CurrencyAccessor;
7
+use App\Utilities\Currency\CurrencyConverter;
8
+use Illuminate\Database\Eloquent\Collection;
9
+
10
+class InvoiceCollection extends Collection
11
+{
12
+    public function sumMoneyInCents(string $column): int
13
+    {
14
+        return $this->reduce(static function ($carry, Invoice $invoice) use ($column) {
15
+            return $carry + $invoice->getRawOriginal($column);
16
+        }, 0);
17
+    }
18
+
19
+    public function sumMoneyFormattedSimple(string $column, ?string $currency = null): string
20
+    {
21
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
22
+
23
+        $totalCents = $this->sumMoneyInCents($column);
24
+
25
+        return CurrencyConverter::convertCentsToFormatSimple($totalCents, $currency);
26
+    }
27
+
28
+    public function sumMoneyFormatted(string $column, ?string $currency = null): string
29
+    {
30
+        $currency ??= CurrencyAccessor::getDefaultCurrency();
31
+
32
+        $totalCents = $this->sumMoneyInCents($column);
33
+
34
+        return CurrencyConverter::formatCentsToMoney($totalCents, $currency);
35
+    }
36
+}

+ 11
- 0
app/Concerns/RedirectToListPage.php 查看文件

1
+<?php
2
+
3
+namespace App\Concerns;
4
+
5
+trait RedirectToListPage
6
+{
7
+    protected function getRedirectUrl(): string
8
+    {
9
+        return $this->getResource()::getUrl('index');
10
+    }
11
+}

+ 31
- 0
app/Console/Commands/UpdateOverdueInvoices.php 查看文件

1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Jobs\ProcessOverdueInvoices;
6
+use Illuminate\Console\Command;
7
+
8
+class UpdateOverdueInvoices extends Command
9
+{
10
+    /**
11
+     * The name and signature of the console command.
12
+     *
13
+     * @var string
14
+     */
15
+    protected $signature = 'app:update-overdue-invoices';
16
+
17
+    /**
18
+     * The console command description.
19
+     *
20
+     * @var string
21
+     */
22
+    protected $description = 'Check and mark overdue invoices as overdue';
23
+
24
+    /**
25
+     * Execute the console command.
26
+     */
27
+    public function handle(): void
28
+    {
29
+        ProcessOverdueInvoices::dispatch();
30
+    }
31
+}

+ 2
- 0
app/Contracts/HasSummaryReport.php 查看文件

20
     public function getSummaryCategories(): array;
20
     public function getSummaryCategories(): array;
21
 
21
 
22
     public function getSummaryOverallTotals(): array;
22
     public function getSummaryOverallTotals(): array;
23
+
24
+    public function getSummaryPdfView(): string;
23
 }
25
 }

+ 1
- 1
app/DTO/AccountTransactionDTO.php 查看文件

14
         public string $credit,
14
         public string $credit,
15
         public string $balance,
15
         public string $balance,
16
         public ?TransactionType $type,
16
         public ?TransactionType $type,
17
-        public ?string $tableAction,
17
+        public ?array $tableAction,
18
     ) {}
18
     ) {}
19
 }
19
 }

+ 29
- 0
app/Enums/Accounting/AdjustmentCategory.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum AdjustmentCategory: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Tax = 'tax';
13
+    case Discount = 'discount';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+
20
+    public function isTax(): bool
21
+    {
22
+        return $this === self::Tax;
23
+    }
24
+
25
+    public function isDiscount(): bool
26
+    {
27
+        return $this === self::Discount;
28
+    }
29
+}

app/Enums/Setting/DiscountComputation.php → app/Enums/Accounting/AdjustmentComputation.php 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4
 
4
 
5
 use Filament\Support\Contracts\HasLabel;
5
 use Filament\Support\Contracts\HasLabel;
6
 
6
 
7
-enum DiscountComputation: string implements HasLabel
7
+enum AdjustmentComputation: string implements HasLabel
8
 {
8
 {
9
     case Percentage = 'percentage';
9
     case Percentage = 'percentage';
10
     case Fixed = 'fixed';
10
     case Fixed = 'fixed';
11
 
11
 
12
-    public const DEFAULT = self::Percentage->value;
13
-
14
     public function getLabel(): ?string
12
     public function getLabel(): ?string
15
     {
13
     {
16
         return translate($this->name);
14
         return translate($this->name);

app/Enums/Setting/TaxScope.php → app/Enums/Accounting/AdjustmentScope.php 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4
 
4
 
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum TaxScope: string implements HasLabel
5
+enum AdjustmentScope: string
8
 {
6
 {
9
     case Product = 'product';
7
     case Product = 'product';
10
     case Service = 'service';
8
     case Service = 'service';

app/Enums/Setting/DiscountType.php → app/Enums/Accounting/AdjustmentType.php 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Enums\Setting;
3
+namespace App\Enums\Accounting;
4
 
4
 
5
+use App\Enums\Concerns\ParsesEnum;
5
 use Filament\Support\Contracts\HasColor;
6
 use Filament\Support\Contracts\HasColor;
6
 use Filament\Support\Contracts\HasIcon;
7
 use Filament\Support\Contracts\HasIcon;
7
 use Filament\Support\Contracts\HasLabel;
8
 use Filament\Support\Contracts\HasLabel;
8
 
9
 
9
-enum DiscountType: string implements HasColor, HasIcon, HasLabel
10
+enum AdjustmentType: string implements HasColor, HasIcon, HasLabel
10
 {
11
 {
12
+    use ParsesEnum;
13
+
11
     case Sales = 'sales';
14
     case Sales = 'sales';
12
     case Purchase = 'purchase';
15
     case Purchase = 'purchase';
13
-    case None = 'none';
14
-
15
-    public const DEFAULT = self::Sales->value;
16
 
16
 
17
     public function getLabel(): ?string
17
     public function getLabel(): ?string
18
     {
18
     {
24
         return match ($this) {
24
         return match ($this) {
25
             self::Sales => 'success',
25
             self::Sales => 'success',
26
             self::Purchase => 'warning',
26
             self::Purchase => 'warning',
27
-            self::None => 'gray',
28
         };
27
         };
29
     }
28
     }
30
 
29
 
33
         return match ($this) {
32
         return match ($this) {
34
             self::Sales => 'heroicon-o-currency-dollar',
33
             self::Sales => 'heroicon-o-currency-dollar',
35
             self::Purchase => 'heroicon-o-shopping-bag',
34
             self::Purchase => 'heroicon-o-shopping-bag',
36
-            self::None => 'heroicon-o-x-circle',
37
         };
35
         };
38
     }
36
     }
37
+
38
+    public function isSales(): bool
39
+    {
40
+        return $this === self::Sales;
41
+    }
42
+
43
+    public function isPurchase(): bool
44
+    {
45
+        return $this === self::Purchase;
46
+    }
39
 }
47
 }

+ 38
- 0
app/Enums/Accounting/BillStatus.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BillStatus: string implements HasColor, HasLabel
9
+{
10
+    case Overdue = 'overdue';
11
+    case Partial = 'partial';
12
+    case Paid = 'paid';
13
+    case Unpaid = 'unpaid';
14
+    case Void = 'void';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return $this->name;
19
+    }
20
+
21
+    public function getColor(): string | array | null
22
+    {
23
+        return match ($this) {
24
+            self::Overdue => 'danger',
25
+            self::Partial, self::Unpaid => 'warning',
26
+            self::Paid => 'success',
27
+            self::Void => 'gray',
28
+        };
29
+    }
30
+
31
+    public static function canBeOverdue(): array
32
+    {
33
+        return [
34
+            self::Partial,
35
+            self::Unpaid,
36
+        ];
37
+    }
38
+}

+ 48
- 0
app/Enums/Accounting/InvoiceStatus.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum InvoiceStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Unsent = 'unsent';
12
+    case Sent = 'sent';
13
+
14
+    case Partial = 'partial';
15
+
16
+    case Paid = 'paid';
17
+
18
+    case Overdue = 'overdue';
19
+
20
+    case Overpaid = 'overpaid';
21
+
22
+    case Void = 'void';
23
+
24
+    public function getLabel(): ?string
25
+    {
26
+        return $this->name;
27
+    }
28
+
29
+    public function getColor(): string | array | null
30
+    {
31
+        return match ($this) {
32
+            self::Draft, self::Unsent, self::Void => 'gray',
33
+            self::Sent => 'primary',
34
+            self::Partial => 'warning',
35
+            self::Paid, self::Overpaid => 'success',
36
+            self::Overdue => 'danger',
37
+        };
38
+    }
39
+
40
+    public static function canBeOverdue(): array
41
+    {
42
+        return [
43
+            self::Partial,
44
+            self::Sent,
45
+            self::Unsent,
46
+        ];
47
+    }
48
+}

+ 27
- 0
app/Enums/Accounting/PaymentMethod.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum PaymentMethod: string implements HasLabel
8
+{
9
+    case BankPayment = 'bank_payment';
10
+    case Cash = 'cash';
11
+    case Check = 'check';
12
+    case CreditCard = 'credit_card';
13
+    case PayPal = 'paypal';
14
+    case Other = 'other';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return match ($this) {
19
+            self::BankPayment => 'Bank Payment',
20
+            self::Cash => 'Cash',
21
+            self::Check => 'Check',
22
+            self::CreditCard => 'Credit Card',
23
+            self::PayPal => 'PayPal',
24
+            self::Other => 'Other',
25
+        };
26
+    }
27
+}

+ 17
- 0
app/Enums/Common/AddressType.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum AddressType: string implements HasLabel
8
+{
9
+    case General = 'general';
10
+    case Billing = 'billing';
11
+    case Shipping = 'shipping';
12
+
13
+    public function getLabel(): string
14
+    {
15
+        return $this->name;
16
+    }
17
+}

+ 19
- 0
app/Enums/Common/ContractorType.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum ContractorType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Individual = 'individual';
13
+    case Business = 'business';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+}

+ 25
- 0
app/Enums/Common/OfferingType.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+use JaOcero\RadioDeck\Contracts\HasIcons;
7
+
8
+enum OfferingType: string implements HasIcons, HasLabel
9
+{
10
+    case Product = 'product';
11
+    case Service = 'service';
12
+
13
+    public function getLabel(): string
14
+    {
15
+        return $this->name;
16
+    }
17
+
18
+    public function getIcons(): ?string
19
+    {
20
+        return match ($this) {
21
+            self::Product => 'heroicon-o-cube-transparent',
22
+            self::Service => 'heroicon-o-briefcase',
23
+        };
24
+    }
25
+}

+ 31
- 0
app/Enums/Common/VendorType.php 查看文件

1
+<?php
2
+
3
+namespace App\Enums\Common;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasDescription;
7
+use Filament\Support\Contracts\HasLabel;
8
+
9
+enum VendorType: string implements HasDescription, HasLabel
10
+{
11
+    use ParsesEnum;
12
+
13
+    case Regular = 'regular';
14
+    case Contractor = 'contractor';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return match ($this) {
19
+            self::Regular => 'Regular',
20
+            self::Contractor => '1099-NEC Contractor',
21
+        };
22
+    }
23
+
24
+    public function getDescription(): ?string
25
+    {
26
+        return match ($this) {
27
+            self::Regular => 'Vendors who supply goods or services to your business, such as office supplies, utilities, or equipment.',
28
+            self::Contractor => 'Independent contractors providing services to your business, typically requiring 1099-NEC reporting for tax purposes.',
29
+        };
30
+    }
31
+}

+ 5
- 1
app/Enums/Concerns/ParsesEnum.php 查看文件

4
 
4
 
5
 trait ParsesEnum
5
 trait ParsesEnum
6
 {
6
 {
7
-    public static function parse(string | self $value): self
7
+    public static function parse(string | self | null $value): ?self
8
     {
8
     {
9
+        if ($value === null) {
10
+            return null;
11
+        }
12
+
9
         if ($value instanceof self) {
13
         if ($value instanceof self) {
10
             return $value;
14
             return $value;
11
         }
15
         }

+ 0
- 16
app/Enums/Setting/DiscountScope.php 查看文件

1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum DiscountScope: string implements HasLabel
8
-{
9
-    case Product = 'product';
10
-    case Service = 'service';
11
-
12
-    public function getLabel(): ?string
13
-    {
14
-        return translate($this->name);
15
-    }
16
-}

+ 0
- 19
app/Enums/Setting/TaxComputation.php 查看文件

1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasLabel;
6
-
7
-enum TaxComputation: string implements HasLabel
8
-{
9
-    case Fixed = 'fixed';
10
-    case Percentage = 'percentage';
11
-    case Compound = 'compound';
12
-
13
-    public const DEFAULT = self::Percentage->value;
14
-
15
-    public function getLabel(): ?string
16
-    {
17
-        return translate($this->name);
18
-    }
19
-}

+ 0
- 39
app/Enums/Setting/TaxType.php 查看文件

1
-<?php
2
-
3
-namespace App\Enums\Setting;
4
-
5
-use Filament\Support\Contracts\HasColor;
6
-use Filament\Support\Contracts\HasIcon;
7
-use Filament\Support\Contracts\HasLabel;
8
-
9
-enum TaxType: string implements HasColor, HasIcon, HasLabel
10
-{
11
-    case Sales = 'sales';
12
-    case Purchase = 'purchase';
13
-    case None = 'none';
14
-
15
-    public const DEFAULT = self::Sales->value;
16
-
17
-    public function getLabel(): ?string
18
-    {
19
-        return translate($this->name);
20
-    }
21
-
22
-    public function getColor(): string | array | null
23
-    {
24
-        return match ($this) {
25
-            self::Sales => 'success',
26
-            self::Purchase => 'warning',
27
-            self::None => 'gray',
28
-        };
29
-    }
30
-
31
-    public function getIcon(): ?string
32
-    {
33
-        return match ($this) {
34
-            self::Sales => 'heroicon-o-currency-dollar',
35
-            self::Purchase => 'heroicon-o-shopping-bag',
36
-            self::None => 'heroicon-o-x-circle',
37
-        };
38
-    }
39
-}

+ 2
- 18
app/Filament/Company/Clusters/Settings/Pages/Appearance.php 查看文件

4
 
4
 
5
 use App\Enums\Setting\Font;
5
 use App\Enums\Setting\Font;
6
 use App\Enums\Setting\PrimaryColor;
6
 use App\Enums\Setting\PrimaryColor;
7
-use App\Enums\Setting\RecordsPerPage;
8
-use App\Enums\Setting\TableSortDirection;
9
 use App\Filament\Company\Clusters\Settings;
7
 use App\Filament\Company\Clusters\Settings;
10
 use App\Models\Setting\Appearance as AppearanceModel;
8
 use App\Models\Setting\Appearance as AppearanceModel;
9
+use App\Services\CompanySettingsService;
11
 use Filament\Actions\Action;
10
 use Filament\Actions\Action;
12
 use Filament\Actions\ActionGroup;
11
 use Filament\Actions\ActionGroup;
13
 use Filament\Forms\Components\Component;
12
 use Filament\Forms\Components\Component;
103
         return $form
102
         return $form
104
             ->schema([
103
             ->schema([
105
                 $this->getGeneralSection(),
104
                 $this->getGeneralSection(),
106
-                $this->getDataPresentationSection(),
107
             ])
105
             ])
108
             ->model($this->record)
106
             ->model($this->record)
109
             ->statePath('data')
107
             ->statePath('data')
141
             ])->columns();
139
             ])->columns();
142
     }
140
     }
143
 
141
 
144
-    protected function getDataPresentationSection(): Component
145
-    {
146
-        return Section::make('Data Presentation')
147
-            ->schema([
148
-                Select::make('table_sort_direction')
149
-                    ->softRequired()
150
-                    ->localizeLabel()
151
-                    ->options(TableSortDirection::class),
152
-                Select::make('records_per_page')
153
-                    ->softRequired()
154
-                    ->localizeLabel()
155
-                    ->options(RecordsPerPage::class),
156
-            ])->columns();
157
-    }
158
-
159
     protected function handleRecordUpdate(AppearanceModel $record, array $data): AppearanceModel
142
     protected function handleRecordUpdate(AppearanceModel $record, array $data): AppearanceModel
160
     {
143
     {
161
         $record->fill($data);
144
         $record->fill($data);
166
         ];
149
         ];
167
 
150
 
168
         if ($record->isDirty($keysToWatch)) {
151
         if ($record->isDirty($keysToWatch)) {
152
+            CompanySettingsService::invalidateSettings($record->company_id);
169
             $this->dispatch('appearanceUpdated');
153
             $this->dispatch('appearanceUpdated');
170
         }
154
         }
171
 
155
 

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

108
         return $form
108
         return $form
109
             ->schema([
109
             ->schema([
110
                 $this->getGeneralSection(),
110
                 $this->getGeneralSection(),
111
-                $this->getModifiersSection(),
111
+                // $this->getModifiersSection(),
112
             ])
112
             ])
113
             ->model($this->record)
113
             ->model($this->record)
114
             ->statePath('data')
114
             ->statePath('data')

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

9
 use App\Filament\Company\Clusters\Settings;
9
 use App\Filament\Company\Clusters\Settings;
10
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
10
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
11
 use App\Models\Setting\Localization as LocalizationModel;
11
 use App\Models\Setting\Localization as LocalizationModel;
12
+use App\Services\CompanySettingsService;
12
 use App\Utilities\Localization\Timezone;
13
 use App\Utilities\Localization\Timezone;
13
 use Filament\Actions\Action;
14
 use Filament\Actions\Action;
14
 use Filament\Actions\ActionGroup;
15
 use Filament\Actions\ActionGroup;
214
         ];
215
         ];
215
 
216
 
216
         if ($record->isDirty($keysToWatch)) {
217
         if ($record->isDirty($keysToWatch)) {
218
+            CompanySettingsService::invalidateSettings($record->company_id);
217
             $this->dispatch('localizationUpdated');
219
             $this->dispatch('localizationUpdated');
218
         }
220
         }
219
 
221
 

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Clusters\Settings\Resources;
4
+
5
+use App\Enums\Accounting\AdjustmentCategory;
6
+use App\Enums\Accounting\AdjustmentComputation;
7
+use App\Enums\Accounting\AdjustmentScope;
8
+use App\Enums\Accounting\AdjustmentType;
9
+use App\Filament\Company\Clusters\Settings;
10
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
11
+use App\Models\Accounting\Adjustment;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+use Wallo\FilamentSelectify\Components\ToggleButton;
18
+
19
+class AdjustmentResource extends Resource
20
+{
21
+    protected static ?string $model = Adjustment::class;
22
+
23
+    protected static ?string $cluster = Settings::class;
24
+
25
+    public static function form(Form $form): Form
26
+    {
27
+        return $form
28
+            ->schema([
29
+                Forms\Components\Section::make('General')
30
+                    ->schema([
31
+                        Forms\Components\TextInput::make('name')
32
+                            ->autofocus()
33
+                            ->required()
34
+                            ->maxLength(255),
35
+                        Forms\Components\Textarea::make('description')
36
+                            ->label('Description')
37
+                            ->autosize(),
38
+                    ]),
39
+                Forms\Components\Section::make('Configuration')
40
+                    ->schema([
41
+                        Forms\Components\Select::make('category')
42
+                            ->localizeLabel()
43
+                            ->options(AdjustmentCategory::class)
44
+                            ->default(AdjustmentCategory::Tax)
45
+                            ->live()
46
+                            ->required(),
47
+                        Forms\Components\Select::make('type')
48
+                            ->localizeLabel()
49
+                            ->options(AdjustmentType::class)
50
+                            ->default(AdjustmentType::Sales)
51
+                            ->live()
52
+                            ->required(),
53
+                        ToggleButton::make('recoverable')
54
+                            ->label('Recoverable')
55
+                            ->default(false)
56
+                            ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Tax && AdjustmentType::parse($get('type')) === AdjustmentType::Purchase),
57
+                    ])
58
+                    ->columns()
59
+                    ->visibleOn('create'),
60
+                Forms\Components\Section::make('Adjustment Details')
61
+                    ->schema([
62
+                        Forms\Components\Select::make('computation')
63
+                            ->localizeLabel()
64
+                            ->options(AdjustmentComputation::class)
65
+                            ->default(AdjustmentComputation::Percentage)
66
+                            ->live()
67
+                            ->required(),
68
+                        Forms\Components\TextInput::make('rate')
69
+                            ->localizeLabel()
70
+                            ->rate(static fn (Forms\Get $get) => $get('computation'))
71
+                            ->required(),
72
+                        Forms\Components\Select::make('scope')
73
+                            ->localizeLabel()
74
+                            ->options(AdjustmentScope::class),
75
+                    ])
76
+                    ->columns(),
77
+                Forms\Components\Section::make('Dates')
78
+                    ->schema([
79
+                        Forms\Components\DateTimePicker::make('start_date'),
80
+                        Forms\Components\DateTimePicker::make('end_date'),
81
+                    ])
82
+                    ->columns()
83
+                    ->visible(fn (Forms\Get $get) => AdjustmentCategory::parse($get('category')) === AdjustmentCategory::Discount),
84
+            ]);
85
+    }
86
+
87
+    public static function table(Table $table): Table
88
+    {
89
+        return $table
90
+            ->columns([
91
+                Tables\Columns\TextColumn::make('name')
92
+                    ->label('Name')
93
+                    ->sortable(),
94
+                Tables\Columns\TextColumn::make('category')
95
+                    ->searchable(),
96
+                Tables\Columns\TextColumn::make('type')
97
+                    ->searchable(),
98
+                Tables\Columns\TextColumn::make('rate')
99
+                    ->localizeLabel()
100
+                    ->rate(static fn (Adjustment $record) => $record->computation->value)
101
+                    ->searchable()
102
+                    ->sortable(),
103
+            ])
104
+            ->filters([
105
+                //
106
+            ])
107
+            ->actions([
108
+                Tables\Actions\EditAction::make(),
109
+            ])
110
+            ->bulkActions([
111
+                //
112
+            ]);
113
+    }
114
+
115
+    public static function getRelations(): array
116
+    {
117
+        return [
118
+            //
119
+        ];
120
+    }
121
+
122
+    public static function getPages(): array
123
+    {
124
+        return [
125
+            'index' => Pages\ListAdjustments::route('/'),
126
+            'create' => Pages\CreateAdjustment::route('/create'),
127
+            'edit' => Pages\EditAdjustment::route('/{record}/edit'),
128
+        ];
129
+    }
130
+}

+ 11
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/CreateAdjustment.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4
+
5
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6
+use Filament\Resources\Pages\CreateRecord;
7
+
8
+class CreateAdjustment extends CreateRecord
9
+{
10
+    protected static string $resource = AdjustmentResource::class;
11
+}

+ 18
- 0
app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/EditAdjustment.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4
+
5
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6
+use Filament\Resources\Pages\EditRecord;
7
+
8
+class EditAdjustment extends EditRecord
9
+{
10
+    protected static string $resource = AdjustmentResource::class;
11
+
12
+    protected function getHeaderActions(): array
13
+    {
14
+        return [
15
+            //
16
+        ];
17
+    }
18
+}

app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/ListDiscounts.php → app/Filament/Company/Clusters/Settings/Resources/AdjustmentResource/Pages/ListAdjustments.php 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
3
+namespace App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource\Pages;
4
 
4
 
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
5
+use App\Filament\Company\Clusters\Settings\Resources\AdjustmentResource;
6
 use Filament\Actions;
6
 use Filament\Actions;
7
 use Filament\Resources\Pages\ListRecords;
7
 use Filament\Resources\Pages\ListRecords;
8
 use Filament\Support\Enums\MaxWidth;
8
 use Filament\Support\Enums\MaxWidth;
9
 
9
 
10
-class ListDiscounts extends ListRecords
10
+class ListAdjustments extends ListRecords
11
 {
11
 {
12
-    protected static string $resource = DiscountResource::class;
12
+    protected static string $resource = AdjustmentResource::class;
13
 
13
 
14
     protected function getHeaderActions(): array
14
     protected function getHeaderActions(): array
15
     {
15
     {

+ 0
- 189
app/Filament/Company/Clusters/Settings/Resources/DiscountResource.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources;
4
-
5
-use App\Concerns\NotifiesOnDelete;
6
-use App\Enums\Setting\DateFormat;
7
-use App\Enums\Setting\DiscountComputation;
8
-use App\Enums\Setting\DiscountScope;
9
-use App\Enums\Setting\DiscountType;
10
-use App\Enums\Setting\TimeFormat;
11
-use App\Filament\Company\Clusters\Settings;
12
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
13
-use App\Models\Setting\Discount;
14
-use App\Models\Setting\Localization;
15
-use Closure;
16
-use Filament\Forms;
17
-use Filament\Forms\Form;
18
-use Filament\Resources\Resource;
19
-use Filament\Support\Enums\FontWeight;
20
-use Filament\Tables;
21
-use Filament\Tables\Table;
22
-use Wallo\FilamentSelectify\Components\ToggleButton;
23
-
24
-class DiscountResource extends Resource
25
-{
26
-    use NotifiesOnDelete;
27
-
28
-    protected static ?string $model = Discount::class;
29
-
30
-    protected static ?string $modelLabel = 'Discount';
31
-
32
-    protected static ?string $cluster = Settings::class;
33
-
34
-    public static function getModelLabel(): string
35
-    {
36
-        $modelLabel = static::$modelLabel;
37
-
38
-        return translate($modelLabel);
39
-    }
40
-
41
-    public static function form(Form $form): Form
42
-    {
43
-        return $form
44
-            ->schema([
45
-                Forms\Components\Section::make('General')
46
-                    ->schema([
47
-                        Forms\Components\TextInput::make('name')
48
-                            ->autofocus()
49
-                            ->required()
50
-                            ->localizeLabel()
51
-                            ->maxLength(255)
52
-                            ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
53
-                                return static function (string $attribute, $value, Closure $fail) use ($component, $get) {
54
-                                    $existingDiscount = Discount::where('name', $value)
55
-                                        ->whereKeyNot($component->getRecord()?->getKey())
56
-                                        ->where('type', $get('type'))
57
-                                        ->first();
58
-
59
-                                    if ($existingDiscount) {
60
-                                        $message = translate('The :Type :record ":name" already exists.', [
61
-                                            'Type' => $existingDiscount->type->getLabel(),
62
-                                            'record' => strtolower(static::getModelLabel()),
63
-                                            'name' => $value,
64
-                                        ]);
65
-
66
-                                        $fail($message);
67
-                                    }
68
-                                };
69
-                            }),
70
-                        Forms\Components\TextInput::make('description')
71
-                            ->localizeLabel(),
72
-                        Forms\Components\Select::make('computation')
73
-                            ->localizeLabel()
74
-                            ->options(DiscountComputation::class)
75
-                            ->default(DiscountComputation::Percentage)
76
-                            ->live()
77
-                            ->required(),
78
-                        Forms\Components\TextInput::make('rate')
79
-                            ->localizeLabel()
80
-                            ->rate(static fn (Forms\Get $get) => $get('computation'))
81
-                            ->required(),
82
-                        Forms\Components\Select::make('type')
83
-                            ->localizeLabel()
84
-                            ->options(DiscountType::class)
85
-                            ->default(DiscountType::Sales)
86
-                            ->required(),
87
-                        Forms\Components\Select::make('scope')
88
-                            ->localizeLabel()
89
-                            ->options(DiscountScope::class)
90
-                            ->nullable(),
91
-                        Forms\Components\DateTimePicker::make('start_date')
92
-                            ->localizeLabel()
93
-                            ->beforeOrEqual('end_date')
94
-                            ->seconds(false)
95
-                            ->disabled(static fn (string $operation, ?Discount $record = null) => $operation === 'edit' && $record?->start_date?->isPast() ?? false)
96
-                            ->helperText(static fn (Forms\Components\DateTimePicker $component) => $component->isDisabled() ? 'Start date cannot be changed after the discount has begun.' : null),
97
-                        Forms\Components\DateTimePicker::make('end_date')
98
-                            ->localizeLabel()
99
-                            ->afterOrEqual('start_date')
100
-                            ->seconds(false),
101
-                        ToggleButton::make('enabled')
102
-                            ->localizeLabel('Default')
103
-                            ->onLabel(Discount::enabledLabel())
104
-                            ->offLabel(Discount::disabledLabel()),
105
-                    ])->columns(),
106
-            ]);
107
-    }
108
-
109
-    public static function table(Table $table): Table
110
-    {
111
-        return $table
112
-            ->columns([
113
-                Tables\Columns\TextColumn::make('name')
114
-                    ->localizeLabel()
115
-                    ->weight(FontWeight::Medium)
116
-                    ->icon(static fn (Discount $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
117
-                    ->tooltip(static function (Discount $record) {
118
-                        if ($record->isDisabled()) {
119
-                            return null;
120
-                        }
121
-
122
-                        return translate('Default :Type :Record', [
123
-                            'Type' => $record->type->getLabel(),
124
-                            'Record' => static::getModelLabel(),
125
-                        ]);
126
-                    })
127
-                    ->iconPosition('after')
128
-                    ->searchable()
129
-                    ->sortable(),
130
-                Tables\Columns\TextColumn::make('rate')
131
-                    ->localizeLabel()
132
-                    ->rate(static fn (Discount $record) => $record->computation->value)
133
-                    ->searchable()
134
-                    ->sortable(),
135
-                Tables\Columns\TextColumn::make('type')
136
-                    ->localizeLabel()
137
-                    ->badge()
138
-                    ->searchable()
139
-                    ->sortable(),
140
-                Tables\Columns\TextColumn::make('start_date')
141
-                    ->localizeLabel()
142
-                    ->formatStateUsing(static function (Discount $record) {
143
-                        $dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
144
-                        $timeFormat = Localization::firstOrFail()->time_format->value ?? TimeFormat::DEFAULT;
145
-
146
-                        return $record->start_date ? $record->start_date->format("{$dateFormat} {$timeFormat}") : 'N/A';
147
-                    })
148
-                    ->searchable()
149
-                    ->sortable(),
150
-                Tables\Columns\TextColumn::make('end_date')
151
-                    ->localizeLabel()
152
-                    ->formatStateUsing(static function (Discount $record) {
153
-                        $dateFormat = Localization::firstOrFail()->date_format->value ?? DateFormat::DEFAULT;
154
-                        $timeFormat = Localization::firstOrFail()->time_format->value ?? TimeFormat::DEFAULT;
155
-
156
-                        return $record->end_date ? $record->end_date->format("{$dateFormat} {$timeFormat}") : 'N/A';
157
-                    })
158
-                    ->color(static fn (Discount $record) => $record->end_date?->isPast() ? 'danger' : null)
159
-                    ->searchable()
160
-                    ->sortable(),
161
-            ])
162
-            ->filters([
163
-                //
164
-            ])
165
-            ->actions([
166
-                Tables\Actions\EditAction::make(),
167
-            ])
168
-            ->bulkActions([
169
-                Tables\Actions\BulkActionGroup::make([
170
-                    Tables\Actions\DeleteBulkAction::make(),
171
-                ]),
172
-            ])
173
-            ->checkIfRecordIsSelectableUsing(static function (Discount $record) {
174
-                return $record->isDisabled();
175
-            })
176
-            ->emptyStateActions([
177
-                Tables\Actions\CreateAction::make(),
178
-            ]);
179
-    }
180
-
181
-    public static function getPages(): array
182
-    {
183
-        return [
184
-            'index' => Pages\ListDiscounts::route('/'),
185
-            'create' => Pages\CreateDiscount::route('/create'),
186
-            'edit' => Pages\EditDiscount::route('/{record}/edit'),
187
-        ];
188
-    }
189
-}

+ 0
- 16
app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/CreateDiscount.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
6
-use Filament\Resources\Pages\CreateRecord;
7
-
8
-class CreateDiscount extends CreateRecord
9
-{
10
-    protected static string $resource = DiscountResource::class;
11
-
12
-    protected function getRedirectUrl(): string
13
-    {
14
-        return $this->getResource()::getUrl('index');
15
-    }
16
-}

+ 0
- 24
app/Filament/Company/Clusters/Settings/Resources/DiscountResource/Pages/EditDiscount.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\DiscountResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\DiscountResource;
6
-use Filament\Actions;
7
-use Filament\Resources\Pages\EditRecord;
8
-
9
-class EditDiscount extends EditRecord
10
-{
11
-    protected static string $resource = DiscountResource::class;
12
-
13
-    protected function getHeaderActions(): array
14
-    {
15
-        return [
16
-            Actions\DeleteAction::make(),
17
-        ];
18
-    }
19
-
20
-    protected function getRedirectUrl(): string
21
-    {
22
-        return $this->getResource()::getUrl('index');
23
-    }
24
-}

+ 0
- 158
app/Filament/Company/Clusters/Settings/Resources/TaxResource.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources;
4
-
5
-use App\Concerns\NotifiesOnDelete;
6
-use App\Enums\Setting\TaxComputation;
7
-use App\Enums\Setting\TaxScope;
8
-use App\Enums\Setting\TaxType;
9
-use App\Filament\Company\Clusters\Settings;
10
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
11
-use App\Models\Setting\Tax;
12
-use Closure;
13
-use Filament\Forms;
14
-use Filament\Forms\Form;
15
-use Filament\Resources\Resource;
16
-use Filament\Support\Enums\FontWeight;
17
-use Filament\Tables;
18
-use Filament\Tables\Table;
19
-use Wallo\FilamentSelectify\Components\ToggleButton;
20
-
21
-class TaxResource extends Resource
22
-{
23
-    use NotifiesOnDelete;
24
-
25
-    protected static ?string $model = Tax::class;
26
-
27
-    protected static ?string $modelLabel = 'Tax';
28
-
29
-    protected static ?string $cluster = Settings::class;
30
-
31
-    public static function getModelLabel(): string
32
-    {
33
-        $modelLabel = static::$modelLabel;
34
-
35
-        return translate($modelLabel);
36
-    }
37
-
38
-    public static function form(Form $form): Form
39
-    {
40
-        return $form
41
-            ->schema([
42
-                Forms\Components\Section::make('General')
43
-                    ->schema([
44
-                        Forms\Components\TextInput::make('name')
45
-                            ->autofocus()
46
-                            ->required()
47
-                            ->localizeLabel()
48
-                            ->maxLength(255)
49
-                            ->rule(static function (Forms\Get $get, Forms\Components\Component $component): Closure {
50
-                                return static function (string $attribute, $value, Closure $fail) use ($component, $get) {
51
-                                    $existingTax = Tax::where('name', $value)
52
-                                        ->whereKeyNot($component->getRecord()?->getKey())
53
-                                        ->where('type', $get('type'))
54
-                                        ->first();
55
-
56
-                                    if ($existingTax) {
57
-                                        $message = translate('The :Type :record ":name" already exists.', [
58
-                                            'Type' => $existingTax->type->getLabel(),
59
-                                            'record' => strtolower(static::getModelLabel()),
60
-                                            'name' => $value,
61
-                                        ]);
62
-
63
-                                        $fail($message);
64
-                                    }
65
-                                };
66
-                            }),
67
-                        Forms\Components\TextInput::make('description'),
68
-                        Forms\Components\Select::make('computation')
69
-                            ->localizeLabel()
70
-                            ->options(TaxComputation::class)
71
-                            ->default(TaxComputation::Percentage)
72
-                            ->live()
73
-                            ->required(),
74
-                        Forms\Components\TextInput::make('rate')
75
-                            ->localizeLabel()
76
-                            ->rate(static fn (Forms\Get $get) => $get('computation'))
77
-                            ->required(),
78
-                        Forms\Components\Select::make('type')
79
-                            ->localizeLabel()
80
-                            ->options(TaxType::class)
81
-                            ->default(TaxType::Sales)
82
-                            ->required(),
83
-                        Forms\Components\Select::make('scope')
84
-                            ->localizeLabel()
85
-                            ->options(TaxScope::class),
86
-                        ToggleButton::make('enabled')
87
-                            ->localizeLabel('Default')
88
-                            ->onLabel(Tax::enabledLabel())
89
-                            ->offLabel(Tax::disabledLabel()),
90
-                    ])->columns(),
91
-            ]);
92
-    }
93
-
94
-    public static function table(Table $table): Table
95
-    {
96
-        return $table
97
-            ->columns([
98
-                Tables\Columns\TextColumn::make('name')
99
-                    ->localizeLabel()
100
-                    ->weight(FontWeight::Medium)
101
-                    ->icon(static fn (Tax $record) => $record->isEnabled() ? 'heroicon-o-lock-closed' : null)
102
-                    ->tooltip(static function (Tax $record) {
103
-                        if ($record->isDisabled()) {
104
-                            return null;
105
-                        }
106
-
107
-                        return translate('Default :Type :Record', [
108
-                            'Type' => $record->type->getLabel(),
109
-                            'Record' => static::getModelLabel(),
110
-                        ]);
111
-                    })
112
-                    ->iconPosition('after')
113
-                    ->searchable()
114
-                    ->sortable(),
115
-                Tables\Columns\TextColumn::make('computation')
116
-                    ->localizeLabel()
117
-                    ->searchable()
118
-                    ->sortable(),
119
-                Tables\Columns\TextColumn::make('rate')
120
-                    ->localizeLabel()
121
-                    ->rate(static fn (Tax $record) => $record->computation->value)
122
-                    ->searchable()
123
-                    ->sortable(),
124
-                Tables\Columns\TextColumn::make('type')
125
-                    ->localizeLabel()
126
-                    ->badge()
127
-                    ->searchable()
128
-                    ->sortable(),
129
-            ])
130
-            ->filters([
131
-                //
132
-            ])
133
-            ->actions([
134
-                Tables\Actions\EditAction::make(),
135
-                Tables\Actions\DeleteAction::make(),
136
-            ])
137
-            ->bulkActions([
138
-                Tables\Actions\BulkActionGroup::make([
139
-                    Tables\Actions\DeleteBulkAction::make(),
140
-                ]),
141
-            ])
142
-            ->checkIfRecordIsSelectableUsing(static function (Tax $record) {
143
-                return $record->isDisabled();
144
-            })
145
-            ->emptyStateActions([
146
-                Tables\Actions\CreateAction::make(),
147
-            ]);
148
-    }
149
-
150
-    public static function getPages(): array
151
-    {
152
-        return [
153
-            'index' => Pages\ListTaxes::route('/'),
154
-            'create' => Pages\CreateTax::route('/create'),
155
-            'edit' => Pages\EditTax::route('/{record}/edit'),
156
-        ];
157
-    }
158
-}

+ 0
- 16
app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/CreateTax.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
6
-use Filament\Resources\Pages\CreateRecord;
7
-
8
-class CreateTax extends CreateRecord
9
-{
10
-    protected static string $resource = TaxResource::class;
11
-
12
-    protected function getRedirectUrl(): string
13
-    {
14
-        return $this->getResource()::getUrl('index');
15
-    }
16
-}

+ 0
- 24
app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/EditTax.php 查看文件

1
-<?php
2
-
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
4
-
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
6
-use Filament\Actions;
7
-use Filament\Resources\Pages\EditRecord;
8
-
9
-class EditTax extends EditRecord
10
-{
11
-    protected static string $resource = TaxResource::class;
12
-
13
-    protected function getHeaderActions(): array
14
-    {
15
-        return [
16
-            Actions\DeleteAction::make(),
17
-        ];
18
-    }
19
-
20
-    protected function getRedirectUrl(): string
21
-    {
22
-        return $this->getResource()::getUrl('index');
23
-    }
24
-}

+ 2
- 0
app/Filament/Company/Pages/Accounting/AccountChart.php 查看文件

46
     public function categories(): Collection
46
     public function categories(): Collection
47
     {
47
     {
48
         return AccountSubtype::withCount('accounts')
48
         return AccountSubtype::withCount('accounts')
49
+            ->with('accounts')
50
+            ->with('accounts.adjustment')
49
             ->get()
51
             ->get()
50
             ->groupBy('category');
52
             ->groupBy('category');
51
     }
53
     }

+ 22
- 15
app/Filament/Company/Pages/Accounting/Transactions.php 查看文件

87
         return static::getModel()::query();
87
         return static::getModel()::query();
88
     }
88
     }
89
 
89
 
90
+    public function getMaxContentWidth(): MaxWidth | string | null
91
+    {
92
+        return 'max-w-8xl';
93
+    }
94
+
90
     protected function getHeaderActions(): array
95
     protected function getHeaderActions(): array
91
     {
96
     {
92
         return [
97
         return [
262
                     'account',
267
                     'account',
263
                     'bankAccount.account',
268
                     'bankAccount.account',
264
                     'journalEntries.account',
269
                     'journalEntries.account',
265
-                ]);
270
+                ])
271
+                    ->where(function (Builder $query) {
272
+                        $query->whereNull('transactionable_id')
273
+                            ->orWhere('is_payment', true);
274
+                    });
266
             })
275
             })
267
             ->columns([
276
             ->columns([
268
                 Tables\Columns\TextColumn::make('posted_at')
277
                 Tables\Columns\TextColumn::make('posted_at')
275
                     ->toggleable(isToggledHiddenByDefault: true),
284
                     ->toggleable(isToggledHiddenByDefault: true),
276
                 Tables\Columns\TextColumn::make('description')
285
                 Tables\Columns\TextColumn::make('description')
277
                     ->label('Description')
286
                     ->label('Description')
278
-                    ->limit(30)
287
+                    ->limit(50)
279
                     ->toggleable(),
288
                     ->toggleable(),
280
                 Tables\Columns\TextColumn::make('bankAccount.account.name')
289
                 Tables\Columns\TextColumn::make('bankAccount.account.name')
281
                     ->label('Account')
290
                     ->label('Account')
296
                         }
305
                         }
297
                     )
306
                     )
298
                     ->sortable()
307
                     ->sortable()
299
-                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
308
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
300
             ])
309
             ])
301
             ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
310
             ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
302
             ->defaultSort('posted_at', 'desc')
311
             ->defaultSort('posted_at', 'desc')
320
                     ->options(TransactionType::class),
329
                     ->options(TransactionType::class),
321
                 $this->buildDateRangeFilter('posted_at', 'Posted', true),
330
                 $this->buildDateRangeFilter('posted_at', 'Posted', true),
322
                 $this->buildDateRangeFilter('updated_at', 'Last Modified'),
331
                 $this->buildDateRangeFilter('updated_at', 'Last Modified'),
323
-            ], layout: Tables\Enums\FiltersLayout::Modal)
332
+            ])
324
             ->filtersFormSchema(fn (array $filters): array => [
333
             ->filtersFormSchema(fn (array $filters): array => [
325
                 Grid::make()
334
                 Grid::make()
326
                     ->schema([
335
                     ->schema([
770
 
779
 
771
     protected function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null): array
780
     protected function getBankAccountOptions(?int $excludedAccountId = null, ?int $currentBankAccountId = null): array
772
     {
781
     {
773
-        return BankAccount::join('accounts', 'accounts.bank_account_id', '=', 'bank_accounts.id')
774
-            ->where('accounts.archived', false)
775
-            ->select(['bank_accounts.id', 'accounts.name', 'accounts.subtype_id'])
776
-            ->with(['account.subtype' => static function ($query) {
782
+        return BankAccount::query()
783
+            ->whereHas('account', function (Builder $query) {
784
+                $query->where('archived', false);
785
+            })
786
+            ->with(['account' => function ($query) {
787
+                $query->where('archived', false);
788
+            }, 'account.subtype' => function ($query) {
777
                 $query->select(['id', 'name']);
789
                 $query->select(['id', 'name']);
778
             }])
790
             }])
779
-            ->when($excludedAccountId, function (Builder $query) use ($excludedAccountId) {
780
-                $query->whereNot('accounts.id', $excludedAccountId);
781
-            })
782
-            ->when($currentBankAccountId, function (Builder $query) use ($currentBankAccountId) {
783
-                // Ensure the current bank account is included even if archived
784
-                $query->orWhere('bank_accounts.id', $currentBankAccountId);
785
-            })
791
+            ->when($excludedAccountId, fn (Builder $query) => $query->where('account_id', '!=', $excludedAccountId))
792
+            ->when($currentBankAccountId, fn (Builder $query) => $query->orWhere('id', $currentBankAccountId))
786
             ->get()
793
             ->get()
787
             ->groupBy('account.subtype.name')
794
             ->groupBy('account.subtype.name')
788
             ->map(fn (Collection $bankAccounts, string $subtype) => $bankAccounts->pluck('account.name', 'id'))
795
             ->map(fn (Collection $bankAccounts, string $subtype) => $bankAccounts->pluck('account.name', 'id'))

+ 24
- 0
app/Filament/Company/Pages/Concerns/HasReportTabs.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Pages\Concerns;
4
+
5
+use Livewire\Attributes\Url;
6
+
7
+trait HasReportTabs
8
+{
9
+    #[Url]
10
+    public ?string $activeTab = 'summary';
11
+
12
+    public function getTabs(): array
13
+    {
14
+        return [
15
+            'summary' => 'Summary',
16
+            'details' => 'Details',
17
+        ];
18
+    }
19
+
20
+    public function getActiveTab(): string
21
+    {
22
+        return $this->activeTab;
23
+    }
24
+}

+ 22
- 0
app/Filament/Company/Pages/CreateCompany.php 查看文件

11
 use Filament\Forms\Components\Select;
11
 use Filament\Forms\Components\Select;
12
 use Filament\Forms\Components\TextInput;
12
 use Filament\Forms\Components\TextInput;
13
 use Filament\Forms\Form;
13
 use Filament\Forms\Form;
14
+use Filament\Support\Enums\MaxWidth;
15
+use Illuminate\Contracts\Support\Htmlable;
14
 use Illuminate\Database\Eloquent\Model;
16
 use Illuminate\Database\Eloquent\Model;
15
 use Illuminate\Support\Facades\Auth;
17
 use Illuminate\Support\Facades\Auth;
16
 use Illuminate\Support\Facades\DB;
18
 use Illuminate\Support\Facades\DB;
23
 {
25
 {
24
     protected bool $hasTopbar = false;
26
     protected bool $hasTopbar = false;
25
 
27
 
28
+    protected static string $view = 'filament.company.pages.create-company';
29
+
30
+    protected static string $layout = 'components.company.layout.custom-simple';
31
+
32
+    public function getHeading(): string | Htmlable
33
+    {
34
+        return '';
35
+    }
36
+
37
+    public function getMaxWidth(): MaxWidth | string | null
38
+    {
39
+        return MaxWidth::FourExtraLarge;
40
+    }
41
+
42
+    public function hasLogo(): bool
43
+    {
44
+        return true;
45
+    }
46
+
26
     public function form(Form $form): Form
47
     public function form(Form $form): Form
27
     {
48
     {
28
         return $form
49
         return $form
58
                     ->optionsLimit(5)
79
                     ->optionsLimit(5)
59
                     ->softRequired(),
80
                     ->softRequired(),
60
             ])
81
             ])
82
+            ->columns()
61
             ->model(FilamentCompanies::companyModel())
83
             ->model(FilamentCompanies::companyModel())
62
             ->statePath('data');
84
             ->statePath('data');
63
     }
85
     }

+ 28
- 8
app/Filament/Company/Pages/Reports.php 查看文件

11
 use App\Infolists\Components\ReportEntry;
11
 use App\Infolists\Components\ReportEntry;
12
 use Filament\Infolists\Components\Section;
12
 use Filament\Infolists\Components\Section;
13
 use Filament\Infolists\Infolist;
13
 use Filament\Infolists\Infolist;
14
+use Filament\Navigation\NavigationItem;
14
 use Filament\Pages\Page;
15
 use Filament\Pages\Page;
15
 use Filament\Support\Colors\Color;
16
 use Filament\Support\Colors\Color;
16
 
17
 
20
 
21
 
21
     protected static string $view = 'filament.company.pages.reports';
22
     protected static string $view = 'filament.company.pages.reports';
22
 
23
 
24
+    public static function getNavigationItems(): array
25
+    {
26
+        return [
27
+            NavigationItem::make(static::getNavigationLabel())
28
+                ->group(static::getNavigationGroup())
29
+                ->parentItem(static::getNavigationParentItem())
30
+                ->icon(static::getNavigationIcon())
31
+                ->activeIcon(static::getActiveNavigationIcon())
32
+                ->isActiveWhen(fn (): bool => request()->routeIs([
33
+                    static::getRouteName(),
34
+                    static::getRouteName() . '.*',
35
+                ]))
36
+                ->sort(static::getNavigationSort())
37
+                ->badge(static::getNavigationBadge(), color: static::getNavigationBadgeColor())
38
+                ->badgeTooltip(static::getNavigationBadgeTooltip())
39
+                ->url(static::getNavigationUrl()),
40
+        ];
41
+    }
42
+
23
     public function reportsInfolist(Infolist $infolist): Infolist
43
     public function reportsInfolist(Infolist $infolist): Infolist
24
     {
44
     {
25
         return $infolist
45
         return $infolist
27
             ->schema([
47
             ->schema([
28
                 Section::make('Financial Statements')
48
                 Section::make('Financial Statements')
29
                     ->aside()
49
                     ->aside()
30
-                    ->description('Key financial statements that provide a snapshot of your company’s financial health.')
50
+                    ->description('Key financial statements that provide an overview of your company’s financial health and performance.')
31
                     ->extraAttributes(['class' => 'es-report-card'])
51
                     ->extraAttributes(['class' => 'es-report-card'])
32
                     ->schema([
52
                     ->schema([
33
                         ReportEntry::make('income_statement')
53
                         ReportEntry::make('income_statement')
34
                             ->hiddenLabel()
54
                             ->hiddenLabel()
35
                             ->heading('Income Statement')
55
                             ->heading('Income Statement')
36
-                            ->description('Tracks revenue and expenses to show profit or loss over a specific period of time.')
56
+                            ->description('Shows revenue, expenses, and net earnings over a period, indicating overall financial performance.')
37
                             ->icon('heroicon-o-chart-bar')
57
                             ->icon('heroicon-o-chart-bar')
38
                             ->iconColor(Color::Indigo)
58
                             ->iconColor(Color::Indigo)
39
                             ->url(IncomeStatement::getUrl()),
59
                             ->url(IncomeStatement::getUrl()),
40
                         ReportEntry::make('balance_sheet')
60
                         ReportEntry::make('balance_sheet')
41
                             ->hiddenLabel()
61
                             ->hiddenLabel()
42
                             ->heading('Balance Sheet')
62
                             ->heading('Balance Sheet')
43
-                            ->description('Snapshot of assets, liabilities, and equity at a specific point in time.')
63
+                            ->description('Displays your company’s assets, liabilities, and equity at a single point in time, showing overall financial health and stability.')
44
                             ->icon('heroicon-o-clipboard-document-list')
64
                             ->icon('heroicon-o-clipboard-document-list')
45
                             ->iconColor(Color::Emerald)
65
                             ->iconColor(Color::Emerald)
46
                             ->url(BalanceSheet::getUrl()),
66
                             ->url(BalanceSheet::getUrl()),
47
                         ReportEntry::make('cash_flow_statement')
67
                         ReportEntry::make('cash_flow_statement')
48
                             ->hiddenLabel()
68
                             ->hiddenLabel()
49
                             ->heading('Cash Flow Statement')
69
                             ->heading('Cash Flow Statement')
50
-                            ->description('Shows cash inflows and outflows over a specific period of time.')
70
+                            ->description('Tracks cash inflows and outflows, giving insight into liquidity and cash management over a period.')
51
                             ->icon('heroicon-o-document-currency-dollar')
71
                             ->icon('heroicon-o-document-currency-dollar')
52
                             ->iconColor(Color::Cyan)
72
                             ->iconColor(Color::Cyan)
53
                             ->url(CashFlowStatement::getUrl()),
73
                             ->url(CashFlowStatement::getUrl()),
54
                     ]),
74
                     ]),
55
                 Section::make('Detailed Reports')
75
                 Section::make('Detailed Reports')
56
                     ->aside()
76
                     ->aside()
57
-                    ->description('Dig into the details of your company’s transactions, balances, and accounts.')
77
+                    ->description('Detailed reports that provide a comprehensive view of your company’s financial transactions and account balances.')
58
                     ->extraAttributes(['class' => 'es-report-card'])
78
                     ->extraAttributes(['class' => 'es-report-card'])
59
                     ->schema([
79
                     ->schema([
60
                         ReportEntry::make('account_balances')
80
                         ReportEntry::make('account_balances')
61
                             ->hiddenLabel()
81
                             ->hiddenLabel()
62
                             ->heading('Account Balances')
82
                             ->heading('Account Balances')
63
-                            ->description('Summary view of balances and activity for all accounts.')
83
+                            ->description('Lists all accounts and their balances, including starting, debit, credit, net movement, and ending balances.')
64
                             ->icon('heroicon-o-currency-dollar')
84
                             ->icon('heroicon-o-currency-dollar')
65
                             ->iconColor(Color::Teal)
85
                             ->iconColor(Color::Teal)
66
                             ->url(AccountBalances::getUrl()),
86
                             ->url(AccountBalances::getUrl()),
67
                         ReportEntry::make('trial_balance')
87
                         ReportEntry::make('trial_balance')
68
                             ->hiddenLabel()
88
                             ->hiddenLabel()
69
                             ->heading('Trial Balance')
89
                             ->heading('Trial Balance')
70
-                            ->description('The sum of all debit and credit balances for all accounts on a single day. This helps to ensure that the books are in balance.')
90
+                            ->description('Summarizes all account debits and credits on a specific date to verify the ledger is balanced.')
71
                             ->icon('heroicon-o-scale')
91
                             ->icon('heroicon-o-scale')
72
                             ->iconColor(Color::Sky)
92
                             ->iconColor(Color::Sky)
73
                             ->url(TrialBalance::getUrl()),
93
                             ->url(TrialBalance::getUrl()),
74
                         ReportEntry::make('account_transactions')
94
                         ReportEntry::make('account_transactions')
75
                             ->hiddenLabel()
95
                             ->hiddenLabel()
76
                             ->heading('Account Transactions')
96
                             ->heading('Account Transactions')
77
-                            ->description('A record of all transactions for a company. The general ledger is the core of a company\'s financial records.')
97
+                            ->description('A record of all transactions, essential for monitoring and reconciling financial activity in the ledger.')
78
                             ->icon('heroicon-o-adjustments-horizontal')
98
                             ->icon('heroicon-o-adjustments-horizontal')
79
                             ->iconColor(Color::Amber)
99
                             ->iconColor(Color::Amber)
80
                             ->url(AccountTransactions::getUrl()),
100
                             ->url(AccountTransactions::getUrl()),

+ 0
- 4
app/Filament/Company/Pages/Reports/AccountBalances.php 查看文件

17
 {
17
 {
18
     protected static string $view = 'filament.company.pages.reports.detailed-report';
18
     protected static string $view = 'filament.company.pages.reports.detailed-report';
19
 
19
 
20
-    protected static ?string $slug = 'reports/account-balances';
21
-
22
-    protected static bool $shouldRegisterNavigation = false;
23
-
24
     protected ReportService $reportService;
20
     protected ReportService $reportService;
25
 
21
 
26
     protected ExportService $exportService;
22
     protected ExportService $exportService;

+ 43
- 7
app/Filament/Company/Pages/Reports/AccountTransactions.php 查看文件

7
 use App\Filament\Company\Pages\Accounting\Transactions;
7
 use App\Filament\Company\Pages\Accounting\Transactions;
8
 use App\Models\Accounting\Account;
8
 use App\Models\Accounting\Account;
9
 use App\Models\Accounting\JournalEntry;
9
 use App\Models\Accounting\JournalEntry;
10
+use App\Models\Common\Client;
11
+use App\Models\Common\Vendor;
10
 use App\Services\ExportService;
12
 use App\Services\ExportService;
11
 use App\Services\ReportService;
13
 use App\Services\ReportService;
12
 use App\Support\Column;
14
 use App\Support\Column;
27
 {
29
 {
28
     protected static string $view = 'filament.company.pages.reports.account-transactions';
30
     protected static string $view = 'filament.company.pages.reports.account-transactions';
29
 
31
 
30
-    protected static ?string $slug = 'reports/account-transactions';
31
-
32
-    protected static bool $shouldRegisterNavigation = false;
33
-
34
     protected ReportService $reportService;
32
     protected ReportService $reportService;
35
 
33
 
36
     protected ExportService $exportService;
34
     protected ExportService $exportService;
43
 
41
 
44
     public function getMaxContentWidth(): MaxWidth | string | null
42
     public function getMaxContentWidth(): MaxWidth | string | null
45
     {
43
     {
46
-        return 'max-w-[90rem]';
44
+        return 'max-w-8xl';
47
     }
45
     }
48
 
46
 
49
     protected function initializeDefaultFilters(): void
47
     protected function initializeDefaultFilters(): void
51
         if (empty($this->getFilterState('selectedAccount'))) {
49
         if (empty($this->getFilterState('selectedAccount'))) {
52
             $this->setFilterState('selectedAccount', 'all');
50
             $this->setFilterState('selectedAccount', 'all');
53
         }
51
         }
52
+
53
+        if (empty($this->getFilterState('selectedEntity'))) {
54
+            $this->setFilterState('selectedEntity', 'all');
55
+        }
54
     }
56
     }
55
 
57
 
56
     /**
58
     /**
81
     public function filtersForm(Form $form): Form
83
     public function filtersForm(Form $form): Form
82
     {
84
     {
83
         return $form
85
         return $form
84
-            ->columns(4)
86
+            ->columns(5)
85
             ->schema([
87
             ->schema([
86
                 Select::make('selectedAccount')
88
                 Select::make('selectedAccount')
87
                     ->label('Account')
89
                     ->label('Account')
95
                 ])->extraFieldWrapperAttributes([
97
                 ])->extraFieldWrapperAttributes([
96
                     'class' => 'report-hidden-label',
98
                     'class' => 'report-hidden-label',
97
                 ]),
99
                 ]),
100
+                Select::make('selectedEntity')
101
+                    ->label('Entity')
102
+                    ->options($this->getEntityOptions())
103
+                    ->searchable()
104
+                    ->selectablePlaceholder(false),
98
                 Actions::make([
105
                 Actions::make([
99
                     Actions\Action::make('applyFilters')
106
                     Actions\Action::make('applyFilters')
100
                         ->label('Update Report')
107
                         ->label('Update Report')
120
         return $allAccountsOption + $accounts;
127
         return $allAccountsOption + $accounts;
121
     }
128
     }
122
 
129
 
130
+    protected function getEntityOptions(): array
131
+    {
132
+        $clients = Client::query()
133
+            ->orderBy('name')
134
+            ->pluck('name', 'id')
135
+            ->toArray();
136
+
137
+        $vendors = Vendor::query()
138
+            ->orderBy('name')
139
+            ->pluck('name', 'id')
140
+            ->mapWithKeys(fn ($name, $id) => [-$id => $name])
141
+            ->toArray();
142
+
143
+        $allEntitiesOption = [
144
+            'All Entities' => ['all' => 'All Entities'],
145
+        ];
146
+
147
+        return $allEntitiesOption + [
148
+            'Clients' => $clients,
149
+            'Vendors' => $vendors,
150
+        ];
151
+    }
152
+
123
     protected function buildReport(array $columns): ReportDTO
153
     protected function buildReport(array $columns): ReportDTO
124
     {
154
     {
125
-        return $this->reportService->buildAccountTransactionsReport($this->getFormattedStartDate(), $this->getFormattedEndDate(), $columns, $this->getFilterState('selectedAccount'));
155
+        return $this->reportService->buildAccountTransactionsReport(
156
+            startDate: $this->getFormattedStartDate(),
157
+            endDate: $this->getFormattedEndDate(),
158
+            columns: $columns,
159
+            accountId: $this->getFilterState('selectedAccount'),
160
+            entityId: $this->getFilterState('selectedEntity'),
161
+        );
126
     }
162
     }
127
 
163
 
128
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport
164
     protected function getTransformer(ReportDTO $reportDTO): ExportableReport

+ 5
- 8
app/Filament/Company/Pages/Reports/BalanceSheet.php 查看文件

4
 
4
 
5
 use App\Contracts\ExportableReport;
5
 use App\Contracts\ExportableReport;
6
 use App\DTO\ReportDTO;
6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7
 use App\Filament\Forms\Components\DateRangeSelect;
8
 use App\Filament\Forms\Components\DateRangeSelect;
8
 use App\Services\ExportService;
9
 use App\Services\ExportService;
9
 use App\Services\ReportService;
10
 use App\Services\ReportService;
11
 use App\Transformers\BalanceSheetReportTransformer;
12
 use App\Transformers\BalanceSheetReportTransformer;
12
 use Filament\Forms\Form;
13
 use Filament\Forms\Form;
13
 use Filament\Support\Enums\Alignment;
14
 use Filament\Support\Enums\Alignment;
14
-use Livewire\Attributes\Url;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16
 
16
 
17
 class BalanceSheet extends BaseReportPage
17
 class BalanceSheet extends BaseReportPage
18
 {
18
 {
19
-    protected static string $view = 'filament.company.pages.reports.balance-sheet';
19
+    use HasReportTabs;
20
 
20
 
21
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.balance-sheet';
22
 
22
 
23
     protected ReportService $reportService;
23
     protected ReportService $reportService;
24
 
24
 
25
     protected ExportService $exportService;
25
     protected ExportService $exportService;
26
 
26
 
27
-    #[Url]
28
-    public ?string $activeTab = 'summary';
29
-
30
     public function boot(ReportService $reportService, ExportService $exportService): void
27
     public function boot(ReportService $reportService, ExportService $exportService): void
31
     {
28
     {
32
         $this->reportService = $reportService;
29
         $this->reportService = $reportService;
77
 
74
 
78
     public function exportCSV(): StreamedResponse
75
     public function exportCSV(): StreamedResponse
79
     {
76
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
77
+        return $this->exportService->exportToCsv($this->company, $this->report, endDate: $this->getFilterState('asOfDate'), activeTab: $this->getActiveTab());
81
     }
78
     }
82
 
79
 
83
     public function exportPDF(): StreamedResponse
80
     public function exportPDF(): StreamedResponse
84
     {
81
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, endDate: $this->getFilterState('asOfDate'));
82
+        return $this->exportService->exportToPdf($this->company, $this->report, endDate: $this->getFilterState('asOfDate'), activeTab: $this->getActiveTab());
86
     }
83
     }
87
 }
84
 }

+ 26
- 0
app/Filament/Company/Pages/Reports/BaseReportPage.php 查看文件

6
 use App\DTO\ReportDTO;
6
 use App\DTO\ReportDTO;
7
 use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
7
 use App\Filament\Company\Pages\Concerns\HasDeferredFiltersForm;
8
 use App\Filament\Company\Pages\Concerns\HasTableColumnToggleForm;
8
 use App\Filament\Company\Pages\Concerns\HasTableColumnToggleForm;
9
+use App\Filament\Company\Pages\Reports;
9
 use App\Filament\Forms\Components\DateRangeSelect;
10
 use App\Filament\Forms\Components\DateRangeSelect;
10
 use App\Models\Company;
11
 use App\Models\Company;
11
 use App\Services\DateRangeService;
12
 use App\Services\DateRangeService;
62
         $this->fiscalYearEndDate = $this->company->locale->fiscalYearEndDate();
63
         $this->fiscalYearEndDate = $this->company->locale->fiscalYearEndDate();
63
     }
64
     }
64
 
65
 
66
+    public static function shouldRegisterNavigation(): bool
67
+    {
68
+        return false;
69
+    }
70
+
71
+    public static function getSlug(): string
72
+    {
73
+        $prefix = Reports::getSlug() . '/';
74
+
75
+        if (filled(static::$slug)) {
76
+            return $prefix . static::$slug;
77
+        }
78
+
79
+        return $prefix . str(class_basename(static::class))
80
+            ->kebab()
81
+            ->slug();
82
+    }
83
+
84
+    public function getBreadcrumbs(): array
85
+    {
86
+        return [
87
+            Reports::getUrl() => Reports::getNavigationLabel(),
88
+        ];
89
+    }
90
+
65
     protected function loadDefaultDateRange(): void
91
     protected function loadDefaultDateRange(): void
66
     {
92
     {
67
         $flatFields = $this->getFiltersForm()->getFlatFields();
93
         $flatFields = $this->getFiltersForm()->getFlatFields();

+ 5
- 10
app/Filament/Company/Pages/Reports/CashFlowStatement.php 查看文件

4
 
4
 
5
 use App\Contracts\ExportableReport;
5
 use App\Contracts\ExportableReport;
6
 use App\DTO\ReportDTO;
6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7
 use App\Services\ExportService;
8
 use App\Services\ExportService;
8
 use App\Services\ReportService;
9
 use App\Services\ReportService;
9
 use App\Support\Column;
10
 use App\Support\Column;
11
 use Filament\Forms\Form;
12
 use Filament\Forms\Form;
12
 use Filament\Support\Enums\Alignment;
13
 use Filament\Support\Enums\Alignment;
13
 use Guava\FilamentClusters\Forms\Cluster;
14
 use Guava\FilamentClusters\Forms\Cluster;
14
-use Livewire\Attributes\Url;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16
 
16
 
17
 class CashFlowStatement extends BaseReportPage
17
 class CashFlowStatement extends BaseReportPage
18
 {
18
 {
19
-    protected static string $view = 'filament.company.pages.reports.cash-flow-statement';
20
-
21
-    protected static ?string $slug = 'reports/cash-flow-statement';
19
+    use HasReportTabs;
22
 
20
 
23
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.cash-flow-statement';
24
 
22
 
25
     protected ReportService $reportService;
23
     protected ReportService $reportService;
26
 
24
 
27
     protected ExportService $exportService;
25
     protected ExportService $exportService;
28
 
26
 
29
-    #[Url]
30
-    public ?string $activeTab = 'summary';
31
-
32
     public function boot(ReportService $reportService, ExportService $exportService): void
27
     public function boot(ReportService $reportService, ExportService $exportService): void
33
     {
28
     {
34
         $this->reportService = $reportService;
29
         $this->reportService = $reportService;
77
 
72
 
78
     public function exportCSV(): StreamedResponse
73
     public function exportCSV(): StreamedResponse
79
     {
74
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
75
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
81
     }
76
     }
82
 
77
 
83
     public function exportPDF(): StreamedResponse
78
     public function exportPDF(): StreamedResponse
84
     {
79
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
80
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
86
     }
81
     }
87
 }
82
 }

+ 5
- 10
app/Filament/Company/Pages/Reports/IncomeStatement.php 查看文件

4
 
4
 
5
 use App\Contracts\ExportableReport;
5
 use App\Contracts\ExportableReport;
6
 use App\DTO\ReportDTO;
6
 use App\DTO\ReportDTO;
7
+use App\Filament\Company\Pages\Concerns\HasReportTabs;
7
 use App\Services\ExportService;
8
 use App\Services\ExportService;
8
 use App\Services\ReportService;
9
 use App\Services\ReportService;
9
 use App\Support\Column;
10
 use App\Support\Column;
11
 use Filament\Forms\Form;
12
 use Filament\Forms\Form;
12
 use Filament\Support\Enums\Alignment;
13
 use Filament\Support\Enums\Alignment;
13
 use Guava\FilamentClusters\Forms\Cluster;
14
 use Guava\FilamentClusters\Forms\Cluster;
14
-use Livewire\Attributes\Url;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
15
 use Symfony\Component\HttpFoundation\StreamedResponse;
16
 
16
 
17
 class IncomeStatement extends BaseReportPage
17
 class IncomeStatement extends BaseReportPage
18
 {
18
 {
19
-    protected static string $view = 'filament.company.pages.reports.income-statement';
20
-
21
-    protected static ?string $slug = 'reports/income-statement';
19
+    use HasReportTabs;
22
 
20
 
23
-    protected static bool $shouldRegisterNavigation = false;
21
+    protected static string $view = 'filament.company.pages.reports.income-statement';
24
 
22
 
25
     protected ReportService $reportService;
23
     protected ReportService $reportService;
26
 
24
 
27
     protected ExportService $exportService;
25
     protected ExportService $exportService;
28
 
26
 
29
-    #[Url]
30
-    public ?string $activeTab = 'summary';
31
-
32
     public function boot(ReportService $reportService, ExportService $exportService): void
27
     public function boot(ReportService $reportService, ExportService $exportService): void
33
     {
28
     {
34
         $this->reportService = $reportService;
29
         $this->reportService = $reportService;
77
 
72
 
78
     public function exportCSV(): StreamedResponse
73
     public function exportCSV(): StreamedResponse
79
     {
74
     {
80
-        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
75
+        return $this->exportService->exportToCsv($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
81
     }
76
     }
82
 
77
 
83
     public function exportPDF(): StreamedResponse
78
     public function exportPDF(): StreamedResponse
84
     {
79
     {
85
-        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'));
80
+        return $this->exportService->exportToPdf($this->company, $this->report, $this->getFilterState('startDate'), $this->getFilterState('endDate'), $this->getActiveTab());
86
     }
81
     }
87
 }
82
 }

+ 0
- 4
app/Filament/Company/Pages/Reports/TrialBalance.php 查看文件

18
 {
18
 {
19
     protected static string $view = 'filament.company.pages.reports.trial-balance';
19
     protected static string $view = 'filament.company.pages.reports.trial-balance';
20
 
20
 
21
-    protected static ?string $slug = 'reports/trial-balance';
22
-
23
-    protected static bool $shouldRegisterNavigation = false;
24
-
25
     protected ReportService $reportService;
21
     protected ReportService $reportService;
26
 
22
 
27
     protected ExportService $exportService;
23
     protected ExportService $exportService;

+ 6
- 0
app/Filament/Company/Resources/Banking/AccountResource/Pages/ListAccounts.php 查看文件

5
 use App\Filament\Company\Resources\Banking\AccountResource;
5
 use App\Filament\Company\Resources\Banking\AccountResource;
6
 use Filament\Actions;
6
 use Filament\Actions;
7
 use Filament\Resources\Pages\ListRecords;
7
 use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
8
 
9
 
9
 class ListAccounts extends ListRecords
10
 class ListAccounts extends ListRecords
10
 {
11
 {
16
             Actions\CreateAction::make(),
17
             Actions\CreateAction::make(),
17
         ];
18
         ];
18
     }
19
     }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
19
 }
25
 }

+ 195
- 0
app/Filament/Company/Resources/Common/OfferingResource.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common;
4
+
5
+use App\Enums\Accounting\AccountCategory;
6
+use App\Enums\Accounting\AccountType;
7
+use App\Enums\Common\OfferingType;
8
+use App\Filament\Company\Resources\Common\OfferingResource\Pages;
9
+use App\Models\Accounting\Account;
10
+use App\Models\Common\Offering;
11
+use App\Utilities\Currency\CurrencyAccessor;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+use Illuminate\Database\Eloquent\Builder;
18
+use Illuminate\Support\Str;
19
+use JaOcero\RadioDeck\Forms\Components\RadioDeck;
20
+
21
+class OfferingResource extends Resource
22
+{
23
+    protected static ?string $model = Offering::class;
24
+
25
+    protected static ?string $modelLabel = 'Offering';
26
+
27
+    protected static ?string $navigationIcon = 'heroicon-o-square-3-stack-3d';
28
+
29
+    public static function form(Form $form): Form
30
+    {
31
+        return $form
32
+            ->schema([
33
+                Forms\Components\Section::make('General')
34
+                    ->schema([
35
+                        RadioDeck::make('type')
36
+                            ->options(OfferingType::class)
37
+                            ->default(OfferingType::Product)
38
+                            ->icons(OfferingType::class)
39
+                            ->color('primary')
40
+                            ->columns()
41
+                            ->required(),
42
+                        Forms\Components\TextInput::make('name')
43
+                            ->autofocus()
44
+                            ->required()
45
+                            ->columnStart(1)
46
+                            ->maxLength(255),
47
+                        Forms\Components\TextInput::make('price')
48
+                            ->required()
49
+                            ->money(),
50
+                        Forms\Components\Textarea::make('description')
51
+                            ->label('Description')
52
+                            ->columnSpan(2)
53
+                            ->rows(3),
54
+                        Forms\Components\CheckboxList::make('attributes')
55
+                            ->options([
56
+                                'Sellable' => 'Sellable',
57
+                                'Purchasable' => 'Purchasable',
58
+                            ])
59
+                            ->hiddenLabel()
60
+                            ->required()
61
+                            ->live()
62
+                            ->bulkToggleable()
63
+                            ->validationMessages([
64
+                                'required' => 'The offering must be either sellable or purchasable.',
65
+                            ]),
66
+                    ])->columns(),
67
+                // Sellable Section
68
+                Forms\Components\Section::make('Sale Information')
69
+                    ->schema([
70
+                        Forms\Components\Select::make('income_account_id')
71
+                            ->label('Income Account')
72
+                            ->options(Account::query()
73
+                                ->where('category', AccountCategory::Revenue)
74
+                                ->where('type', AccountType::OperatingRevenue)
75
+                                ->pluck('name', 'id')
76
+                                ->toArray())
77
+                            ->searchable()
78
+                            ->preload()
79
+                            ->required()
80
+                            ->validationMessages([
81
+                                'required' => 'The income account is required for sellable offerings.',
82
+                            ]),
83
+                        Forms\Components\Select::make('salesTaxes')
84
+                            ->label('Sales Tax')
85
+                            ->relationship('salesTaxes', 'name')
86
+                            ->preload()
87
+                            ->multiple(),
88
+                        Forms\Components\Select::make('salesDiscounts')
89
+                            ->label('Sales Discount')
90
+                            ->relationship('salesDiscounts', 'name')
91
+                            ->preload()
92
+                            ->multiple(),
93
+                    ])
94
+                    ->columns()
95
+                    ->visible(fn (Forms\Get $get) => in_array('Sellable', $get('attributes') ?? [])),
96
+
97
+                // Purchasable Section
98
+                Forms\Components\Section::make('Purchase Information')
99
+                    ->schema([
100
+                        Forms\Components\Select::make('expense_account_id')
101
+                            ->label('Expense Account')
102
+                            ->options(Account::query()
103
+                                ->where('category', AccountCategory::Expense)
104
+                                ->where('type', AccountType::OperatingExpense)
105
+                                ->orderBy('name')
106
+                                ->pluck('name', 'id')
107
+                                ->toArray())
108
+                            ->searchable()
109
+                            ->preload()
110
+                            ->required()
111
+                            ->validationMessages([
112
+                                'required' => 'The expense account is required for purchasable offerings.',
113
+                            ]),
114
+                        Forms\Components\Select::make('purchaseTaxes')
115
+                            ->label('Purchase Tax')
116
+                            ->relationship('purchaseTaxes', 'name')
117
+                            ->preload()
118
+                            ->multiple(),
119
+                        Forms\Components\Select::make('purchaseDiscounts')
120
+                            ->label('Purchase Discount')
121
+                            ->relationship('purchaseDiscounts', 'name')
122
+                            ->preload()
123
+                            ->multiple(),
124
+                    ])
125
+                    ->columns()
126
+                    ->visible(fn (Forms\Get $get) => in_array('Purchasable', $get('attributes') ?? [])),
127
+            ])->columns();
128
+    }
129
+
130
+    public static function table(Table $table): Table
131
+    {
132
+        return $table
133
+            ->modifyQueryUsing(function (Builder $query) {
134
+                $query->selectRaw("
135
+                        *,
136
+                        CONCAT_WS(' & ',
137
+                            CASE WHEN sellable THEN 'Sellable' END,
138
+                            CASE WHEN purchasable THEN 'Purchasable' END
139
+                        ) AS attributes
140
+                    ");
141
+            })
142
+            ->columns([
143
+                Tables\Columns\TextColumn::make('name')
144
+                    ->label('Name'),
145
+                Tables\Columns\TextColumn::make('attributes')
146
+                    ->label('Attributes')
147
+                    ->badge(),
148
+                Tables\Columns\TextColumn::make('type')
149
+                    ->searchable(),
150
+                Tables\Columns\TextColumn::make('price')
151
+                    ->currency(CurrencyAccessor::getDefaultCurrency(), true)
152
+                    ->sortable()
153
+                    ->description(function (Offering $record) {
154
+                        $adjustments = $record->adjustments()
155
+                            ->pluck('name')
156
+                            ->join(', ');
157
+
158
+                        if (empty($adjustments)) {
159
+                            return null;
160
+                        }
161
+
162
+                        $adjustmentsList = Str::of($adjustments)->limit(40);
163
+
164
+                        return "+ {$adjustmentsList}";
165
+                    }),
166
+            ])
167
+            ->filters([
168
+                //
169
+            ])
170
+            ->actions([
171
+                Tables\Actions\EditAction::make(),
172
+            ])
173
+            ->bulkActions([
174
+                Tables\Actions\BulkActionGroup::make([
175
+                    Tables\Actions\DeleteBulkAction::make(),
176
+                ]),
177
+            ]);
178
+    }
179
+
180
+    public static function getRelations(): array
181
+    {
182
+        return [
183
+            //
184
+        ];
185
+    }
186
+
187
+    public static function getPages(): array
188
+    {
189
+        return [
190
+            'index' => Pages\ListOfferings::route('/'),
191
+            'create' => Pages\CreateOffering::route('/create'),
192
+            'edit' => Pages\EditOffering::route('/{record}/edit'),
193
+        ];
194
+    }
195
+}

+ 27
- 0
app/Filament/Company/Resources/Common/OfferingResource/Pages/CreateOffering.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Common\OfferingResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Illuminate\Database\Eloquent\Model;
9
+
10
+class CreateOffering extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = OfferingResource::class;
15
+
16
+    protected function handleRecordCreation(array $data): Model
17
+    {
18
+        $attributes = array_flip($data['attributes'] ?? []);
19
+
20
+        $data['sellable'] = isset($attributes['Sellable']);
21
+        $data['purchasable'] = isset($attributes['Purchasable']);
22
+
23
+        unset($data['attributes']);
24
+
25
+        return parent::handleRecordCreation($data); // TODO: Change the autogenerated stub
26
+    }
27
+}

+ 45
- 0
app/Filament/Company/Resources/Common/OfferingResource/Pages/EditOffering.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Common\OfferingResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Illuminate\Database\Eloquent\Model;
10
+
11
+class EditOffering extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = OfferingResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    protected function mutateFormDataBeforeFill(array $data): array
25
+    {
26
+        $data['attributes'] = array_filter([
27
+            $data['sellable'] ? 'Sellable' : null,
28
+            $data['purchasable'] ? 'Purchasable' : null,
29
+        ]);
30
+
31
+        return parent::mutateFormDataBeforeFill($data); // TODO: Change the autogenerated stub
32
+    }
33
+
34
+    protected function handleRecordUpdate(Model $record, array $data): Model
35
+    {
36
+        $attributes = array_flip($data['attributes'] ?? []);
37
+
38
+        $data['sellable'] = isset($attributes['Sellable']);
39
+        $data['purchasable'] = isset($attributes['Purchasable']);
40
+
41
+        unset($data['attributes']);
42
+
43
+        return parent::handleRecordUpdate($record, $data); // TODO: Change the autogenerated stub
44
+    }
45
+}

app/Filament/Company/Clusters/Settings/Resources/TaxResource/Pages/ListTaxes.php → app/Filament/Company/Resources/Common/OfferingResource/Pages/ListOfferings.php 查看文件

1
 <?php
1
 <?php
2
 
2
 
3
-namespace App\Filament\Company\Clusters\Settings\Resources\TaxResource\Pages;
3
+namespace App\Filament\Company\Resources\Common\OfferingResource\Pages;
4
 
4
 
5
-use App\Filament\Company\Clusters\Settings\Resources\TaxResource;
5
+use App\Filament\Company\Resources\Common\OfferingResource;
6
 use Filament\Actions;
6
 use Filament\Actions;
7
 use Filament\Resources\Pages\ListRecords;
7
 use Filament\Resources\Pages\ListRecords;
8
 use Filament\Support\Enums\MaxWidth;
8
 use Filament\Support\Enums\MaxWidth;
9
 
9
 
10
-class ListTaxes extends ListRecords
10
+class ListOfferings extends ListRecords
11
 {
11
 {
12
-    protected static string $resource = TaxResource::class;
12
+    protected static string $resource = OfferingResource::class;
13
 
13
 
14
     protected function getHeaderActions(): array
14
     protected function getHeaderActions(): array
15
     {
15
     {
20
 
20
 
21
     public function getMaxContentWidth(): MaxWidth | string | null
21
     public function getMaxContentWidth(): MaxWidth | string | null
22
     {
22
     {
23
-        return MaxWidth::ScreenTwoExtraLarge;
23
+        return 'max-w-8xl';
24
     }
24
     }
25
 }
25
 }

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Filament\Company\Resources\Purchases\BillResource\Pages;
8
+use App\Filament\Tables\Actions\ReplicateBulkAction;
9
+use App\Filament\Tables\Filters\DateRangeFilter;
10
+use App\Models\Accounting\Adjustment;
11
+use App\Models\Accounting\Bill;
12
+use App\Models\Accounting\DocumentLineItem;
13
+use App\Models\Banking\BankAccount;
14
+use App\Models\Common\Offering;
15
+use App\Utilities\Currency\CurrencyConverter;
16
+use Awcodes\TableRepeater\Components\TableRepeater;
17
+use Awcodes\TableRepeater\Header;
18
+use Closure;
19
+use Filament\Forms;
20
+use Filament\Forms\Form;
21
+use Filament\Notifications\Notification;
22
+use Filament\Resources\Resource;
23
+use Filament\Support\Enums\Alignment;
24
+use Filament\Support\Enums\MaxWidth;
25
+use Filament\Tables;
26
+use Filament\Tables\Table;
27
+use Illuminate\Database\Eloquent\Builder;
28
+use Illuminate\Database\Eloquent\Collection;
29
+use Illuminate\Database\Eloquent\Model;
30
+use Illuminate\Support\Facades\Auth;
31
+
32
+class BillResource extends Resource
33
+{
34
+    protected static ?string $model = Bill::class;
35
+
36
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
37
+
38
+    public static function form(Form $form): Form
39
+    {
40
+        $company = Auth::user()->currentCompany;
41
+
42
+        return $form
43
+            ->schema([
44
+                Forms\Components\Section::make('Bill Details')
45
+                    ->schema([
46
+                        Forms\Components\Split::make([
47
+                            Forms\Components\Group::make([
48
+                                Forms\Components\Select::make('vendor_id')
49
+                                    ->relationship('vendor', 'name')
50
+                                    ->preload()
51
+                                    ->searchable()
52
+                                    ->required(),
53
+                            ]),
54
+                            Forms\Components\Group::make([
55
+                                Forms\Components\TextInput::make('bill_number')
56
+                                    ->label('Bill Number')
57
+                                    ->default(fn () => Bill::getNextDocumentNumber())
58
+                                    ->required(),
59
+                                Forms\Components\TextInput::make('order_number')
60
+                                    ->label('P.O/S.O Number'),
61
+                                Forms\Components\DatePicker::make('date')
62
+                                    ->label('Bill Date')
63
+                                    ->default(now())
64
+                                    ->disabled(function (?Bill $record) {
65
+                                        return $record?->hasPayments();
66
+                                    })
67
+                                    ->required(),
68
+                                Forms\Components\DatePicker::make('due_date')
69
+                                    ->label('Due Date')
70
+                                    ->default(function () use ($company) {
71
+                                        return now()->addDays($company->defaultBill->payment_terms->getDays());
72
+                                    })
73
+                                    ->required(),
74
+                            ])->grow(true),
75
+                        ])->from('md'),
76
+                        TableRepeater::make('lineItems')
77
+                            ->relationship()
78
+                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
79
+                                if (! is_array($state)) {
80
+                                    $state = [];
81
+                                }
82
+
83
+                                $relationship = $component->getRelationship();
84
+
85
+                                $existingRecords = $component->getCachedExistingRecords();
86
+
87
+                                $recordsToDelete = [];
88
+
89
+                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
90
+                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
91
+                                        continue;
92
+                                    }
93
+
94
+                                    $recordsToDelete[] = $keyToCheckForDeletion;
95
+                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
96
+                                }
97
+
98
+                                $relationship
99
+                                    ->whereKey($recordsToDelete)
100
+                                    ->get()
101
+                                    ->each(static fn (Model $record) => $record->delete());
102
+
103
+                                $childComponentContainers = $component->getChildComponentContainers(
104
+                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
105
+                                );
106
+
107
+                                $itemOrder = 1;
108
+                                $orderColumn = $component->getOrderColumn();
109
+
110
+                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
111
+
112
+                                foreach ($childComponentContainers as $itemKey => $item) {
113
+                                    $itemData = $item->getState(shouldCallHooksBefore: false);
114
+
115
+                                    if ($orderColumn) {
116
+                                        $itemData[$orderColumn] = $itemOrder;
117
+
118
+                                        $itemOrder++;
119
+                                    }
120
+
121
+                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
122
+                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
123
+
124
+                                        if ($itemData === null) {
125
+                                            continue;
126
+                                        }
127
+
128
+                                        $translatableContentDriver ?
129
+                                            $translatableContentDriver->updateRecord($record, $itemData) :
130
+                                            $record->fill($itemData)->save();
131
+
132
+                                        continue;
133
+                                    }
134
+
135
+                                    $relatedModel = $component->getRelatedModel();
136
+
137
+                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
138
+
139
+                                    if ($itemData === null) {
140
+                                        continue;
141
+                                    }
142
+
143
+                                    if ($translatableContentDriver) {
144
+                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
145
+                                    } else {
146
+                                        $record = new $relatedModel;
147
+                                        $record->fill($itemData);
148
+                                    }
149
+
150
+                                    $record = $relationship->save($record);
151
+                                    $item->model($record)->saveRelationships();
152
+                                    $existingRecords->push($record);
153
+                                }
154
+
155
+                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
156
+
157
+                                /** @var Bill $bill */
158
+                                $bill = $component->getRecord();
159
+
160
+                                // Recalculate totals for line items
161
+                                $bill->lineItems()->each(function (DocumentLineItem $lineItem) {
162
+                                    $lineItem->updateQuietly([
163
+                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
164
+                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
165
+                                    ]);
166
+                                });
167
+
168
+                                $subtotal = $bill->lineItems()->sum('subtotal') / 100;
169
+                                $taxTotal = $bill->lineItems()->sum('tax_total') / 100;
170
+                                $discountTotal = $bill->lineItems()->sum('discount_total') / 100;
171
+                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
172
+
173
+                                $bill->updateQuietly([
174
+                                    'subtotal' => $subtotal,
175
+                                    'tax_total' => $taxTotal,
176
+                                    'discount_total' => $discountTotal,
177
+                                    'total' => $grandTotal,
178
+                                ]);
179
+
180
+                                $bill->refresh();
181
+
182
+                                if (! $bill->initialTransaction) {
183
+                                    $bill->createInitialTransaction();
184
+                                } else {
185
+                                    $bill->updateInitialTransaction();
186
+                                }
187
+                            })
188
+                            ->headers([
189
+                                Header::make('Items')->width('15%'),
190
+                                Header::make('Description')->width('25%'),
191
+                                Header::make('Quantity')->width('10%'),
192
+                                Header::make('Price')->width('10%'),
193
+                                Header::make('Taxes')->width('15%'),
194
+                                Header::make('Discounts')->width('15%'),
195
+                                Header::make('Amount')->width('10%')->align('right'),
196
+                            ])
197
+                            ->schema([
198
+                                Forms\Components\Select::make('offering_id')
199
+                                    ->relationship('purchasableOffering', 'name')
200
+                                    ->preload()
201
+                                    ->searchable()
202
+                                    ->required()
203
+                                    ->live()
204
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
205
+                                        $offeringId = $state;
206
+                                        $offeringRecord = Offering::with(['purchaseTaxes', 'purchaseDiscounts'])->find($offeringId);
207
+
208
+                                        if ($offeringRecord) {
209
+                                            $set('description', $offeringRecord->description);
210
+                                            $set('unit_price', $offeringRecord->price);
211
+                                            $set('purchaseTaxes', $offeringRecord->purchaseTaxes->pluck('id')->toArray());
212
+                                            $set('purchaseDiscounts', $offeringRecord->purchaseDiscounts->pluck('id')->toArray());
213
+                                        }
214
+                                    }),
215
+                                Forms\Components\TextInput::make('description'),
216
+                                Forms\Components\TextInput::make('quantity')
217
+                                    ->required()
218
+                                    ->numeric()
219
+                                    ->live()
220
+                                    ->default(1),
221
+                                Forms\Components\TextInput::make('unit_price')
222
+                                    ->hiddenLabel()
223
+                                    ->numeric()
224
+                                    ->live()
225
+                                    ->default(0),
226
+                                Forms\Components\Select::make('purchaseTaxes')
227
+                                    ->relationship('purchaseTaxes', 'name')
228
+                                    ->preload()
229
+                                    ->multiple()
230
+                                    ->live()
231
+                                    ->searchable(),
232
+                                Forms\Components\Select::make('purchaseDiscounts')
233
+                                    ->relationship('purchaseDiscounts', 'name')
234
+                                    ->preload()
235
+                                    ->multiple()
236
+                                    ->live()
237
+                                    ->searchable(),
238
+                                Forms\Components\Placeholder::make('total')
239
+                                    ->hiddenLabel()
240
+                                    ->content(function (Forms\Get $get) {
241
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
242
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
243
+                                        $purchaseTaxes = $get('purchaseTaxes') ?? [];
244
+                                        $purchaseDiscounts = $get('purchaseDiscounts') ?? [];
245
+
246
+                                        $subtotal = $quantity * $unitPrice;
247
+
248
+                                        // Calculate tax amount based on subtotal
249
+                                        $taxAmount = 0;
250
+                                        if (! empty($purchaseTaxes)) {
251
+                                            $taxRates = Adjustment::whereIn('id', $purchaseTaxes)->pluck('rate');
252
+                                            $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
253
+                                        }
254
+
255
+                                        // Calculate discount amount based on subtotal
256
+                                        $discountAmount = 0;
257
+                                        if (! empty($purchaseDiscounts)) {
258
+                                            $discountRates = Adjustment::whereIn('id', $purchaseDiscounts)->pluck('rate');
259
+                                            $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
260
+                                        }
261
+
262
+                                        // Final total
263
+                                        $total = $subtotal + ($taxAmount - $discountAmount);
264
+
265
+                                        return CurrencyConverter::formatToMoney($total);
266
+                                    }),
267
+                            ]),
268
+                        Forms\Components\Grid::make(6)
269
+                            ->schema([
270
+                                Forms\Components\ViewField::make('totals')
271
+                                    ->columnStart(5)
272
+                                    ->columnSpan(2)
273
+                                    ->view('filament.forms.components.bill-totals'),
274
+                            ]),
275
+                    ]),
276
+            ]);
277
+    }
278
+
279
+    public static function table(Table $table): Table
280
+    {
281
+        return $table
282
+            ->defaultSort('due_date')
283
+            ->columns([
284
+                Tables\Columns\TextColumn::make('status')
285
+                    ->badge()
286
+                    ->searchable(),
287
+                Tables\Columns\TextColumn::make('due_date')
288
+                    ->label('Due')
289
+                    ->asRelativeDay()
290
+                    ->sortable(),
291
+                Tables\Columns\TextColumn::make('date')
292
+                    ->date()
293
+                    ->sortable(),
294
+                Tables\Columns\TextColumn::make('bill_number')
295
+                    ->label('Number')
296
+                    ->searchable()
297
+                    ->sortable(),
298
+                Tables\Columns\TextColumn::make('vendor.name')
299
+                    ->sortable(),
300
+                Tables\Columns\TextColumn::make('total')
301
+                    ->currency()
302
+                    ->sortable(),
303
+                Tables\Columns\TextColumn::make('amount_paid')
304
+                    ->label('Amount Paid')
305
+                    ->currency()
306
+                    ->sortable(),
307
+                Tables\Columns\TextColumn::make('amount_due')
308
+                    ->label('Amount Due')
309
+                    ->currency()
310
+                    ->sortable(),
311
+            ])
312
+            ->filters([
313
+                Tables\Filters\SelectFilter::make('vendor')
314
+                    ->relationship('vendor', 'name')
315
+                    ->searchable()
316
+                    ->preload(),
317
+                Tables\Filters\SelectFilter::make('status')
318
+                    ->options(BillStatus::class)
319
+                    ->native(false),
320
+                Tables\Filters\TernaryFilter::make('has_payments')
321
+                    ->label('Has Payments')
322
+                    ->queries(
323
+                        true: fn (Builder $query) => $query->whereHas('payments'),
324
+                        false: fn (Builder $query) => $query->whereDoesntHave('payments'),
325
+                    ),
326
+                DateRangeFilter::make('date')
327
+                    ->fromLabel('From Date')
328
+                    ->untilLabel('To Date')
329
+                    ->indicatorLabel('Date'),
330
+                DateRangeFilter::make('due_date')
331
+                    ->fromLabel('From Due Date')
332
+                    ->untilLabel('To Due Date')
333
+                    ->indicatorLabel('Due'),
334
+            ])
335
+            ->actions([
336
+                Tables\Actions\ActionGroup::make([
337
+                    Tables\Actions\EditAction::make(),
338
+                    Tables\Actions\ViewAction::make(),
339
+                    Tables\Actions\DeleteAction::make(),
340
+                    Bill::getReplicateAction(Tables\Actions\ReplicateAction::class),
341
+                    Tables\Actions\Action::make('recordPayment')
342
+                        ->label('Record Payment')
343
+                        ->stickyModalHeader()
344
+                        ->stickyModalFooter()
345
+                        ->modalFooterActionsAlignment(Alignment::End)
346
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
347
+                        ->icon('heroicon-o-credit-card')
348
+                        ->visible(function (Bill $record) {
349
+                            return $record->canRecordPayment();
350
+                        })
351
+                        ->mountUsing(function (Bill $record, Form $form) {
352
+                            $form->fill([
353
+                                'posted_at' => now(),
354
+                                'amount' => $record->amount_due,
355
+                            ]);
356
+                        })
357
+                        ->databaseTransaction()
358
+                        ->successNotificationTitle('Payment Recorded')
359
+                        ->form([
360
+                            Forms\Components\DatePicker::make('posted_at')
361
+                                ->label('Date'),
362
+                            Forms\Components\TextInput::make('amount')
363
+                                ->label('Amount')
364
+                                ->required()
365
+                                ->money()
366
+                                ->live(onBlur: true)
367
+                                ->helperText(function (Bill $record, $state) {
368
+                                    if (! CurrencyConverter::isValidAmount($state)) {
369
+                                        return null;
370
+                                    }
371
+
372
+                                    $amountDue = $record->getRawOriginal('amount_due');
373
+                                    $amount = CurrencyConverter::convertToCents($state);
374
+
375
+                                    if ($amount <= 0) {
376
+                                        return 'Please enter a valid positive amount';
377
+                                    }
378
+
379
+                                    $newAmountDue = $amountDue - $amount;
380
+
381
+                                    return match (true) {
382
+                                        $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
383
+                                        $newAmountDue === 0 => 'Bill will be fully paid',
384
+                                        default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
385
+                                    };
386
+                                })
387
+                                ->rules([
388
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
389
+                                        if (! CurrencyConverter::isValidAmount($value)) {
390
+                                            $fail('Please enter a valid amount');
391
+                                        }
392
+                                    },
393
+                                ]),
394
+                            Forms\Components\Select::make('payment_method')
395
+                                ->label('Payment Method')
396
+                                ->required()
397
+                                ->options(PaymentMethod::class),
398
+                            Forms\Components\Select::make('bank_account_id')
399
+                                ->label('Account')
400
+                                ->required()
401
+                                ->options(BankAccount::query()
402
+                                    ->get()
403
+                                    ->pluck('account.name', 'id'))
404
+                                ->searchable(),
405
+                            Forms\Components\Textarea::make('notes')
406
+                                ->label('Notes'),
407
+                        ])
408
+                        ->action(function (Bill $record, Tables\Actions\Action $action, array $data) {
409
+                            $record->recordPayment($data);
410
+
411
+                            $action->success();
412
+                        }),
413
+                ]),
414
+            ])
415
+            ->bulkActions([
416
+                Tables\Actions\BulkActionGroup::make([
417
+                    Tables\Actions\DeleteBulkAction::make(),
418
+                    ReplicateBulkAction::make()
419
+                        ->label('Replicate')
420
+                        ->modalWidth(MaxWidth::Large)
421
+                        ->modalDescription('Replicating bills will also replicate their line items. Are you sure you want to proceed?')
422
+                        ->successNotificationTitle('Bills Replicated Successfully')
423
+                        ->failureNotificationTitle('Failed to Replicate Bills')
424
+                        ->databaseTransaction()
425
+                        ->deselectRecordsAfterCompletion()
426
+                        ->excludeAttributes([
427
+                            'status',
428
+                            'amount_paid',
429
+                            'amount_due',
430
+                            'created_by',
431
+                            'updated_by',
432
+                            'created_at',
433
+                            'updated_at',
434
+                            'bill_number',
435
+                            'date',
436
+                            'due_date',
437
+                            'paid_at',
438
+                        ])
439
+                        ->beforeReplicaSaved(function (Bill $replica) {
440
+                            $replica->status = BillStatus::Unpaid;
441
+                            $replica->bill_number = Bill::getNextDocumentNumber();
442
+                            $replica->date = now();
443
+                            $replica->due_date = now()->addDays($replica->company->defaultBill->payment_terms->getDays());
444
+                        })
445
+                        ->withReplicatedRelationships(['lineItems'])
446
+                        ->withExcludedRelationshipAttributes('lineItems', [
447
+                            'subtotal',
448
+                            'total',
449
+                            'created_by',
450
+                            'updated_by',
451
+                            'created_at',
452
+                            'updated_at',
453
+                        ]),
454
+                    Tables\Actions\BulkAction::make('recordPayments')
455
+                        ->label('Record Payments')
456
+                        ->icon('heroicon-o-credit-card')
457
+                        ->stickyModalHeader()
458
+                        ->stickyModalFooter()
459
+                        ->modalFooterActionsAlignment(Alignment::End)
460
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
461
+                        ->databaseTransaction()
462
+                        ->successNotificationTitle('Payments Recorded')
463
+                        ->failureNotificationTitle('Failed to Record Payments')
464
+                        ->deselectRecordsAfterCompletion()
465
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
466
+                            $cantRecordPayments = $records->contains(fn (Bill $bill) => ! $bill->canRecordPayment());
467
+
468
+                            if ($cantRecordPayments) {
469
+                                Notification::make()
470
+                                    ->title('Payment Recording Failed')
471
+                                    ->body('Bills that are either paid or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
472
+                                    ->persistent()
473
+                                    ->danger()
474
+                                    ->send();
475
+
476
+                                $action->cancel(true);
477
+                            }
478
+                        })
479
+                        ->mountUsing(function (Collection $records, Form $form) {
480
+                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
481
+
482
+                            $form->fill([
483
+                                'posted_at' => now(),
484
+                                'amount' => CurrencyConverter::convertCentsToFormatSimple($totalAmountDue),
485
+                            ]);
486
+                        })
487
+                        ->form([
488
+                            Forms\Components\DatePicker::make('posted_at')
489
+                                ->label('Date'),
490
+                            Forms\Components\TextInput::make('amount')
491
+                                ->label('Amount')
492
+                                ->required()
493
+                                ->money()
494
+                                ->rules([
495
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
496
+                                        if (! CurrencyConverter::isValidAmount($value)) {
497
+                                            $fail('Please enter a valid amount');
498
+                                        }
499
+                                    },
500
+                                ]),
501
+                            Forms\Components\Select::make('payment_method')
502
+                                ->label('Payment Method')
503
+                                ->required()
504
+                                ->options(PaymentMethod::class),
505
+                            Forms\Components\Select::make('bank_account_id')
506
+                                ->label('Account')
507
+                                ->required()
508
+                                ->options(BankAccount::query()
509
+                                    ->get()
510
+                                    ->pluck('account.name', 'id'))
511
+                                ->searchable(),
512
+                            Forms\Components\Textarea::make('notes')
513
+                                ->label('Notes'),
514
+                        ])
515
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
516
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
517
+                            $totalAmountDue = $records->sum(fn (Bill $bill) => $bill->getRawOriginal('amount_due'));
518
+
519
+                            if ($totalPaymentAmount > $totalAmountDue) {
520
+                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
521
+
522
+                                Notification::make()
523
+                                    ->title('Excess Payment Amount')
524
+                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
525
+                                    ->persistent()
526
+                                    ->warning()
527
+                                    ->send();
528
+
529
+                                $action->halt(true);
530
+                            }
531
+                        })
532
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action, array $data) {
533
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
534
+                            $remainingAmount = $totalPaymentAmount;
535
+
536
+                            $records->each(function (Bill $record) use (&$remainingAmount, $data) {
537
+                                $amountDue = $record->getRawOriginal('amount_due');
538
+
539
+                                if ($amountDue <= 0 || $remainingAmount <= 0) {
540
+                                    return;
541
+                                }
542
+
543
+                                $paymentAmount = min($amountDue, $remainingAmount);
544
+                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
545
+
546
+                                $record->recordPayment($data);
547
+                                $remainingAmount -= $paymentAmount;
548
+                            });
549
+
550
+                            $action->success();
551
+                        }),
552
+                ]),
553
+            ]);
554
+    }
555
+
556
+    public static function getRelations(): array
557
+    {
558
+        return [
559
+            BillResource\RelationManagers\PaymentsRelationManager::class,
560
+        ];
561
+    }
562
+
563
+    public static function getPages(): array
564
+    {
565
+        return [
566
+            'index' => Pages\ListBills::route('/'),
567
+            'create' => Pages\CreateBill::route('/create'),
568
+            'view' => Pages\ViewBill::route('/{record}'),
569
+            'edit' => Pages\EditBill::route('/{record}/edit'),
570
+        ];
571
+    }
572
+
573
+    public static function getWidgets(): array
574
+    {
575
+        return [
576
+            BillResource\Widgets\BillOverview::class,
577
+        ];
578
+    }
579
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\BillResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateBill extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = BillResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::Full;
19
+    }
20
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\BillResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditBill extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = BillResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::Full;
27
+    }
28
+}

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

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

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Purchases\BillResource;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use App\Models\Accounting\Bill;
8
+use Filament\Actions;
9
+use Filament\Infolists\Components\Section;
10
+use Filament\Infolists\Components\TextEntry;
11
+use Filament\Infolists\Infolist;
12
+use Filament\Resources\Pages\ViewRecord;
13
+use Filament\Support\Enums\FontWeight;
14
+use Filament\Support\Enums\IconPosition;
15
+use Filament\Support\Enums\IconSize;
16
+
17
+class ViewBill extends ViewRecord
18
+{
19
+    protected static string $resource = BillResource::class;
20
+
21
+    protected $listeners = [
22
+        'refresh' => '$refresh',
23
+    ];
24
+
25
+    protected function getHeaderActions(): array
26
+    {
27
+        return [
28
+            Actions\ActionGroup::make([
29
+                Actions\EditAction::make(),
30
+                Actions\DeleteAction::make(),
31
+                Bill::getReplicateAction(),
32
+            ])
33
+                ->label('Actions')
34
+                ->button()
35
+                ->outlined()
36
+                ->dropdownPlacement('bottom-end')
37
+                ->icon('heroicon-c-chevron-down')
38
+                ->iconSize(IconSize::Small)
39
+                ->iconPosition(IconPosition::After),
40
+        ];
41
+    }
42
+
43
+    public function infolist(Infolist $infolist): Infolist
44
+    {
45
+        return $infolist
46
+            ->schema([
47
+                Section::make('Bill Details')
48
+                    ->columns(4)
49
+                    ->schema([
50
+                        TextEntry::make('bill_number')
51
+                            ->label('Invoice #'),
52
+                        TextEntry::make('status')
53
+                            ->badge(),
54
+                        TextEntry::make('vendor.name')
55
+                            ->label('Vendor')
56
+                            ->color('primary')
57
+                            ->weight(FontWeight::SemiBold)
58
+                            ->url(static fn (Bill $record) => VendorResource::getUrl('edit', ['record' => $record->vendor_id])),
59
+                        TextEntry::make('total')
60
+                            ->label('Total')
61
+                            ->money(),
62
+                        TextEntry::make('amount_due')
63
+                            ->label('Amount Due')
64
+                            ->money(),
65
+                        TextEntry::make('date')
66
+                            ->label('Date')
67
+                            ->date(),
68
+                        TextEntry::make('due_date')
69
+                            ->label('Due')
70
+                            ->asRelativeDay(),
71
+                        TextEntry::make('paid_at')
72
+                            ->label('Paid At')
73
+                            ->placeholder('Not Paid')
74
+                            ->date(),
75
+                    ]),
76
+            ]);
77
+    }
78
+}

+ 182
- 0
app/Filament/Company/Resources/Purchases/BillResource/RelationManagers/PaymentsRelationManager.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\RelationManagers;
4
+
5
+use App\Enums\Accounting\PaymentMethod;
6
+use App\Enums\Accounting\TransactionType;
7
+use App\Filament\Company\Resources\Purchases\BillResource\Pages\ViewBill;
8
+use App\Models\Accounting\Bill;
9
+use App\Models\Accounting\Transaction;
10
+use App\Models\Banking\BankAccount;
11
+use App\Utilities\Currency\CurrencyAccessor;
12
+use App\Utilities\Currency\CurrencyConverter;
13
+use Closure;
14
+use Filament\Forms;
15
+use Filament\Forms\Form;
16
+use Filament\Resources\RelationManagers\RelationManager;
17
+use Filament\Support\Colors\Color;
18
+use Filament\Support\Enums\FontWeight;
19
+use Filament\Support\Enums\MaxWidth;
20
+use Filament\Tables;
21
+use Filament\Tables\Table;
22
+use Illuminate\Database\Eloquent\Model;
23
+
24
+class PaymentsRelationManager extends RelationManager
25
+{
26
+    protected static string $relationship = 'payments';
27
+
28
+    protected static ?string $modelLabel = 'Payment';
29
+
30
+    protected $listeners = [
31
+        'refresh' => '$refresh',
32
+    ];
33
+
34
+    public function isReadOnly(): bool
35
+    {
36
+        return false;
37
+    }
38
+
39
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
40
+    {
41
+        return $pageClass === ViewBill::class;
42
+    }
43
+
44
+    public function form(Form $form): Form
45
+    {
46
+        return $form
47
+            ->columns(1)
48
+            ->schema([
49
+                Forms\Components\DatePicker::make('posted_at')
50
+                    ->label('Date'),
51
+                Forms\Components\TextInput::make('amount')
52
+                    ->label('Amount')
53
+                    ->required()
54
+                    ->money()
55
+                    ->live(onBlur: true)
56
+                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
57
+                        if (! CurrencyConverter::isValidAmount($state)) {
58
+                            return null;
59
+                        }
60
+
61
+                        /** @var Bill $ownerRecord */
62
+                        $ownerRecord = $livewire->getOwnerRecord();
63
+
64
+                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
65
+
66
+                        $amount = CurrencyConverter::convertToCents($state);
67
+
68
+                        if ($amount <= 0) {
69
+                            return 'Please enter a valid positive amount';
70
+                        }
71
+
72
+                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
73
+
74
+                        $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
75
+
76
+                        return match (true) {
77
+                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
78
+                            $newAmountDue === 0 => 'Bill will be fully paid',
79
+                            default => 'Amount exceeds bill total by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
80
+                        };
81
+                    })
82
+                    ->rules([
83
+                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
84
+                            if (! CurrencyConverter::isValidAmount($value)) {
85
+                                $fail('Please enter a valid amount');
86
+                            }
87
+                        },
88
+                    ]),
89
+                Forms\Components\Select::make('payment_method')
90
+                    ->label('Payment Method')
91
+                    ->required()
92
+                    ->options(PaymentMethod::class),
93
+                Forms\Components\Select::make('bank_account_id')
94
+                    ->label('Account')
95
+                    ->required()
96
+                    ->options(BankAccount::query()
97
+                        ->get()
98
+                        ->pluck('account.name', 'id'))
99
+                    ->searchable(),
100
+                Forms\Components\Textarea::make('notes')
101
+                    ->label('Notes'),
102
+            ]);
103
+    }
104
+
105
+    public function table(Table $table): Table
106
+    {
107
+        return $table
108
+            ->recordTitleAttribute('description')
109
+            ->columns([
110
+                Tables\Columns\TextColumn::make('posted_at')
111
+                    ->label('Date')
112
+                    ->sortable()
113
+                    ->defaultDateFormat(),
114
+                Tables\Columns\TextColumn::make('type')
115
+                    ->label('Type')
116
+                    ->sortable()
117
+                    ->toggleable(isToggledHiddenByDefault: true),
118
+                Tables\Columns\TextColumn::make('description')
119
+                    ->label('Description')
120
+                    ->limit(30)
121
+                    ->toggleable(),
122
+                Tables\Columns\TextColumn::make('bankAccount.account.name')
123
+                    ->label('Account')
124
+                    ->toggleable(),
125
+                Tables\Columns\TextColumn::make('amount')
126
+                    ->label('Amount')
127
+                    ->weight(static fn (Transaction $transaction) => $transaction->reviewed ? null : FontWeight::SemiBold)
128
+                    ->color(
129
+                        static fn (Transaction $transaction) => match ($transaction->type) {
130
+                            TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
131
+                            TransactionType::Journal => 'primary',
132
+                            default => null,
133
+                        }
134
+                    )
135
+                    ->sortable()
136
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
137
+            ])
138
+            ->filters([
139
+                //
140
+            ])
141
+            ->headerActions([
142
+                Tables\Actions\CreateAction::make()
143
+                    ->label('Record Payment')
144
+                    ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
145
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
146
+                    ->visible(function () {
147
+                        return $this->getOwnerRecord()->canRecordPayment();
148
+                    })
149
+                    ->mountUsing(function (Form $form) {
150
+                        $record = $this->getOwnerRecord();
151
+                        $form->fill([
152
+                            'posted_at' => now(),
153
+                            'amount' => $record->amount_due,
154
+                        ]);
155
+                    })
156
+                    ->databaseTransaction()
157
+                    ->successNotificationTitle('Payment Recorded')
158
+                    ->action(function (Tables\Actions\CreateAction $action, array $data) {
159
+                        /** @var Bill $record */
160
+                        $record = $this->getOwnerRecord();
161
+
162
+                        $record->recordPayment($data);
163
+
164
+                        $action->success();
165
+
166
+                        $this->dispatch('refresh');
167
+                    }),
168
+            ])
169
+            ->actions([
170
+                Tables\Actions\EditAction::make()
171
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
172
+                    ->after(fn () => $this->dispatch('refresh')),
173
+                Tables\Actions\DeleteAction::make()
174
+                    ->after(fn () => $this->dispatch('refresh')),
175
+            ])
176
+            ->bulkActions([
177
+                Tables\Actions\BulkActionGroup::make([
178
+                    Tables\Actions\DeleteBulkAction::make(),
179
+                ]),
180
+            ]);
181
+    }
182
+}

+ 69
- 0
app/Filament/Company/Resources/Purchases/BillResource/Widgets/BillOverview.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\BillResource\Widgets;
4
+
5
+use App\Enums\Accounting\BillStatus;
6
+use App\Filament\Company\Resources\Purchases\BillResource\Pages\ListBills;
7
+use App\Filament\Widgets\EnhancedStatsOverviewWidget;
8
+use App\Utilities\Currency\CurrencyAccessor;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Widgets\Concerns\InteractsWithPageTable;
11
+use Illuminate\Support\Number;
12
+
13
+class BillOverview extends EnhancedStatsOverviewWidget
14
+{
15
+    use InteractsWithPageTable;
16
+
17
+    protected function getTablePage(): string
18
+    {
19
+        return ListBills::class;
20
+    }
21
+
22
+    protected function getStats(): array
23
+    {
24
+        $unpaidBills = $this->getPageTableQuery()
25
+            ->whereIn('status', [BillStatus::Unpaid, BillStatus::Partial, BillStatus::Overdue]);
26
+
27
+        $amountToPay = $unpaidBills->sum('amount_due');
28
+
29
+        $amountOverdue = $unpaidBills
30
+            ->clone()
31
+            ->where('status', BillStatus::Overdue)
32
+            ->sum('amount_due');
33
+
34
+        $amountDueWithin7Days = $unpaidBills
35
+            ->clone()
36
+            ->whereBetween('due_date', [today(), today()->addWeek()])
37
+            ->sum('amount_due');
38
+
39
+        $averagePaymentTime = $this->getPageTableQuery()
40
+            ->whereNotNull('paid_at')
41
+            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
42
+            ->value('avg_days');
43
+
44
+        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
45
+
46
+        $lastMonthTotal = $this->getPageTableQuery()
47
+            ->where('status', BillStatus::Paid)
48
+            ->whereBetween('date', [
49
+                today()->subMonth()->startOfMonth(),
50
+                today()->subMonth()->endOfMonth(),
51
+            ])
52
+            ->sum('amount_paid');
53
+
54
+        return [
55
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Total To Pay', CurrencyConverter::formatCentsToMoney($amountToPay))
56
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
57
+                ->description('Includes ' . CurrencyConverter::formatCentsToMoney($amountOverdue) . ' overdue'),
58
+
59
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 7 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin7Days))
60
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
61
+
62
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
63
+                ->suffix('days'),
64
+
65
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Paid Last Month', CurrencyConverter::formatCentsToMoney($lastMonthTotal))
66
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
67
+        ];
68
+    }
69
+}

+ 220
- 0
app/Filament/Company/Resources/Purchases/VendorResource.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases;
4
+
5
+use App\Enums\Common\ContractorType;
6
+use App\Enums\Common\VendorType;
7
+use App\Filament\Company\Resources\Purchases\VendorResource\Pages;
8
+use App\Filament\Forms\Components\CreateCurrencySelect;
9
+use App\Filament\Forms\Components\CustomSection;
10
+use App\Filament\Forms\Components\PhoneBuilder;
11
+use App\Models\Common\Vendor;
12
+use Filament\Forms;
13
+use Filament\Forms\Form;
14
+use Filament\Resources\Resource;
15
+use Filament\Tables;
16
+use Filament\Tables\Table;
17
+
18
+class VendorResource extends Resource
19
+{
20
+    protected static ?string $model = Vendor::class;
21
+
22
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
23
+
24
+    public static function form(Form $form): Form
25
+    {
26
+        return $form
27
+            ->schema([
28
+                Forms\Components\Section::make('General Information')
29
+                    ->schema([
30
+                        Forms\Components\Group::make()
31
+                            ->columns(2)
32
+                            ->schema([
33
+                                Forms\Components\TextInput::make('name')
34
+                                    ->label('Vendor Name')
35
+                                    ->required()
36
+                                    ->maxLength(255),
37
+                                Forms\Components\Radio::make('type')
38
+                                    ->label('Vendor Type')
39
+                                    ->required()
40
+                                    ->live()
41
+                                    ->options(VendorType::class)
42
+                                    ->default(VendorType::Regular)
43
+                                    ->columnSpanFull(),
44
+                                CreateCurrencySelect::make('currency_code')
45
+                                    ->relationship('currency', 'name')
46
+                                    ->nullable()
47
+                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Regular),
48
+                                Forms\Components\Select::make('contractor_type')
49
+                                    ->label('Contractor Type')
50
+                                    ->required()
51
+                                    ->live()
52
+                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Contractor)
53
+                                    ->options(ContractorType::class),
54
+                                Forms\Components\TextInput::make('ssn')
55
+                                    ->label('Social Security Number')
56
+                                    ->required()
57
+                                    ->live()
58
+                                    ->mask('999-99-9999')
59
+                                    ->stripCharacters('-')
60
+                                    ->maxLength(11)
61
+                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Individual)
62
+                                    ->maxLength(255),
63
+                                Forms\Components\TextInput::make('ein')
64
+                                    ->label('Employer Identification Number')
65
+                                    ->required()
66
+                                    ->live()
67
+                                    ->mask('99-9999999')
68
+                                    ->stripCharacters('-')
69
+                                    ->maxLength(10)
70
+                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Business)
71
+                                    ->maxLength(255),
72
+                                Forms\Components\TextInput::make('account_number')
73
+                                    ->maxLength(255),
74
+                                Forms\Components\TextInput::make('website')
75
+                                    ->maxLength(255),
76
+                                Forms\Components\Textarea::make('notes')
77
+                                    ->columnSpanFull(),
78
+                            ]),
79
+                        CustomSection::make('Primary Contact')
80
+                            ->relationship('contact')
81
+                            ->contained(false)
82
+                            ->schema([
83
+                                Forms\Components\Hidden::make('is_primary')
84
+                                    ->default(true),
85
+                                Forms\Components\TextInput::make('first_name')
86
+                                    ->label('First Name')
87
+                                    ->required()
88
+                                    ->maxLength(255),
89
+                                Forms\Components\TextInput::make('last_name')
90
+                                    ->label('Last Name')
91
+                                    ->required()
92
+                                    ->maxLength(255),
93
+                                Forms\Components\TextInput::make('email')
94
+                                    ->label('Email')
95
+                                    ->required()
96
+                                    ->email()
97
+                                    ->columnSpanFull()
98
+                                    ->maxLength(255),
99
+                                PhoneBuilder::make('phones')
100
+                                    ->hiddenLabel()
101
+                                    ->blockLabels(false)
102
+                                    ->default([
103
+                                        ['type' => 'primary'],
104
+                                    ])
105
+                                    ->columnSpanFull()
106
+                                    ->blocks([
107
+                                        Forms\Components\Builder\Block::make('primary')
108
+                                            ->schema([
109
+                                                Forms\Components\TextInput::make('number')
110
+                                                    ->label('Phone')
111
+                                                    ->required()
112
+                                                    ->maxLength(15),
113
+                                            ])->maxItems(1),
114
+                                        Forms\Components\Builder\Block::make('mobile')
115
+                                            ->schema([
116
+                                                Forms\Components\TextInput::make('number')
117
+                                                    ->label('Mobile')
118
+                                                    ->required()
119
+                                                    ->maxLength(15),
120
+                                            ])->maxItems(1),
121
+                                        Forms\Components\Builder\Block::make('toll_free')
122
+                                            ->schema([
123
+                                                Forms\Components\TextInput::make('number')
124
+                                                    ->label('Toll Free')
125
+                                                    ->required()
126
+                                                    ->maxLength(15),
127
+                                            ])->maxItems(1),
128
+                                        Forms\Components\Builder\Block::make('fax')
129
+                                            ->schema([
130
+                                                Forms\Components\TextInput::make('number')
131
+                                                    ->label('Fax')
132
+                                                    ->live()
133
+                                                    ->maxLength(15),
134
+                                            ])->maxItems(1),
135
+                                    ])
136
+                                    ->deletable(fn (PhoneBuilder $builder) => $builder->getItemsCount() > 1)
137
+                                    ->reorderable(false)
138
+                                    ->blockNumbers(false)
139
+                                    ->addActionLabel('Add Phone'),
140
+                            ])->columns(),
141
+                    ])->columns(1),
142
+                Forms\Components\Section::make('Address Information')
143
+                    ->relationship('address')
144
+                    ->schema([
145
+                        Forms\Components\Hidden::make('type')
146
+                            ->default('general'),
147
+                        Forms\Components\TextInput::make('address_line_1')
148
+                            ->label('Address Line 1')
149
+                            ->required()
150
+                            ->maxLength(255),
151
+                        Forms\Components\TextInput::make('address_line_2')
152
+                            ->label('Address Line 2')
153
+                            ->maxLength(255),
154
+                        Forms\Components\TextInput::make('city')
155
+                            ->label('City')
156
+                            ->required()
157
+                            ->maxLength(255),
158
+                        Forms\Components\TextInput::make('state')
159
+                            ->label('State')
160
+                            ->required()
161
+                            ->maxLength(255),
162
+                        Forms\Components\TextInput::make('postal_code')
163
+                            ->label('Postal Code / Zip Code')
164
+                            ->required()
165
+                            ->maxLength(255),
166
+                        Forms\Components\TextInput::make('country')
167
+                            ->label('Country')
168
+                            ->required()
169
+                            ->maxLength(255),
170
+                    ])
171
+                    ->columns(2),
172
+            ]);
173
+    }
174
+
175
+    public static function table(Table $table): Table
176
+    {
177
+        return $table
178
+            ->columns([
179
+                Tables\Columns\TextColumn::make('type')
180
+                    ->badge()
181
+                    ->searchable(),
182
+                Tables\Columns\TextColumn::make('name')
183
+                    ->searchable()
184
+                    ->description(fn (Vendor $vendor) => $vendor->contact?->full_name),
185
+                Tables\Columns\TextColumn::make('contact.email')
186
+                    ->label('Email')
187
+                    ->searchable(),
188
+                Tables\Columns\TextColumn::make('primaryContact.phones')
189
+                    ->label('Phone')
190
+                    ->state(fn (Vendor $vendor) => $vendor->contact?->first_available_phone),
191
+            ])
192
+            ->filters([
193
+                //
194
+            ])
195
+            ->actions([
196
+                Tables\Actions\EditAction::make(),
197
+            ])
198
+            ->bulkActions([
199
+                Tables\Actions\BulkActionGroup::make([
200
+                    Tables\Actions\DeleteBulkAction::make(),
201
+                ]),
202
+            ]);
203
+    }
204
+
205
+    public static function getRelations(): array
206
+    {
207
+        return [
208
+            //
209
+        ];
210
+    }
211
+
212
+    public static function getPages(): array
213
+    {
214
+        return [
215
+            'index' => Pages\ListVendors::route('/'),
216
+            'create' => Pages\CreateVendor::route('/create'),
217
+            'edit' => Pages\EditVendor::route('/{record}/edit'),
218
+        ];
219
+    }
220
+}

+ 20
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/CreateVendor.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateVendor extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = VendorResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::FiveExtraLarge;
19
+    }
20
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Purchases\VendorResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditVendor extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = VendorResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::FiveExtraLarge;
27
+    }
28
+}

+ 25
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/ListVendors.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Purchases\VendorResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class ListVendors extends ListRecords
11
+{
12
+    protected static string $resource = VendorResource::class;
13
+
14
+    protected function getHeaderActions(): array
15
+    {
16
+        return [
17
+            Actions\CreateAction::make(),
18
+        ];
19
+    }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
25
+}

+ 294
- 0
app/Filament/Company/Resources/Sales/ClientResource.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource\Pages;
6
+use App\Filament\Forms\Components\CreateCurrencySelect;
7
+use App\Filament\Forms\Components\CustomSection;
8
+use App\Filament\Forms\Components\PhoneBuilder;
9
+use App\Models\Common\Client;
10
+use Filament\Forms;
11
+use Filament\Forms\Form;
12
+use Filament\Resources\Resource;
13
+use Filament\Tables;
14
+use Filament\Tables\Table;
15
+
16
+class ClientResource extends Resource
17
+{
18
+    protected static ?string $model = Client::class;
19
+
20
+    public static function form(Form $form): Form
21
+    {
22
+        return $form
23
+            ->schema([
24
+                Forms\Components\Section::make('General Information')
25
+                    ->schema([
26
+                        Forms\Components\Group::make()
27
+                            ->columns()
28
+                            ->schema([
29
+                                Forms\Components\TextInput::make('name')
30
+                                    ->label('Client Name')
31
+                                    ->required()
32
+                                    ->maxLength(255),
33
+                                Forms\Components\TextInput::make('account_number')
34
+                                    ->maxLength(255)
35
+                                    ->columnStart(1),
36
+                                Forms\Components\TextInput::make('website')
37
+                                    ->maxLength(255),
38
+                                Forms\Components\Textarea::make('notes')
39
+                                    ->columnSpanFull(),
40
+                            ]),
41
+                        CustomSection::make('Primary Contact')
42
+                            ->relationship('primaryContact')
43
+                            ->contained(false)
44
+                            ->schema([
45
+                                Forms\Components\Hidden::make('is_primary')
46
+                                    ->default(true),
47
+                                Forms\Components\TextInput::make('first_name')
48
+                                    ->label('First Name')
49
+                                    ->required()
50
+                                    ->maxLength(255),
51
+                                Forms\Components\TextInput::make('last_name')
52
+                                    ->label('Last Name')
53
+                                    ->required()
54
+                                    ->maxLength(255),
55
+                                Forms\Components\TextInput::make('email')
56
+                                    ->label('Email')
57
+                                    ->required()
58
+                                    ->email()
59
+                                    ->columnSpanFull()
60
+                                    ->maxLength(255),
61
+                                PhoneBuilder::make('phones')
62
+                                    ->hiddenLabel()
63
+                                    ->blockLabels(false)
64
+                                    ->default([
65
+                                        ['type' => 'primary'],
66
+                                    ])
67
+                                    ->columnSpanFull()
68
+                                    ->blocks([
69
+                                        Forms\Components\Builder\Block::make('primary')
70
+                                            ->schema([
71
+                                                Forms\Components\TextInput::make('number')
72
+                                                    ->label('Phone')
73
+                                                    ->required()
74
+                                                    ->maxLength(15),
75
+                                            ])->maxItems(1),
76
+                                        Forms\Components\Builder\Block::make('mobile')
77
+                                            ->schema([
78
+                                                Forms\Components\TextInput::make('number')
79
+                                                    ->label('Mobile')
80
+                                                    ->required()
81
+                                                    ->maxLength(15),
82
+                                            ])->maxItems(1),
83
+                                        Forms\Components\Builder\Block::make('toll_free')
84
+                                            ->schema([
85
+                                                Forms\Components\TextInput::make('number')
86
+                                                    ->label('Toll Free')
87
+                                                    ->required()
88
+                                                    ->maxLength(15),
89
+                                            ])->maxItems(1),
90
+                                        Forms\Components\Builder\Block::make('fax')
91
+                                            ->schema([
92
+                                                Forms\Components\TextInput::make('number')
93
+                                                    ->label('Fax')
94
+                                                    ->live()
95
+                                                    ->maxLength(15),
96
+                                            ])->maxItems(1),
97
+                                    ])
98
+                                    ->deletable(fn (PhoneBuilder $builder) => $builder->getItemsCount() > 1)
99
+                                    ->reorderable(false)
100
+                                    ->blockNumbers(false)
101
+                                    ->addActionLabel('Add Phone'),
102
+                            ])->columns(),
103
+                        Forms\Components\Repeater::make('secondaryContacts')
104
+                            ->relationship()
105
+                            ->hiddenLabel()
106
+                            ->extraAttributes([
107
+                                'class' => 'uncontained',
108
+                            ])
109
+                            ->columns()
110
+                            ->defaultItems(0)
111
+                            ->maxItems(3)
112
+                            ->itemLabel(function (Forms\Components\Repeater $component, array $state): ?string {
113
+                                if ($component->getItemsCount() === 1) {
114
+                                    return 'Secondary Contact';
115
+                                }
116
+
117
+                                $firstName = $state['first_name'] ?? null;
118
+                                $lastName = $state['last_name'] ?? null;
119
+
120
+                                if ($firstName && $lastName) {
121
+                                    return "{$firstName} {$lastName}";
122
+                                }
123
+
124
+                                if ($firstName) {
125
+                                    return $firstName;
126
+                                }
127
+
128
+                                return 'Secondary Contact';
129
+                            })
130
+                            ->addActionLabel('Add Contact')
131
+                            ->schema([
132
+                                Forms\Components\TextInput::make('first_name')
133
+                                    ->label('First Name')
134
+                                    ->required()
135
+                                    ->live(onBlur: true)
136
+                                    ->maxLength(255),
137
+                                Forms\Components\TextInput::make('last_name')
138
+                                    ->label('Last Name')
139
+                                    ->required()
140
+                                    ->live(onBlur: true)
141
+                                    ->maxLength(255),
142
+                                Forms\Components\TextInput::make('email')
143
+                                    ->label('Email')
144
+                                    ->required()
145
+                                    ->email()
146
+                                    ->maxLength(255),
147
+                                PhoneBuilder::make('phones')
148
+                                    ->hiddenLabel()
149
+                                    ->blockLabels(false)
150
+                                    ->default([
151
+                                        ['type' => 'primary'],
152
+                                    ])
153
+                                    ->blocks([
154
+                                        Forms\Components\Builder\Block::make('primary')
155
+                                            ->schema([
156
+                                                Forms\Components\TextInput::make('number')
157
+                                                    ->label('Phone')
158
+                                                    ->required()
159
+                                                    ->maxLength(255),
160
+                                            ])->maxItems(1),
161
+                                    ])
162
+                                    ->addable(false)
163
+                                    ->deletable(false)
164
+                                    ->reorderable(false)
165
+                                    ->blockNumbers(false),
166
+                            ]),
167
+                    ])->columns(1),
168
+                Forms\Components\Section::make('Billing')
169
+                    ->schema([
170
+                        CreateCurrencySelect::make('currency_code')
171
+                            ->relationship('currency', 'name'),
172
+                        CustomSection::make('Billing Address')
173
+                            ->relationship('billingAddress')
174
+                            ->contained(false)
175
+                            ->schema([
176
+                                Forms\Components\Hidden::make('type')
177
+                                    ->default('billing'),
178
+                                Forms\Components\TextInput::make('address_line_1')
179
+                                    ->label('Address Line 1')
180
+                                    ->required()
181
+                                    ->maxLength(255),
182
+                                Forms\Components\TextInput::make('address_line_2')
183
+                                    ->label('Address Line 2')
184
+                                    ->maxLength(255),
185
+                                Forms\Components\TextInput::make('city')
186
+                                    ->label('City')
187
+                                    ->required()
188
+                                    ->maxLength(255),
189
+                                Forms\Components\TextInput::make('state')
190
+                                    ->label('State')
191
+                                    ->required()
192
+                                    ->maxLength(255),
193
+                                Forms\Components\TextInput::make('postal_code')
194
+                                    ->label('Postal Code / Zip Code')
195
+                                    ->required()
196
+                                    ->maxLength(255),
197
+                                Forms\Components\TextInput::make('country')
198
+                                    ->label('Country')
199
+                                    ->required()
200
+                                    ->maxLength(255),
201
+                            ])->columns(),
202
+                    ])
203
+                    ->columns(1),
204
+                Forms\Components\Section::make('Shipping')
205
+                    ->relationship('shippingAddress')
206
+                    ->schema([
207
+                        Forms\Components\TextInput::make('recipient')
208
+                            ->label('Recipient')
209
+                            ->required()
210
+                            ->maxLength(255),
211
+                        Forms\Components\Hidden::make('type')
212
+                            ->default('shipping'),
213
+                        Forms\Components\TextInput::make('phone')
214
+                            ->label('Phone')
215
+                            ->required()
216
+                            ->maxLength(255),
217
+                        CustomSection::make('Shipping Address')
218
+                            ->contained(false)
219
+                            ->schema([
220
+                                Forms\Components\TextInput::make('address_line_1')
221
+                                    ->label('Address Line 1')
222
+                                    ->required()
223
+                                    ->maxLength(255),
224
+                                Forms\Components\TextInput::make('address_line_2')
225
+                                    ->label('Address Line 2')
226
+                                    ->maxLength(255),
227
+                                Forms\Components\TextInput::make('city')
228
+                                    ->label('City')
229
+                                    ->required()
230
+                                    ->maxLength(255),
231
+                                Forms\Components\TextInput::make('state')
232
+                                    ->label('State')
233
+                                    ->required()
234
+                                    ->maxLength(255),
235
+                                Forms\Components\TextInput::make('postal_code')
236
+                                    ->label('Postal Code / Zip Code')
237
+                                    ->required()
238
+                                    ->maxLength(255),
239
+                                Forms\Components\TextInput::make('country')
240
+                                    ->label('Country')
241
+                                    ->required()
242
+                                    ->maxLength(255),
243
+                                Forms\Components\Textarea::make('notes')
244
+                                    ->label('Delivery Instructions')
245
+                                    ->maxLength(255)
246
+                                    ->columnSpanFull(),
247
+                            ])->columns(),
248
+                    ])->columns(),
249
+            ]);
250
+    }
251
+
252
+    public static function table(Table $table): Table
253
+    {
254
+        return $table
255
+            ->columns([
256
+                Tables\Columns\TextColumn::make('name')
257
+                    ->searchable()
258
+                    ->description(fn (Client $client) => $client->primaryContact->full_name),
259
+                Tables\Columns\TextColumn::make('primaryContact.email')
260
+                    ->label('Email')
261
+                    ->searchable(),
262
+                Tables\Columns\TextColumn::make('primaryContact.phones')
263
+                    ->label('Phone')
264
+                    ->state(fn (Client $client) => $client->primaryContact->first_available_phone),
265
+            ])
266
+            ->filters([
267
+                //
268
+            ])
269
+            ->actions([
270
+                Tables\Actions\EditAction::make(),
271
+            ])
272
+            ->bulkActions([
273
+                Tables\Actions\BulkActionGroup::make([
274
+                    Tables\Actions\DeleteBulkAction::make(),
275
+                ]),
276
+            ]);
277
+    }
278
+
279
+    public static function getRelations(): array
280
+    {
281
+        return [
282
+            //
283
+        ];
284
+    }
285
+
286
+    public static function getPages(): array
287
+    {
288
+        return [
289
+            'index' => Pages\ListClients::route('/'),
290
+            'create' => Pages\CreateClient::route('/create'),
291
+            'edit' => Pages\EditClient::route('/{record}/edit'),
292
+        ];
293
+    }
294
+}

+ 20
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateClient extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = ClientResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::FiveExtraLarge;
19
+    }
20
+}

+ 28
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditClient extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = ClientResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::FiveExtraLarge;
27
+    }
28
+}

+ 25
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/ListClients.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource;
6
+use Filament\Actions;
7
+use Filament\Resources\Pages\ListRecords;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class ListClients extends ListRecords
11
+{
12
+    protected static string $resource = ClientResource::class;
13
+
14
+    protected function getHeaderActions(): array
15
+    {
16
+        return [
17
+            Actions\CreateAction::make(),
18
+        ];
19
+    }
20
+
21
+    public function getMaxContentWidth(): MaxWidth | string | null
22
+    {
23
+        return 'max-w-8xl';
24
+    }
25
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Collections\Accounting\InvoiceCollection;
6
+use App\Enums\Accounting\InvoiceStatus;
7
+use App\Enums\Accounting\PaymentMethod;
8
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
9
+use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
10
+use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
11
+use App\Filament\Tables\Actions\ReplicateBulkAction;
12
+use App\Filament\Tables\Filters\DateRangeFilter;
13
+use App\Models\Accounting\Adjustment;
14
+use App\Models\Accounting\DocumentLineItem;
15
+use App\Models\Accounting\Invoice;
16
+use App\Models\Banking\BankAccount;
17
+use App\Models\Common\Offering;
18
+use App\Utilities\Currency\CurrencyConverter;
19
+use Awcodes\TableRepeater\Components\TableRepeater;
20
+use Awcodes\TableRepeater\Header;
21
+use Closure;
22
+use Filament\Forms;
23
+use Filament\Forms\Components\FileUpload;
24
+use Filament\Forms\Form;
25
+use Filament\Notifications\Notification;
26
+use Filament\Resources\Resource;
27
+use Filament\Support\Enums\Alignment;
28
+use Filament\Support\Enums\MaxWidth;
29
+use Filament\Tables;
30
+use Filament\Tables\Table;
31
+use Illuminate\Database\Eloquent\Builder;
32
+use Illuminate\Database\Eloquent\Collection;
33
+use Illuminate\Database\Eloquent\Model;
34
+use Illuminate\Support\Facades\Auth;
35
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
36
+
37
+class InvoiceResource extends Resource
38
+{
39
+    protected static ?string $model = Invoice::class;
40
+
41
+    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
42
+
43
+    public static function form(Form $form): Form
44
+    {
45
+        $company = Auth::user()->currentCompany;
46
+
47
+        return $form
48
+            ->schema([
49
+                Forms\Components\Section::make('Invoice Header')
50
+                    ->collapsible()
51
+                    ->schema([
52
+                        Forms\Components\Split::make([
53
+                            Forms\Components\Group::make([
54
+                                FileUpload::make('logo')
55
+                                    ->openable()
56
+                                    ->maxSize(1024)
57
+                                    ->localizeLabel()
58
+                                    ->visibility('public')
59
+                                    ->disk('public')
60
+                                    ->directory('logos/document')
61
+                                    ->imageResizeMode('contain')
62
+                                    ->imageCropAspectRatio('3:2')
63
+                                    ->panelAspectRatio('3:2')
64
+                                    ->maxWidth(MaxWidth::ExtraSmall)
65
+                                    ->panelLayout('integrated')
66
+                                    ->removeUploadedFileButtonPosition('center bottom')
67
+                                    ->uploadButtonPosition('center bottom')
68
+                                    ->uploadProgressIndicatorPosition('center bottom')
69
+                                    ->getUploadedFileNameForStorageUsing(
70
+                                        static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
71
+                                            ->prepend(Auth::user()->currentCompany->id . '_'),
72
+                                    )
73
+                                    ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
74
+                            ]),
75
+                            Forms\Components\Group::make([
76
+                                Forms\Components\TextInput::make('header')
77
+                                    ->default(fn () => $company->defaultInvoice->header),
78
+                                Forms\Components\TextInput::make('subheader')
79
+                                    ->default(fn () => $company->defaultInvoice->subheader),
80
+                                Forms\Components\View::make('filament.forms.components.company-info')
81
+                                    ->viewData([
82
+                                        'company_name' => $company->name,
83
+                                        'company_address' => $company->profile->address,
84
+                                        'company_city' => $company->profile->city?->name,
85
+                                        'company_state' => $company->profile->state?->name,
86
+                                        'company_zip' => $company->profile->zip_code,
87
+                                        'company_country' => $company->profile->state?->country->name,
88
+                                    ]),
89
+                            ])->grow(true),
90
+                        ])->from('md'),
91
+                    ]),
92
+                Forms\Components\Section::make('Invoice Details')
93
+                    ->schema([
94
+                        Forms\Components\Split::make([
95
+                            Forms\Components\Group::make([
96
+                                Forms\Components\Select::make('client_id')
97
+                                    ->relationship('client', 'name')
98
+                                    ->preload()
99
+                                    ->searchable()
100
+                                    ->required(),
101
+                            ]),
102
+                            Forms\Components\Group::make([
103
+                                Forms\Components\TextInput::make('invoice_number')
104
+                                    ->label('Invoice Number')
105
+                                    ->default(fn () => Invoice::getNextDocumentNumber()),
106
+                                Forms\Components\TextInput::make('order_number')
107
+                                    ->label('P.O/S.O Number'),
108
+                                Forms\Components\DatePicker::make('date')
109
+                                    ->label('Invoice Date')
110
+                                    ->live()
111
+                                    ->default(now())
112
+                                    ->disabled(function (?Invoice $record) {
113
+                                        return $record?->hasPayments();
114
+                                    })
115
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
116
+                                        $date = $state;
117
+                                        $dueDate = $get('due_date');
118
+
119
+                                        if ($date && $dueDate && $date > $dueDate) {
120
+                                            $set('due_date', $date);
121
+                                        }
122
+                                    }),
123
+                                Forms\Components\DatePicker::make('due_date')
124
+                                    ->label('Payment Due')
125
+                                    ->default(function () use ($company) {
126
+                                        return now()->addDays($company->defaultInvoice->payment_terms->getDays());
127
+                                    })
128
+                                    ->minDate(static function (Forms\Get $get) {
129
+                                        return $get('date') ?? now();
130
+                                    }),
131
+                            ])->grow(true),
132
+                        ])->from('md'),
133
+                        TableRepeater::make('lineItems')
134
+                            ->relationship()
135
+                            ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
136
+                                if (! is_array($state)) {
137
+                                    $state = [];
138
+                                }
139
+
140
+                                $relationship = $component->getRelationship();
141
+
142
+                                $existingRecords = $component->getCachedExistingRecords();
143
+
144
+                                $recordsToDelete = [];
145
+
146
+                                foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
147
+                                    if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
148
+                                        continue;
149
+                                    }
150
+
151
+                                    $recordsToDelete[] = $keyToCheckForDeletion;
152
+                                    $existingRecords->forget("record-{$keyToCheckForDeletion}");
153
+                                }
154
+
155
+                                $relationship
156
+                                    ->whereKey($recordsToDelete)
157
+                                    ->get()
158
+                                    ->each(static fn (Model $record) => $record->delete());
159
+
160
+                                $childComponentContainers = $component->getChildComponentContainers(
161
+                                    withHidden: $component->shouldSaveRelationshipsWhenHidden(),
162
+                                );
163
+
164
+                                $itemOrder = 1;
165
+                                $orderColumn = $component->getOrderColumn();
166
+
167
+                                $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
168
+
169
+                                foreach ($childComponentContainers as $itemKey => $item) {
170
+                                    $itemData = $item->getState(shouldCallHooksBefore: false);
171
+
172
+                                    if ($orderColumn) {
173
+                                        $itemData[$orderColumn] = $itemOrder;
174
+
175
+                                        $itemOrder++;
176
+                                    }
177
+
178
+                                    if ($record = ($existingRecords[$itemKey] ?? null)) {
179
+                                        $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
180
+
181
+                                        if ($itemData === null) {
182
+                                            continue;
183
+                                        }
184
+
185
+                                        $translatableContentDriver ?
186
+                                            $translatableContentDriver->updateRecord($record, $itemData) :
187
+                                            $record->fill($itemData)->save();
188
+
189
+                                        continue;
190
+                                    }
191
+
192
+                                    $relatedModel = $component->getRelatedModel();
193
+
194
+                                    $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
195
+
196
+                                    if ($itemData === null) {
197
+                                        continue;
198
+                                    }
199
+
200
+                                    if ($translatableContentDriver) {
201
+                                        $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
202
+                                    } else {
203
+                                        $record = new $relatedModel;
204
+                                        $record->fill($itemData);
205
+                                    }
206
+
207
+                                    $record = $relationship->save($record);
208
+                                    $item->model($record)->saveRelationships();
209
+                                    $existingRecords->push($record);
210
+                                }
211
+
212
+                                $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
213
+
214
+                                /** @var Invoice $invoice */
215
+                                $invoice = $component->getRecord();
216
+
217
+                                // Recalculate totals for line items
218
+                                $invoice->lineItems()->each(function (DocumentLineItem $lineItem) {
219
+                                    $lineItem->updateQuietly([
220
+                                        'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
221
+                                        'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
222
+                                    ]);
223
+                                });
224
+
225
+                                $subtotal = $invoice->lineItems()->sum('subtotal') / 100;
226
+                                $taxTotal = $invoice->lineItems()->sum('tax_total') / 100;
227
+                                $discountTotal = $invoice->lineItems()->sum('discount_total') / 100;
228
+                                $grandTotal = $subtotal + $taxTotal - $discountTotal;
229
+
230
+                                $invoice->updateQuietly([
231
+                                    'subtotal' => $subtotal,
232
+                                    'tax_total' => $taxTotal,
233
+                                    'discount_total' => $discountTotal,
234
+                                    'total' => $grandTotal,
235
+                                ]);
236
+
237
+                                if ($invoice->approved_at && $invoice->approvalTransaction) {
238
+                                    $invoice->updateApprovalTransaction();
239
+                                }
240
+                            })
241
+                            ->headers([
242
+                                Header::make('Items')->width('15%'),
243
+                                Header::make('Description')->width('25%'),
244
+                                Header::make('Quantity')->width('10%'),
245
+                                Header::make('Price')->width('10%'),
246
+                                Header::make('Taxes')->width('15%'),
247
+                                Header::make('Discounts')->width('15%'),
248
+                                Header::make('Amount')->width('10%')->align('right'),
249
+                            ])
250
+                            ->schema([
251
+                                Forms\Components\Select::make('offering_id')
252
+                                    ->relationship('sellableOffering', 'name')
253
+                                    ->preload()
254
+                                    ->searchable()
255
+                                    ->required()
256
+                                    ->live()
257
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
258
+                                        $offeringId = $state;
259
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
260
+
261
+                                        if ($offeringRecord) {
262
+                                            $set('description', $offeringRecord->description);
263
+                                            $set('unit_price', $offeringRecord->price);
264
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
265
+                                            $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
266
+                                        }
267
+                                    }),
268
+                                Forms\Components\TextInput::make('description'),
269
+                                Forms\Components\TextInput::make('quantity')
270
+                                    ->required()
271
+                                    ->numeric()
272
+                                    ->live()
273
+                                    ->default(1),
274
+                                Forms\Components\TextInput::make('unit_price')
275
+                                    ->hiddenLabel()
276
+                                    ->numeric()
277
+                                    ->live()
278
+                                    ->default(0),
279
+                                Forms\Components\Select::make('salesTaxes')
280
+                                    ->relationship('salesTaxes', 'name')
281
+                                    ->preload()
282
+                                    ->multiple()
283
+                                    ->live()
284
+                                    ->searchable(),
285
+                                Forms\Components\Select::make('salesDiscounts')
286
+                                    ->relationship('salesDiscounts', 'name')
287
+                                    ->preload()
288
+                                    ->multiple()
289
+                                    ->live()
290
+                                    ->searchable(),
291
+                                Forms\Components\Placeholder::make('total')
292
+                                    ->hiddenLabel()
293
+                                    ->content(function (Forms\Get $get) {
294
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
295
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
296
+                                        $salesTaxes = $get('salesTaxes') ?? [];
297
+                                        $salesDiscounts = $get('salesDiscounts') ?? [];
298
+
299
+                                        $subtotal = $quantity * $unitPrice;
300
+
301
+                                        // Calculate tax amount based on subtotal
302
+                                        $taxAmount = 0;
303
+                                        if (! empty($salesTaxes)) {
304
+                                            $taxRates = Adjustment::whereIn('id', $salesTaxes)->pluck('rate');
305
+                                            $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
306
+                                        }
307
+
308
+                                        // Calculate discount amount based on subtotal
309
+                                        $discountAmount = 0;
310
+                                        if (! empty($salesDiscounts)) {
311
+                                            $discountRates = Adjustment::whereIn('id', $salesDiscounts)->pluck('rate');
312
+                                            $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
313
+                                        }
314
+
315
+                                        // Final total
316
+                                        $total = $subtotal + ($taxAmount - $discountAmount);
317
+
318
+                                        return CurrencyConverter::formatToMoney($total);
319
+                                    }),
320
+                            ]),
321
+                        Forms\Components\Grid::make(6)
322
+                            ->schema([
323
+                                Forms\Components\ViewField::make('totals')
324
+                                    ->columnStart(5)
325
+                                    ->columnSpan(2)
326
+                                    ->view('filament.forms.components.invoice-totals'),
327
+                            ]),
328
+                        Forms\Components\Textarea::make('terms')
329
+                            ->columnSpanFull(),
330
+                    ]),
331
+                Forms\Components\Section::make('Invoice Footer')
332
+                    ->collapsible()
333
+                    ->schema([
334
+                        Forms\Components\Textarea::make('footer')
335
+                            ->columnSpanFull(),
336
+                    ]),
337
+            ]);
338
+    }
339
+
340
+    public static function table(Table $table): Table
341
+    {
342
+        return $table
343
+            ->defaultSort('due_date')
344
+            ->columns([
345
+                Tables\Columns\TextColumn::make('status')
346
+                    ->badge()
347
+                    ->searchable(),
348
+                Tables\Columns\TextColumn::make('due_date')
349
+                    ->label('Due')
350
+                    ->asRelativeDay()
351
+                    ->sortable(),
352
+                Tables\Columns\TextColumn::make('date')
353
+                    ->date()
354
+                    ->sortable(),
355
+                Tables\Columns\TextColumn::make('invoice_number')
356
+                    ->label('Number')
357
+                    ->searchable()
358
+                    ->sortable(),
359
+                Tables\Columns\TextColumn::make('client.name')
360
+                    ->sortable()
361
+                    ->searchable(),
362
+                Tables\Columns\TextColumn::make('total')
363
+                    ->currency()
364
+                    ->sortable(),
365
+                Tables\Columns\TextColumn::make('amount_paid')
366
+                    ->label('Amount Paid')
367
+                    ->currency()
368
+                    ->sortable(),
369
+                Tables\Columns\TextColumn::make('amount_due')
370
+                    ->label('Amount Due')
371
+                    ->currency()
372
+                    ->sortable(),
373
+            ])
374
+            ->filters([
375
+                Tables\Filters\SelectFilter::make('client')
376
+                    ->relationship('client', 'name')
377
+                    ->searchable()
378
+                    ->preload(),
379
+                Tables\Filters\SelectFilter::make('status')
380
+                    ->options(InvoiceStatus::class)
381
+                    ->native(false),
382
+                Tables\Filters\TernaryFilter::make('has_payments')
383
+                    ->label('Has Payments')
384
+                    ->queries(
385
+                        true: fn (Builder $query) => $query->whereHas('payments'),
386
+                        false: fn (Builder $query) => $query->whereDoesntHave('payments'),
387
+                    ),
388
+                DateRangeFilter::make('date')
389
+                    ->fromLabel('From Date')
390
+                    ->untilLabel('To Date')
391
+                    ->indicatorLabel('Date'),
392
+                DateRangeFilter::make('due_date')
393
+                    ->fromLabel('From Due Date')
394
+                    ->untilLabel('To Due Date')
395
+                    ->indicatorLabel('Due'),
396
+            ])
397
+            ->actions([
398
+                Tables\Actions\ActionGroup::make([
399
+                    Tables\Actions\EditAction::make(),
400
+                    Tables\Actions\ViewAction::make(),
401
+                    Tables\Actions\DeleteAction::make(),
402
+                    Invoice::getReplicateAction(Tables\Actions\ReplicateAction::class),
403
+                    Invoice::getApproveDraftAction(Tables\Actions\Action::class),
404
+                    Invoice::getMarkAsSentAction(Tables\Actions\Action::class),
405
+                    Tables\Actions\Action::make('recordPayment')
406
+                        ->label(fn (Invoice $record) => $record->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
407
+                        ->stickyModalHeader()
408
+                        ->stickyModalFooter()
409
+                        ->modalFooterActionsAlignment(Alignment::End)
410
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
411
+                        ->icon('heroicon-o-credit-card')
412
+                        ->visible(function (Invoice $record) {
413
+                            return $record->canRecordPayment();
414
+                        })
415
+                        ->mountUsing(function (Invoice $record, Form $form) {
416
+                            $form->fill([
417
+                                'posted_at' => now(),
418
+                                'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
419
+                            ]);
420
+                        })
421
+                        ->databaseTransaction()
422
+                        ->successNotificationTitle('Payment Recorded')
423
+                        ->form([
424
+                            Forms\Components\DatePicker::make('posted_at')
425
+                                ->label('Date'),
426
+                            Forms\Components\TextInput::make('amount')
427
+                                ->label('Amount')
428
+                                ->required()
429
+                                ->money()
430
+                                ->live(onBlur: true)
431
+                                ->helperText(function (Invoice $record, $state) {
432
+                                    if (! CurrencyConverter::isValidAmount($state)) {
433
+                                        return null;
434
+                                    }
435
+
436
+                                    $amountDue = $record->getRawOriginal('amount_due');
437
+
438
+                                    $amount = CurrencyConverter::convertToCents($state);
439
+
440
+                                    if ($amount <= 0) {
441
+                                        return 'Please enter a valid positive amount';
442
+                                    }
443
+
444
+                                    if ($record->status === InvoiceStatus::Overpaid) {
445
+                                        $newAmountDue = $amountDue + $amount;
446
+                                    } else {
447
+                                        $newAmountDue = $amountDue - $amount;
448
+                                    }
449
+
450
+                                    return match (true) {
451
+                                        $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
452
+                                        $newAmountDue === 0 => 'Invoice will be fully paid',
453
+                                        default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
454
+                                    };
455
+                                })
456
+                                ->rules([
457
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
458
+                                        if (! CurrencyConverter::isValidAmount($value)) {
459
+                                            $fail('Please enter a valid amount');
460
+                                        }
461
+                                    },
462
+                                ]),
463
+                            Forms\Components\Select::make('payment_method')
464
+                                ->label('Payment Method')
465
+                                ->required()
466
+                                ->options(PaymentMethod::class),
467
+                            Forms\Components\Select::make('bank_account_id')
468
+                                ->label('Account')
469
+                                ->required()
470
+                                ->options(BankAccount::query()
471
+                                    ->get()
472
+                                    ->pluck('account.name', 'id'))
473
+                                ->searchable(),
474
+                            Forms\Components\Textarea::make('notes')
475
+                                ->label('Notes'),
476
+                        ])
477
+                        ->action(function (Invoice $record, Tables\Actions\Action $action, array $data) {
478
+                            $record->recordPayment($data);
479
+
480
+                            $action->success();
481
+                        }),
482
+                ]),
483
+            ])
484
+            ->bulkActions([
485
+                Tables\Actions\BulkActionGroup::make([
486
+                    Tables\Actions\DeleteBulkAction::make(),
487
+                    ReplicateBulkAction::make()
488
+                        ->label('Replicate')
489
+                        ->modalWidth(MaxWidth::Large)
490
+                        ->modalDescription('Replicating invoices will also replicate their line items. Are you sure you want to proceed?')
491
+                        ->successNotificationTitle('Invoices Replicated Successfully')
492
+                        ->failureNotificationTitle('Failed to Replicate Invoices')
493
+                        ->databaseTransaction()
494
+                        ->deselectRecordsAfterCompletion()
495
+                        ->excludeAttributes([
496
+                            'status',
497
+                            'amount_paid',
498
+                            'amount_due',
499
+                            'created_by',
500
+                            'updated_by',
501
+                            'created_at',
502
+                            'updated_at',
503
+                            'invoice_number',
504
+                            'date',
505
+                            'due_date',
506
+                        ])
507
+                        ->beforeReplicaSaved(function (Invoice $replica) {
508
+                            $replica->status = InvoiceStatus::Draft;
509
+                            $replica->invoice_number = Invoice::getNextDocumentNumber();
510
+                            $replica->date = now();
511
+                            $replica->due_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
512
+                        })
513
+                        ->withReplicatedRelationships(['lineItems'])
514
+                        ->withExcludedRelationshipAttributes('lineItems', [
515
+                            'subtotal',
516
+                            'total',
517
+                            'created_by',
518
+                            'updated_by',
519
+                            'created_at',
520
+                            'updated_at',
521
+                        ]),
522
+                    Tables\Actions\BulkAction::make('approveDrafts')
523
+                        ->label('Approve')
524
+                        ->icon('heroicon-o-check-circle')
525
+                        ->databaseTransaction()
526
+                        ->successNotificationTitle('Invoices Approved')
527
+                        ->failureNotificationTitle('Failed to Approve Invoices')
528
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
529
+                            $containsNonDrafts = $records->contains(fn (Invoice $record) => ! $record->isDraft());
530
+
531
+                            if ($containsNonDrafts) {
532
+                                Notification::make()
533
+                                    ->title('Approval Failed')
534
+                                    ->body('Only draft invoices can be approved. Please adjust your selection and try again.')
535
+                                    ->persistent()
536
+                                    ->danger()
537
+                                    ->send();
538
+
539
+                                $action->cancel(true);
540
+                            }
541
+                        })
542
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
543
+                            $records->each(function (Invoice $record) {
544
+                                $record->approveDraft();
545
+                            });
546
+
547
+                            $action->success();
548
+                        }),
549
+                    Tables\Actions\BulkAction::make('markAsSent')
550
+                        ->label('Mark as Sent')
551
+                        ->icon('heroicon-o-paper-airplane')
552
+                        ->databaseTransaction()
553
+                        ->successNotificationTitle('Invoices Sent')
554
+                        ->failureNotificationTitle('Failed to Mark Invoices as Sent')
555
+                        ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
556
+                            $doesntContainUnsent = $records->contains(fn (Invoice $record) => $record->status !== InvoiceStatus::Unsent);
557
+
558
+                            if ($doesntContainUnsent) {
559
+                                Notification::make()
560
+                                    ->title('Sending Failed')
561
+                                    ->body('Only unsent invoices can be marked as sent. Please adjust your selection and try again.')
562
+                                    ->persistent()
563
+                                    ->danger()
564
+                                    ->send();
565
+
566
+                                $action->cancel(true);
567
+                            }
568
+                        })
569
+                        ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
570
+                            $records->each(function (Invoice $record) {
571
+                                $record->updateQuietly([
572
+                                    'status' => InvoiceStatus::Sent,
573
+                                ]);
574
+                            });
575
+
576
+                            $action->success();
577
+                        }),
578
+                    Tables\Actions\BulkAction::make('recordPayments')
579
+                        ->label('Record Payments')
580
+                        ->icon('heroicon-o-credit-card')
581
+                        ->stickyModalHeader()
582
+                        ->stickyModalFooter()
583
+                        ->modalFooterActionsAlignment(Alignment::End)
584
+                        ->modalWidth(MaxWidth::TwoExtraLarge)
585
+                        ->databaseTransaction()
586
+                        ->successNotificationTitle('Payments Recorded')
587
+                        ->failureNotificationTitle('Failed to Record Payments')
588
+                        ->deselectRecordsAfterCompletion()
589
+                        ->beforeFormFilled(function (Collection $records, Tables\Actions\BulkAction $action) {
590
+                            $cantRecordPayments = $records->contains(fn (Invoice $record) => ! $record->canBulkRecordPayment());
591
+
592
+                            if ($cantRecordPayments) {
593
+                                Notification::make()
594
+                                    ->title('Payment Recording Failed')
595
+                                    ->body('Invoices that are either draft, paid, overpaid, or voided cannot be processed through bulk payments. Please adjust your selection and try again.')
596
+                                    ->persistent()
597
+                                    ->danger()
598
+                                    ->send();
599
+
600
+                                $action->cancel(true);
601
+                            }
602
+                        })
603
+                        ->mountUsing(function (InvoiceCollection $records, Form $form) {
604
+                            $totalAmountDue = $records->sumMoneyFormattedSimple('amount_due');
605
+
606
+                            $form->fill([
607
+                                'posted_at' => now(),
608
+                                'amount' => $totalAmountDue,
609
+                            ]);
610
+                        })
611
+                        ->form([
612
+                            Forms\Components\DatePicker::make('posted_at')
613
+                                ->label('Date'),
614
+                            Forms\Components\TextInput::make('amount')
615
+                                ->label('Amount')
616
+                                ->required()
617
+                                ->money()
618
+                                ->rules([
619
+                                    static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
620
+                                        if (! CurrencyConverter::isValidAmount($value)) {
621
+                                            $fail('Please enter a valid amount');
622
+                                        }
623
+                                    },
624
+                                ]),
625
+                            Forms\Components\Select::make('payment_method')
626
+                                ->label('Payment Method')
627
+                                ->required()
628
+                                ->options(PaymentMethod::class),
629
+                            Forms\Components\Select::make('bank_account_id')
630
+                                ->label('Account')
631
+                                ->required()
632
+                                ->options(BankAccount::query()
633
+                                    ->get()
634
+                                    ->pluck('account.name', 'id'))
635
+                                ->searchable(),
636
+                            Forms\Components\Textarea::make('notes')
637
+                                ->label('Notes'),
638
+                        ])
639
+                        ->before(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
640
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
641
+                            $totalAmountDue = $records->sumMoneyInCents('amount_due');
642
+
643
+                            if ($totalPaymentAmount > $totalAmountDue) {
644
+                                $formattedTotalAmountDue = CurrencyConverter::formatCentsToMoney($totalAmountDue);
645
+
646
+                                Notification::make()
647
+                                    ->title('Excess Payment Amount')
648
+                                    ->body("The payment amount exceeds the total amount due of {$formattedTotalAmountDue}. Please adjust the payment amount and try again.")
649
+                                    ->persistent()
650
+                                    ->warning()
651
+                                    ->send();
652
+
653
+                                $action->halt(true);
654
+                            }
655
+                        })
656
+                        ->action(function (InvoiceCollection $records, Tables\Actions\BulkAction $action, array $data) {
657
+                            $totalPaymentAmount = CurrencyConverter::convertToCents($data['amount']);
658
+
659
+                            $remainingAmount = $totalPaymentAmount;
660
+
661
+                            $records->each(function (Invoice $record) use (&$remainingAmount, $data) {
662
+                                $amountDue = $record->getRawOriginal('amount_due');
663
+
664
+                                if ($amountDue <= 0 || $remainingAmount <= 0) {
665
+                                    return;
666
+                                }
667
+
668
+                                $paymentAmount = min($amountDue, $remainingAmount);
669
+
670
+                                $data['amount'] = CurrencyConverter::convertCentsToFormatSimple($paymentAmount);
671
+
672
+                                $record->recordPayment($data);
673
+
674
+                                $remainingAmount -= $paymentAmount;
675
+                            });
676
+
677
+                            $action->success();
678
+                        }),
679
+                ]),
680
+            ]);
681
+    }
682
+
683
+    public static function getRelations(): array
684
+    {
685
+        return [
686
+            RelationManagers\PaymentsRelationManager::class,
687
+        ];
688
+    }
689
+
690
+    public static function getPages(): array
691
+    {
692
+        return [
693
+            'index' => Pages\ListInvoices::route('/'),
694
+            'create' => Pages\CreateInvoice::route('/create'),
695
+            'view' => Pages\ViewInvoice::route('/{record}'),
696
+            'edit' => Pages\EditInvoice::route('/{record}/edit'),
697
+        ];
698
+    }
699
+
700
+    public static function getWidgets(): array
701
+    {
702
+        return [
703
+            Widgets\InvoiceOverview::class,
704
+        ];
705
+    }
706
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use Filament\Resources\Pages\CreateRecord;
8
+use Filament\Support\Enums\MaxWidth;
9
+
10
+class CreateInvoice extends CreateRecord
11
+{
12
+    use RedirectToListPage;
13
+
14
+    protected static string $resource = InvoiceResource::class;
15
+
16
+    public function getMaxContentWidth(): MaxWidth | string | null
17
+    {
18
+        return MaxWidth::Full;
19
+    }
20
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4
+
5
+use App\Concerns\RedirectToListPage;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource;
7
+use Filament\Actions;
8
+use Filament\Resources\Pages\EditRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+
11
+class EditInvoice extends EditRecord
12
+{
13
+    use RedirectToListPage;
14
+
15
+    protected static string $resource = InvoiceResource::class;
16
+
17
+    protected function getHeaderActions(): array
18
+    {
19
+        return [
20
+            Actions\DeleteAction::make(),
21
+        ];
22
+    }
23
+
24
+    public function getMaxContentWidth(): MaxWidth | string | null
25
+    {
26
+        return MaxWidth::Full;
27
+    }
28
+}

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

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

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

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

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Enums\Accounting\PaymentMethod;
7
+use App\Enums\Accounting\TransactionType;
8
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ViewInvoice;
9
+use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\Transaction;
11
+use App\Models\Banking\BankAccount;
12
+use App\Utilities\Currency\CurrencyAccessor;
13
+use App\Utilities\Currency\CurrencyConverter;
14
+use Closure;
15
+use Filament\Forms;
16
+use Filament\Forms\Form;
17
+use Filament\Resources\RelationManagers\RelationManager;
18
+use Filament\Support\Colors\Color;
19
+use Filament\Support\Enums\FontWeight;
20
+use Filament\Support\Enums\MaxWidth;
21
+use Filament\Tables;
22
+use Filament\Tables\Table;
23
+use Illuminate\Database\Eloquent\Model;
24
+
25
+class PaymentsRelationManager extends RelationManager
26
+{
27
+    protected static string $relationship = 'payments';
28
+
29
+    protected static ?string $modelLabel = 'Payment';
30
+
31
+    protected $listeners = [
32
+        'refresh' => '$refresh',
33
+    ];
34
+
35
+    public function isReadOnly(): bool
36
+    {
37
+        return false;
38
+    }
39
+
40
+    public static function canViewForRecord(Model $ownerRecord, string $pageClass): bool
41
+    {
42
+        return $ownerRecord->status !== InvoiceStatus::Draft && $pageClass === ViewInvoice::class;
43
+    }
44
+
45
+    public function form(Form $form): Form
46
+    {
47
+        return $form
48
+            ->columns(1)
49
+            ->schema([
50
+                Forms\Components\DatePicker::make('posted_at')
51
+                    ->label('Date'),
52
+                Forms\Components\TextInput::make('amount')
53
+                    ->label('Amount')
54
+                    ->required()
55
+                    ->money()
56
+                    ->live(onBlur: true)
57
+                    ->helperText(function (RelationManager $livewire, $state, ?Transaction $record) {
58
+                        if (! CurrencyConverter::isValidAmount($state)) {
59
+                            return null;
60
+                        }
61
+
62
+                        /** @var Invoice $ownerRecord */
63
+                        $ownerRecord = $livewire->getOwnerRecord();
64
+
65
+                        $amountDue = $ownerRecord->getRawOriginal('amount_due');
66
+
67
+                        $amount = CurrencyConverter::convertToCents($state);
68
+
69
+                        if ($amount <= 0) {
70
+                            return 'Please enter a valid positive amount';
71
+                        }
72
+
73
+                        $currentPaymentAmount = $record?->getRawOriginal('amount') ?? 0;
74
+
75
+                        if ($ownerRecord->status === InvoiceStatus::Overpaid) {
76
+                            $newAmountDue = $amountDue + $amount - $currentPaymentAmount;
77
+                        } else {
78
+                            $newAmountDue = $amountDue - $amount + $currentPaymentAmount;
79
+                        }
80
+
81
+                        return match (true) {
82
+                            $newAmountDue > 0 => 'Amount due after payment will be ' . CurrencyConverter::formatCentsToMoney($newAmountDue),
83
+                            $newAmountDue === 0 => 'Invoice will be fully paid',
84
+                            default => 'Invoice will be overpaid by ' . CurrencyConverter::formatCentsToMoney(abs($newAmountDue)),
85
+                        };
86
+                    })
87
+                    ->rules([
88
+                        static fn (): Closure => static function (string $attribute, $value, Closure $fail) {
89
+                            if (! CurrencyConverter::isValidAmount($value)) {
90
+                                $fail('Please enter a valid amount');
91
+                            }
92
+                        },
93
+                    ]),
94
+                Forms\Components\Select::make('payment_method')
95
+                    ->label('Payment Method')
96
+                    ->required()
97
+                    ->options(PaymentMethod::class),
98
+                Forms\Components\Select::make('bank_account_id')
99
+                    ->label('Account')
100
+                    ->required()
101
+                    ->options(BankAccount::query()
102
+                        ->get()
103
+                        ->pluck('account.name', 'id'))
104
+                    ->searchable(),
105
+                Forms\Components\Textarea::make('notes')
106
+                    ->label('Notes'),
107
+            ]);
108
+    }
109
+
110
+    public function table(Table $table): Table
111
+    {
112
+        return $table
113
+            ->recordTitleAttribute('description')
114
+            ->columns([
115
+                Tables\Columns\TextColumn::make('posted_at')
116
+                    ->label('Date')
117
+                    ->sortable()
118
+                    ->defaultDateFormat(),
119
+                Tables\Columns\TextColumn::make('type')
120
+                    ->label('Type')
121
+                    ->sortable()
122
+                    ->toggleable(isToggledHiddenByDefault: true),
123
+                Tables\Columns\TextColumn::make('description')
124
+                    ->label('Description')
125
+                    ->limit(30)
126
+                    ->toggleable(),
127
+                Tables\Columns\TextColumn::make('bankAccount.account.name')
128
+                    ->label('Account')
129
+                    ->toggleable(),
130
+                Tables\Columns\TextColumn::make('amount')
131
+                    ->label('Amount')
132
+                    ->weight(static fn (Transaction $transaction) => $transaction->reviewed ? null : FontWeight::SemiBold)
133
+                    ->color(
134
+                        static fn (Transaction $transaction) => match ($transaction->type) {
135
+                            TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
136
+                            TransactionType::Journal => 'primary',
137
+                            default => null,
138
+                        }
139
+                    )
140
+                    ->sortable()
141
+                    ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code ?? CurrencyAccessor::getDefaultCurrency(), true),
142
+            ])
143
+            ->filters([
144
+                //
145
+            ])
146
+            ->headerActions([
147
+                Tables\Actions\CreateAction::make()
148
+                    ->label(fn () => $this->getOwnerRecord()->status === InvoiceStatus::Overpaid ? 'Refund Overpayment' : 'Record Payment')
149
+                    ->modalHeading(fn (Tables\Actions\CreateAction $action) => $action->getLabel())
150
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
151
+                    ->visible(function () {
152
+                        return $this->getOwnerRecord()->canRecordPayment();
153
+                    })
154
+                    ->mountUsing(function (Form $form) {
155
+                        $record = $this->getOwnerRecord();
156
+                        $form->fill([
157
+                            'posted_at' => now(),
158
+                            'amount' => $record->status === InvoiceStatus::Overpaid ? ltrim($record->amount_due, '-') : $record->amount_due,
159
+                        ]);
160
+                    })
161
+                    ->databaseTransaction()
162
+                    ->successNotificationTitle('Payment Recorded')
163
+                    ->action(function (Tables\Actions\CreateAction $action, array $data) {
164
+                        /** @var Invoice $record */
165
+                        $record = $this->getOwnerRecord();
166
+
167
+                        $record->recordPayment($data);
168
+
169
+                        $action->success();
170
+
171
+                        $this->dispatch('refresh');
172
+                    }),
173
+            ])
174
+            ->actions([
175
+                Tables\Actions\EditAction::make()
176
+                    ->modalWidth(MaxWidth::TwoExtraLarge)
177
+                    ->after(fn () => $this->dispatch('refresh')),
178
+                Tables\Actions\DeleteAction::make()
179
+                    ->after(fn () => $this->dispatch('refresh')),
180
+            ])
181
+            ->bulkActions([
182
+                Tables\Actions\BulkActionGroup::make([
183
+                    Tables\Actions\DeleteBulkAction::make(),
184
+                ]),
185
+            ]);
186
+    }
187
+}

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

1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
7
+use App\Filament\Widgets\EnhancedStatsOverviewWidget;
8
+use App\Utilities\Currency\CurrencyAccessor;
9
+use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\Widgets\Concerns\InteractsWithPageTable;
11
+use Illuminate\Support\Number;
12
+
13
+class InvoiceOverview extends EnhancedStatsOverviewWidget
14
+{
15
+    use InteractsWithPageTable;
16
+
17
+    protected function getTablePage(): string
18
+    {
19
+        return ListInvoices::class;
20
+    }
21
+
22
+    protected function getStats(): array
23
+    {
24
+        $unpaidInvoices = $this->getPageTableQuery()->unpaid();
25
+
26
+        $amountUnpaid = $unpaidInvoices->sum('amount_due');
27
+
28
+        $amountOverdue = $unpaidInvoices
29
+            ->clone()
30
+            ->where('status', InvoiceStatus::Overdue)
31
+            ->sum('amount_due');
32
+
33
+        $amountDueWithin30Days = $unpaidInvoices
34
+            ->clone()
35
+            ->whereBetween('due_date', [today(), today()->addMonth()])
36
+            ->sum('amount_due');
37
+
38
+        $validInvoices = $this->getPageTableQuery()
39
+            ->whereNotIn('status', [
40
+                InvoiceStatus::Void,
41
+                InvoiceStatus::Draft,
42
+            ]);
43
+
44
+        $totalValidInvoiceAmount = $validInvoices->sum('total');
45
+
46
+        $totalValidInvoiceCount = $validInvoices->count();
47
+
48
+        $averageInvoiceTotal = $totalValidInvoiceCount > 0 ? $totalValidInvoiceAmount / $totalValidInvoiceCount : 0;
49
+
50
+        $averagePaymentTime = $this->getPageTableQuery()
51
+            ->whereNotNull('paid_at')
52
+            ->selectRaw('AVG(TIMESTAMPDIFF(DAY, date, paid_at)) as avg_days')
53
+            ->value('avg_days');
54
+
55
+        $averagePaymentTimeFormatted = Number::format($averagePaymentTime ?? 0, maxPrecision: 1);
56
+
57
+        return [
58
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Total Unpaid', CurrencyConverter::formatCentsToMoney($amountUnpaid))
59
+                ->suffix(CurrencyAccessor::getDefaultCurrency())
60
+                ->description('Includes ' . CurrencyConverter::formatCentsToMoney($amountOverdue) . ' overdue'),
61
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Due Within 30 Days', CurrencyConverter::formatCentsToMoney($amountDueWithin30Days))
62
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
63
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Payment Time', $averagePaymentTimeFormatted)
64
+                ->suffix('days'),
65
+            EnhancedStatsOverviewWidget\EnhancedStat::make('Average Invoice Total', CurrencyConverter::formatCentsToMoney($averageInvoiceTotal))
66
+                ->suffix(CurrencyAccessor::getDefaultCurrency()),
67
+        ];
68
+    }
69
+}

+ 13
- 0
app/Filament/Forms/Components/CustomSection.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Section;
6
+use Filament\Support\Concerns\CanBeContained;
7
+
8
+class CustomSection extends Section
9
+{
10
+    use CanBeContained;
11
+
12
+    protected string $view = 'filament.forms.components.custom-section';
13
+}

+ 165
- 0
app/Filament/Forms/Components/LineItemRepeater.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Awcodes\TableRepeater\Components\TableRepeater;
6
+use Closure;
7
+use Filament\Forms\ComponentContainer;
8
+use Filament\Forms\Components\Component;
9
+
10
+class LineItemRepeater extends TableRepeater
11
+{
12
+    protected array | Closure $nestedSchema = [];
13
+
14
+    protected ?string $nestedColumn = null;
15
+
16
+    /**
17
+     * Set nested schema and optionally the column it belongs to.
18
+     *
19
+     * @param  array<Component> | Closure  $components
20
+     */
21
+    public function withNestedSchema(array | Closure $components, ?string $underColumn = null): static
22
+    {
23
+        $this->nestedSchema = $components;
24
+        $this->nestedColumn = $underColumn;
25
+
26
+        return $this;
27
+    }
28
+
29
+    /**
30
+     * Get the nested schema.
31
+     *
32
+     * @return array<Component>
33
+     */
34
+    public function getNestedSchema(): array
35
+    {
36
+        return $this->evaluate($this->nestedSchema);
37
+    }
38
+
39
+    /**
40
+     * Get the column under which the nested schema should be rendered.
41
+     */
42
+    public function getNestedColumn(): ?string
43
+    {
44
+        return $this->nestedColumn;
45
+    }
46
+
47
+    /**
48
+     * Determine if there is a nested schema defined.
49
+     */
50
+    public function hasNestedSchema(): bool
51
+    {
52
+        return ! empty($this->getNestedSchema());
53
+    }
54
+
55
+    public function getNestedSchemaMap(): array
56
+    {
57
+        return collect($this->getNestedSchema())
58
+            ->keyBy(fn ($component) => $component->getKey())
59
+            ->all();
60
+    }
61
+
62
+    /**
63
+     * Get all child components, including nested schema.
64
+     *
65
+     * @return array<Component>
66
+     */
67
+    public function getChildComponents(): array
68
+    {
69
+        $components = parent::getChildComponents();
70
+
71
+        if ($this->hasNestedSchema()) {
72
+            $components = array_merge($components, $this->getNestedSchema());
73
+        }
74
+
75
+        return $components;
76
+    }
77
+
78
+    public function getChildComponentContainers(bool $withHidden = false): array
79
+    {
80
+        if ((! $withHidden) && $this->isHidden()) {
81
+            return [];
82
+        }
83
+
84
+        $relationship = $this->getRelationship();
85
+
86
+        $records = $relationship ? $this->getCachedExistingRecords() : null;
87
+
88
+        $containers = [];
89
+
90
+        foreach ($this->getState() ?? [] as $itemKey => $itemData) {
91
+            $containers[$itemKey] = $this
92
+                ->getChildComponentContainer()
93
+                ->statePath($itemKey)
94
+                ->model($relationship ? $records[$itemKey] ?? $this->getRelatedModel() : null)
95
+                ->inlineLabel(false)
96
+                ->getClone();
97
+        }
98
+
99
+        return $containers;
100
+    }
101
+
102
+    public function getChildComponentContainersWithoutNestedSchema(bool $withHidden = false): array
103
+    {
104
+        if ((! $withHidden) && $this->isHidden()) {
105
+            return [];
106
+        }
107
+
108
+        $relationship = $this->getRelationship();
109
+        $records = $relationship ? $this->getCachedExistingRecords() : null;
110
+
111
+        $containers = [];
112
+
113
+        $childComponentsWithoutNestedSchema = $this->getChildComponentsWithoutNestedSchema();
114
+
115
+        foreach ($this->getState() ?? [] as $itemKey => $itemData) {
116
+            $containers[$itemKey] = ComponentContainer::make($this->getLivewire())
117
+                ->parentComponent($this)
118
+                ->statePath($itemKey)
119
+                ->model($relationship ? $records[$itemKey] ?? $this->getRelatedModel() : null)
120
+                ->components($childComponentsWithoutNestedSchema)
121
+                ->inlineLabel(false)
122
+                ->getClone();
123
+        }
124
+
125
+        return $containers;
126
+    }
127
+
128
+    public function getChildComponentContainer($key = null): ComponentContainer
129
+    {
130
+        if (filled($key) && array_key_exists($key, $containers = $this->getChildComponentContainers())) {
131
+            return $containers[$key];
132
+        }
133
+
134
+        return ComponentContainer::make($this->getLivewire())
135
+            ->parentComponent($this)
136
+            ->components($this->getChildComponents());
137
+    }
138
+
139
+    public function getChildComponentsWithoutNestedSchema(): array
140
+    {
141
+        // Fetch the nested schema components.
142
+        $nestedSchema = $this->getNestedSchema();
143
+
144
+        // Filter out the nested schema components.
145
+        return array_filter($this->getChildComponents(), function ($component) use ($nestedSchema) {
146
+            return ! in_array($component, $nestedSchema, true);
147
+        });
148
+    }
149
+
150
+    public function getNestedComponents(): array
151
+    {
152
+        // Fetch the nested schema components.
153
+        $nestedSchema = $this->getNestedSchema();
154
+
155
+        // Separate and return only the nested schema components.
156
+        return array_filter($this->getChildComponents(), function ($component) use ($nestedSchema) {
157
+            return in_array($component, $nestedSchema, true);
158
+        });
159
+    }
160
+
161
+    public function getView(): string
162
+    {
163
+        return 'filament.forms.components.line-item-repeater';
164
+    }
165
+}

+ 10
- 0
app/Filament/Forms/Components/PhoneBuilder.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Builder;
6
+
7
+class PhoneBuilder extends Builder
8
+{
9
+    protected string $view = 'filament.forms.components.phone-builder';
10
+}

+ 31
- 5
app/Filament/Tables/Actions/ReplicateBulkAction.php 查看文件

11
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
11
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
12
 use Illuminate\Database\Eloquent\Relations\HasMany;
12
 use Illuminate\Database\Eloquent\Relations\HasMany;
13
 use Illuminate\Database\Eloquent\Relations\HasOne;
13
 use Illuminate\Database\Eloquent\Relations\HasOne;
14
+use Illuminate\Database\Eloquent\Relations\MorphMany;
14
 
15
 
15
 class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
16
 class ReplicateBulkAction extends BulkAction implements ReplicatesRecords
16
 {
17
 {
20
 
21
 
21
     protected array $relationshipsToReplicate = [];
22
     protected array $relationshipsToReplicate = [];
22
 
23
 
24
+    protected array | Closure | null $excludedAttributesPerRelationship = null;
25
+
23
     public static function getDefaultName(): ?string
26
     public static function getDefaultName(): ?string
24
     {
27
     {
25
         return 'replicate';
28
         return 'replicate';
48
                 $records->each(function (Model $record) {
51
                 $records->each(function (Model $record) {
49
                     $this->replica = $record->replicate($this->getExcludedAttributes());
52
                     $this->replica = $record->replicate($this->getExcludedAttributes());
50
 
53
 
51
-                    $this->replica->fill($record->attributesToArray());
52
-
53
                     $this->callBeforeReplicaSaved();
54
                     $this->callBeforeReplicaSaved();
54
 
55
 
55
                     $this->replica->save();
56
                     $this->replica->save();
73
         foreach ($this->relationshipsToReplicate as $relationship) {
74
         foreach ($this->relationshipsToReplicate as $relationship) {
74
             $relation = $original->$relationship();
75
             $relation = $original->$relationship();
75
 
76
 
77
+            $excludedAttributes = $this->excludedAttributesPerRelationship[$relationship] ?? [];
78
+
76
             if ($relation instanceof BelongsToMany) {
79
             if ($relation instanceof BelongsToMany) {
77
                 $replica->$relationship()->sync($relation->pluck($relation->getRelated()->getKeyName()));
80
                 $replica->$relationship()->sync($relation->pluck($relation->getRelated()->getKeyName()));
78
             } elseif ($relation instanceof HasMany) {
81
             } elseif ($relation instanceof HasMany) {
79
-                $relation->each(function (Model $related) use ($replica, $relationship) {
80
-                    $relatedReplica = $related->replicate($this->getExcludedAttributes());
82
+                $relation->each(function (Model $related) use ($excludedAttributes, $replica, $relationship) {
83
+                    $relatedReplica = $related->replicate($excludedAttributes);
81
                     $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
84
                     $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
82
                     $relatedReplica->save();
85
                     $relatedReplica->save();
83
                 });
86
                 });
87
+            } elseif ($relation instanceof MorphMany) {
88
+                $relation->each(function (Model $related) use ($excludedAttributes, $relation, $replica) {
89
+                    $relatedReplica = $related->replicate($excludedAttributes);
90
+                    $relatedReplica->{$relation->getForeignKeyName()} = $replica->getKey();
91
+                    $relatedReplica->{$relation->getMorphType()} = $replica->getMorphClass();
92
+                    $relatedReplica->save();
93
+
94
+                    if (method_exists($related, 'adjustments')) {
95
+                        $relatedReplica->adjustments()->sync($related->adjustments->pluck('id'));
96
+                    }
97
+                });
84
             } elseif ($relation instanceof HasOne && $relation->exists()) {
98
             } elseif ($relation instanceof HasOne && $relation->exists()) {
85
                 $related = $relation->first();
99
                 $related = $relation->first();
86
-                $relatedReplica = $related->replicate($this->getExcludedAttributes());
100
+                $relatedReplica = $related->replicate($excludedAttributes);
87
                 $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
101
                 $relatedReplica->{$replica->$relationship()->getForeignKeyName()} = $replica->getKey();
88
                 $relatedReplica->save();
102
                 $relatedReplica->save();
89
             }
103
             }
97
         return $this;
111
         return $this;
98
     }
112
     }
99
 
113
 
114
+    public function withExcludedRelationshipAttributes(string $relationship, array | Closure | null $attributes): static
115
+    {
116
+        $this->excludedAttributesPerRelationship[$relationship] = $attributes;
117
+
118
+        return $this;
119
+    }
120
+
121
+    public function getExcludedRelationshipAttributes(): ?array
122
+    {
123
+        return $this->evaluate($this->excludedAttributesPerRelationship);
124
+    }
125
+
100
     public function afterReplicaSaved(Closure $callback): static
126
     public function afterReplicaSaved(Closure $callback): static
101
     {
127
     {
102
         $this->afterReplicaSaved = $callback;
128
         $this->afterReplicaSaved = $callback;

+ 172
- 0
app/Filament/Tables/Filters/DateRangeFilter.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Tables\Filters;
4
+
5
+use Filament\Forms\Components\DatePicker;
6
+use Filament\Forms\Get;
7
+use Filament\Forms\Set;
8
+use Filament\Tables\Filters\Filter;
9
+use Filament\Tables\Filters\Indicator;
10
+use Illuminate\Database\Eloquent\Builder;
11
+use Illuminate\Support\Carbon;
12
+use Illuminate\Support\Str;
13
+
14
+class DateRangeFilter extends Filter
15
+{
16
+    protected string $fromLabel = 'From';
17
+
18
+    protected string $untilLabel = 'Until';
19
+
20
+    protected ?string $indicatorLabel = null;
21
+
22
+    protected ?string $defaultFromDate = null;
23
+
24
+    protected ?string $defaultUntilDate = null;
25
+
26
+    protected ?string $fromColumn = null;
27
+
28
+    protected ?string $untilColumn = null;
29
+
30
+    protected function setUp(): void
31
+    {
32
+        parent::setUp();
33
+
34
+        $this->form([
35
+            DatePicker::make('from')
36
+                ->label(fn () => $this->fromLabel)
37
+                ->live()
38
+                ->default(fn () => $this->defaultFromDate)
39
+                ->maxDate(function (Get $get) {
40
+                    return $get('until');
41
+                })
42
+                ->afterStateUpdated(function (Set $set, $state) {
43
+                    if (! $state) {
44
+                        $set('until', null);
45
+                    }
46
+                }),
47
+            DatePicker::make('until')
48
+                ->label(fn () => $this->untilLabel)
49
+                ->live()
50
+                ->default(fn () => $this->defaultUntilDate)
51
+                ->minDate(function (Get $get) {
52
+                    return $get('from');
53
+                }),
54
+        ]);
55
+
56
+        $this->query(function (Builder $query, array $data): Builder {
57
+            $fromColumn = $this->fromColumn ?? $this->getName();
58
+            $untilColumn = $this->untilColumn ?? $this->getName();
59
+
60
+            $fromDate = filled($data['from'] ?? null)
61
+                ? Carbon::parse($data['from'])
62
+                : null;
63
+
64
+            $untilDate = filled($data['until'] ?? null)
65
+                ? Carbon::parse($data['until'])
66
+                : null;
67
+
68
+            if (! $fromDate && ! $untilDate) {
69
+                return $query;
70
+            }
71
+
72
+            return $this->applyDateFilter($query, $fromDate, $untilDate, $fromColumn, $untilColumn);
73
+        });
74
+
75
+        $this->indicateUsing(function (array $data): array {
76
+            $indicators = [];
77
+
78
+            $fromDateFormatted = filled($data['from'] ?? null)
79
+                ? Carbon::parse($data['from'])->toDefaultDateFormat()
80
+                : null;
81
+
82
+            $untilDateFormatted = filled($data['until'] ?? null)
83
+                ? Carbon::parse($data['until'])->toDefaultDateFormat()
84
+                : null;
85
+
86
+            if ($fromDateFormatted && $untilDateFormatted) {
87
+                $indicators[] = Indicator::make($this->getIndicatorLabel() . ': ' . $fromDateFormatted . ' - ' . $untilDateFormatted);
88
+            } else {
89
+                if ($fromDateFormatted) {
90
+                    $indicators[] = Indicator::make($this->fromLabel . ': ' . $fromDateFormatted)
91
+                        ->removeField('from');
92
+                }
93
+
94
+                if ($untilDateFormatted) {
95
+                    $indicators[] = Indicator::make($this->untilLabel . ': ' . $untilDateFormatted)
96
+                        ->removeField('until');
97
+                }
98
+            }
99
+
100
+            return $indicators;
101
+        });
102
+    }
103
+
104
+    protected function applyDateFilter(Builder $query, ?Carbon $fromDate, ?Carbon $untilDate, string $fromColumn, string $untilColumn): Builder
105
+    {
106
+        return $query
107
+            ->when($fromDate && ! $untilDate, function (Builder $query) use ($fromColumn, $fromDate) {
108
+                return $query->where($fromColumn, '>=', $fromDate);
109
+            })
110
+            ->when($fromDate && $untilDate, function (Builder $query) use ($fromColumn, $fromDate, $untilColumn, $untilDate) {
111
+                return $query->where($fromColumn, '>=', $fromDate)
112
+                    ->where($untilColumn, '<=', $untilDate);
113
+            })
114
+            ->when(! $fromDate && $untilDate, function (Builder $query) use ($untilColumn, $untilDate) {
115
+                return $query->where($untilColumn, '<=', $untilDate);
116
+            });
117
+    }
118
+
119
+    public function fromLabel(string $label): static
120
+    {
121
+        $this->fromLabel = $label;
122
+
123
+        return $this;
124
+    }
125
+
126
+    public function untilLabel(string $label): static
127
+    {
128
+        $this->untilLabel = $label;
129
+
130
+        return $this;
131
+    }
132
+
133
+    public function indicatorLabel(string $label): static
134
+    {
135
+        $this->indicatorLabel = $label;
136
+
137
+        return $this;
138
+    }
139
+
140
+    public function getIndicatorLabel(): string
141
+    {
142
+        return $this->indicatorLabel ?? Str::headline($this->getName());
143
+    }
144
+
145
+    public function defaultFromDate(string $date): static
146
+    {
147
+        $this->defaultFromDate = $date;
148
+
149
+        return $this;
150
+    }
151
+
152
+    public function defaultUntilDate(string $date): static
153
+    {
154
+        $this->defaultUntilDate = $date;
155
+
156
+        return $this;
157
+    }
158
+
159
+    public function fromColumn(string $column): static
160
+    {
161
+        $this->fromColumn = $column;
162
+
163
+        return $this;
164
+    }
165
+
166
+    public function untilColumn(string $column): static
167
+    {
168
+        $this->untilColumn = $column;
169
+
170
+        return $this;
171
+    }
172
+}

+ 12
- 0
app/Filament/Widgets/EnhancedStatsOverviewWidget.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Widgets;
4
+
5
+use Filament\Widgets\StatsOverviewWidget;
6
+
7
+class EnhancedStatsOverviewWidget extends StatsOverviewWidget
8
+{
9
+    protected static ?string $pollingInterval = null;
10
+
11
+    protected static bool $isLazy = false;
12
+}

+ 47
- 0
app/Filament/Widgets/EnhancedStatsOverviewWidget/EnhancedStat.php 查看文件

1
+<?php
2
+
3
+namespace App\Filament\Widgets\EnhancedStatsOverviewWidget;
4
+
5
+use Closure;
6
+use Filament\Support\Concerns\EvaluatesClosures;
7
+use Filament\Widgets\StatsOverviewWidget\Stat;
8
+use Illuminate\Contracts\Support\Htmlable;
9
+use Illuminate\Contracts\View\View;
10
+
11
+class EnhancedStat extends Stat
12
+{
13
+    use EvaluatesClosures;
14
+
15
+    protected string | Htmlable | Closure | null $prefixLabel = null;
16
+
17
+    protected string | Htmlable | Closure | null $suffixLabel = null;
18
+
19
+    public function prefix(string | Htmlable | Closure | null $label): static
20
+    {
21
+        $this->prefixLabel = $label;
22
+
23
+        return $this;
24
+    }
25
+
26
+    public function suffix(string | Htmlable | Closure | null $label): static
27
+    {
28
+        $this->suffixLabel = $label;
29
+
30
+        return $this;
31
+    }
32
+
33
+    public function getPrefixLabel(): string | Htmlable | null
34
+    {
35
+        return $this->evaluate($this->prefixLabel);
36
+    }
37
+
38
+    public function getSuffixLabel(): string | Htmlable | null
39
+    {
40
+        return $this->evaluate($this->suffixLabel);
41
+    }
42
+
43
+    public function render(): View
44
+    {
45
+        return view('filament.widgets.enhanced-stats-overview-widget.enhanced-stat', $this->data());
46
+    }
47
+}

+ 30
- 0
app/Jobs/ProcessOverdueInvoices.php 查看文件

1
+<?php
2
+
3
+namespace App\Jobs;
4
+
5
+use App\Enums\Accounting\InvoiceStatus;
6
+use App\Models\Accounting\Invoice;
7
+use Illuminate\Contracts\Queue\ShouldQueue;
8
+use Illuminate\Foundation\Bus\Dispatchable;
9
+use Illuminate\Foundation\Queue\Queueable;
10
+use Illuminate\Queue\InteractsWithQueue;
11
+use Illuminate\Queue\SerializesModels;
12
+
13
+class ProcessOverdueInvoices implements ShouldQueue
14
+{
15
+    use Dispatchable;
16
+    use InteractsWithQueue;
17
+    use Queueable;
18
+    use SerializesModels;
19
+
20
+    /**
21
+     * Execute the job.
22
+     */
23
+    public function handle(): void
24
+    {
25
+        Invoice::query()
26
+            ->whereIn('status', InvoiceStatus::canBeOverdue())
27
+            ->where('due_date', '<', today())
28
+            ->update(['status' => InvoiceStatus::Overdue]);
29
+    }
30
+}

+ 13
- 35
app/Listeners/ConfigureCompanyDefault.php 查看文件

2
 
2
 
3
 namespace App\Listeners;
3
 namespace App\Listeners;
4
 
4
 
5
-use App\Enums\Setting\DateFormat;
6
-use App\Enums\Setting\Font;
7
 use App\Enums\Setting\PrimaryColor;
5
 use App\Enums\Setting\PrimaryColor;
8
-use App\Enums\Setting\RecordsPerPage;
9
-use App\Enums\Setting\TableSortDirection;
10
-use App\Enums\Setting\WeekStart;
11
 use App\Events\CompanyConfigured;
6
 use App\Events\CompanyConfigured;
7
+use App\Services\CompanySettingsService;
12
 use App\Utilities\Currency\ConfigureCurrencies;
8
 use App\Utilities\Currency\ConfigureCurrencies;
13
 use Filament\Facades\Filament;
9
 use Filament\Facades\Filament;
14
 use Filament\Forms\Components\DatePicker;
10
 use Filament\Forms\Components\DatePicker;
16
 use Filament\Forms\Components\Tabs\Tab;
12
 use Filament\Forms\Components\Tabs\Tab;
17
 use Filament\Resources\Components\Tab as ResourcesTab;
13
 use Filament\Resources\Components\Tab as ResourcesTab;
18
 use Filament\Support\Facades\FilamentColor;
14
 use Filament\Support\Facades\FilamentColor;
19
-use Filament\Tables\Table;
20
 
15
 
21
 class ConfigureCompanyDefault
16
 class ConfigureCompanyDefault
22
 {
17
 {
26
     public function handle(CompanyConfigured $event): void
21
     public function handle(CompanyConfigured $event): void
27
     {
22
     {
28
         $company = $event->company;
23
         $company = $event->company;
24
+        $companyId = $company->id;
29
 
25
 
30
-        session([
31
-            'current_company_id' => $company->id,
32
-            'default_language' => $company->locale->language ?? config('transmatic.source_locale'),
33
-            'default_timezone' => $company->locale->timezone ?? config('app.timezone'),
34
-            'default_pagination_page_option' => $company->appearance->records_per_page->value ?? RecordsPerPage::DEFAULT,
35
-            'default_sort' => $company->appearance->table_sort_direction->value ?? TableSortDirection::DEFAULT,
36
-            'default_primary_color' => $company->appearance->primary_color->value ?? PrimaryColor::DEFAULT,
37
-            'default_font' => $company->appearance->font->value ?? Font::DEFAULT,
38
-            'default_date_format' => $company->locale->date_format->value ?? DateFormat::DEFAULT,
39
-            'default_week_start' => $company->locale->week_start->value ?? WeekStart::DEFAULT,
40
-        ]);
41
-
42
-        app()->setLocale(session('default_language'));
43
-        locale_set_default(session('default_language'));
44
-        config(['app.timezone' => session('default_timezone')]);
45
-        date_default_timezone_set(session('default_timezone'));
26
+        session(['current_company_id' => $companyId]);
46
 
27
 
47
-        $paginationPageOptions = RecordsPerPage::caseValues();
28
+        $settings = CompanySettingsService::getSettings($companyId);
48
 
29
 
49
-        Table::configureUsing(static function (Table $table) use ($paginationPageOptions): void {
50
-
51
-            $table
52
-                ->paginationPageOptions($paginationPageOptions)
53
-                ->defaultSort(column: 'id', direction: session('default_sort'))
54
-                ->defaultPaginationPageOption(session('default_pagination_page_option'));
55
-        }, isImportant: true);
30
+        app()->setLocale($settings['default_language']);
31
+        locale_set_default($settings['default_language']);
32
+        config(['app.timezone' => $settings['default_timezone']]);
33
+        date_default_timezone_set($settings['default_timezone']);
56
 
34
 
57
         FilamentColor::register([
35
         FilamentColor::register([
58
-            'primary' => PrimaryColor::from(session('default_primary_color'))->getColor(),
36
+            'primary' => PrimaryColor::from($settings['default_primary_color'])->getColor(),
59
         ]);
37
         ]);
60
 
38
 
61
         Filament::getPanel('company')
39
         Filament::getPanel('company')
62
-            ->font(session('default_font'))
40
+            ->font($settings['default_font'])
63
             ->brandName($company->name);
41
             ->brandName($company->name);
64
 
42
 
65
-        DatePicker::configureUsing(static function (DatePicker $component) {
43
+        DatePicker::configureUsing(static function (DatePicker $component) use ($settings) {
66
             $component
44
             $component
67
-                ->displayFormat(session('default_date_format'))
68
-                ->firstDayOfWeek(session('default_week_start'));
45
+                ->displayFormat($settings['default_date_format'])
46
+                ->firstDayOfWeek($settings['default_week_start']);
69
         });
47
         });
70
 
48
 
71
         Tab::configureUsing(static function (Tab $tab) {
49
         Tab::configureUsing(static function (Tab $tab) {

+ 0
- 4
app/Listeners/SyncAssociatedModels.php 查看文件

40
 
40
 
41
         $keyToMethodMap = [
41
         $keyToMethodMap = [
42
             'bank_account_id' => 'bankAccount',
42
             'bank_account_id' => 'bankAccount',
43
-            'sales_tax_id' => 'salesTax',
44
-            'purchase_tax_id' => 'purchaseTax',
45
-            'sales_discount_id' => 'salesDiscount',
46
-            'purchase_discount_id' => 'purchaseDiscount',
47
         ];
43
         ];
48
 
44
 
49
         foreach ($diff as $key => $value) {
45
         foreach ($diff as $key => $value) {

+ 0
- 29
app/Listeners/SyncWithCompanyDefaults.php 查看文件

2
 
2
 
3
 namespace App\Listeners;
3
 namespace App\Listeners;
4
 
4
 
5
-use App\Enums\Setting\DiscountType;
6
-use App\Enums\Setting\TaxType;
7
 use App\Events\CompanyDefaultEvent;
5
 use App\Events\CompanyDefaultEvent;
8
 use App\Models\Setting\CompanyDefault;
6
 use App\Models\Setting\CompanyDefault;
9
 use Illuminate\Support\Facades\DB;
7
 use Illuminate\Support\Facades\DB;
48
     private function updateCompanyDefaults($model, $companyId): void
46
     private function updateCompanyDefaults($model, $companyId): void
49
     {
47
     {
50
         $modelName = class_basename($model);
48
         $modelName = class_basename($model);
51
-        $type = $model->getAttribute('type');
52
 
49
 
53
         $default = CompanyDefault::firstOrNew([
50
         $default = CompanyDefault::firstOrNew([
54
             'company_id' => $companyId,
51
             'company_id' => $companyId,
55
         ]);
52
         ]);
56
 
53
 
57
         match ($modelName) {
54
         match ($modelName) {
58
-            'Discount' => $this->handleDiscount($default, $type, $model->getKey()),
59
-            'Tax' => $this->handleTax($default, $type, $model->getKey()),
60
             'Currency' => $default->currency_code = $model->getAttribute('code'),
55
             'Currency' => $default->currency_code = $model->getAttribute('code'),
61
             'BankAccount' => $default->bank_account_id = $model->getKey(),
56
             'BankAccount' => $default->bank_account_id = $model->getKey(),
62
             default => null,
57
             default => null,
64
 
59
 
65
         $default->save();
60
         $default->save();
66
     }
61
     }
67
-
68
-    private function handleDiscount($default, $type, $key): void
69
-    {
70
-        if (! in_array($type, [DiscountType::Sales, DiscountType::Purchase], true)) {
71
-            return;
72
-        }
73
-
74
-        match (true) {
75
-            $type === DiscountType::Sales => $default->sales_discount_id = $key,
76
-            $type === DiscountType::Purchase => $default->purchase_discount_id = $key,
77
-        };
78
-    }
79
-
80
-    private function handleTax($default, $type, $key): void
81
-    {
82
-        if (! in_array($type, [TaxType::Sales, TaxType::Purchase], true)) {
83
-            return;
84
-        }
85
-
86
-        match (true) {
87
-            $type === TaxType::Sales => $default->sales_tax_id = $key,
88
-            $type === TaxType::Purchase => $default->purchase_tax_id = $key,
89
-        };
90
-    }
91
 }
62
 }

+ 18
- 3
app/Models/Accounting/Account.php 查看文件

18
 use Illuminate\Database\Eloquent\Model;
18
 use Illuminate\Database\Eloquent\Model;
19
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
20
 use Illuminate\Database\Eloquent\Relations\HasMany;
20
 use Illuminate\Database\Eloquent\Relations\HasMany;
21
+use Illuminate\Database\Eloquent\Relations\HasOne;
21
 use Illuminate\Support\Carbon;
22
 use Illuminate\Support\Carbon;
22
 
23
 
23
 #[ObservedBy(AccountObserver::class)]
24
 #[ObservedBy(AccountObserver::class)]
41
         'description',
42
         'description',
42
         'archived',
43
         'archived',
43
         'default',
44
         'default',
44
-        'bank_account_id',
45
         'created_by',
45
         'created_by',
46
         'updated_by',
46
         'updated_by',
47
     ];
47
     ];
74
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
74
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
75
     }
75
     }
76
 
76
 
77
-    public function bankAccount(): BelongsTo
77
+    public function bankAccount(): HasOne
78
     {
78
     {
79
-        return $this->belongsTo(BankAccount::class, 'bank_account_id');
79
+        return $this->hasOne(BankAccount::class, 'account_id');
80
+    }
81
+
82
+    public function adjustment(): HasOne
83
+    {
84
+        return $this->hasOne(Adjustment::class, 'account_id');
80
     }
85
     }
81
 
86
 
82
     public function getLastTransactionDate(): ?string
87
     public function getLastTransactionDate(): ?string
118
         return $this->hasMany(JournalEntry::class, 'account_id');
123
         return $this->hasMany(JournalEntry::class, 'account_id');
119
     }
124
     }
120
 
125
 
126
+    public static function getAccountsReceivableAccount(): self
127
+    {
128
+        return self::where('name', 'Accounts Receivable')->firstOrFail();
129
+    }
130
+
131
+    public static function getAccountsPayableAccount(): self
132
+    {
133
+        return self::where('name', 'Accounts Payable')->firstOrFail();
134
+    }
135
+
121
     protected static function newFactory(): Factory
136
     protected static function newFactory(): Factory
122
     {
137
     {
123
         return AccountFactory::new();
138
         return AccountFactory::new();

+ 98
- 0
app/Models/Accounting/Adjustment.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\RateCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\AdjustmentCategory;
9
+use App\Enums\Accounting\AdjustmentComputation;
10
+use App\Enums\Accounting\AdjustmentScope;
11
+use App\Enums\Accounting\AdjustmentType;
12
+use App\Models\Common\Offering;
13
+use App\Observers\AdjustmentObserver;
14
+use Database\Factories\Accounting\AdjustmentFactory;
15
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
+use Illuminate\Database\Eloquent\Factories\Factory;
17
+use Illuminate\Database\Eloquent\Factories\HasFactory;
18
+use Illuminate\Database\Eloquent\Model;
19
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
20
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
21
+
22
+#[ObservedBy(AdjustmentObserver::class)]
23
+class Adjustment extends Model
24
+{
25
+    use Blamable;
26
+    use CompanyOwned;
27
+    use HasFactory;
28
+
29
+    protected $table = 'adjustments';
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'account_id',
34
+        'name',
35
+        'description',
36
+        'category',
37
+        'type',
38
+        'recoverable',
39
+        'rate',
40
+        'computation',
41
+        'scope',
42
+        'start_date',
43
+        'end_date',
44
+        'created_by',
45
+        'updated_by',
46
+    ];
47
+
48
+    protected $casts = [
49
+        'category' => AdjustmentCategory::class,
50
+        'type' => AdjustmentType::class,
51
+        'recoverable' => 'boolean',
52
+        'rate' => RateCast::class,
53
+        'computation' => AdjustmentComputation::class,
54
+        'scope' => AdjustmentScope::class,
55
+        'start_date' => 'datetime',
56
+        'end_date' => 'datetime',
57
+    ];
58
+
59
+    public function account(): BelongsTo
60
+    {
61
+        return $this->belongsTo(Account::class, 'account_id');
62
+    }
63
+
64
+    public function offerings(): MorphToMany
65
+    {
66
+        return $this->morphedByMany(Offering::class, 'adjustmentable', 'adjustmentables');
67
+    }
68
+
69
+    public function isSalesTax(): bool
70
+    {
71
+        return $this->category->isTax() && $this->type->isSales();
72
+    }
73
+
74
+    public function isNonRecoverablePurchaseTax(): bool
75
+    {
76
+        return $this->category->isTax() && $this->type->isPurchase() && $this->recoverable === false;
77
+    }
78
+
79
+    public function isRecoverablePurchaseTax(): bool
80
+    {
81
+        return $this->category->isTax() && $this->type->isPurchase() && $this->recoverable === true;
82
+    }
83
+
84
+    public function isSalesDiscount(): bool
85
+    {
86
+        return $this->category->isDiscount() && $this->type->isSales();
87
+    }
88
+
89
+    public function isPurchaseDiscount(): bool
90
+    {
91
+        return $this->category->isDiscount() && $this->type->isPurchase();
92
+    }
93
+
94
+    protected static function newFactory(): Factory
95
+    {
96
+        return AdjustmentFactory::new();
97
+    }
98
+}

+ 311
- 0
app/Models/Accounting/Bill.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\BillStatus;
9
+use App\Enums\Accounting\JournalEntryType;
10
+use App\Enums\Accounting\TransactionType;
11
+use App\Filament\Company\Resources\Purchases\BillResource;
12
+use App\Models\Common\Vendor;
13
+use App\Observers\BillObserver;
14
+use Filament\Actions\MountableAction;
15
+use Filament\Actions\ReplicateAction;
16
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
17
+use Illuminate\Database\Eloquent\Builder;
18
+use Illuminate\Database\Eloquent\Casts\Attribute;
19
+use Illuminate\Database\Eloquent\Factories\HasFactory;
20
+use Illuminate\Database\Eloquent\Model;
21
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
22
+use Illuminate\Database\Eloquent\Relations\MorphMany;
23
+use Illuminate\Database\Eloquent\Relations\MorphOne;
24
+use Illuminate\Support\Carbon;
25
+
26
+#[ObservedBy(BillObserver::class)]
27
+class Bill extends Model
28
+{
29
+    use Blamable;
30
+    use CompanyOwned;
31
+    use HasFactory;
32
+
33
+    protected $table = 'bills';
34
+
35
+    protected $fillable = [
36
+        'company_id',
37
+        'vendor_id',
38
+        'bill_number',
39
+        'order_number',
40
+        'date',
41
+        'due_date',
42
+        'paid_at',
43
+        'status',
44
+        'currency_code',
45
+        'subtotal',
46
+        'tax_total',
47
+        'discount_total',
48
+        'total',
49
+        'amount_paid',
50
+        'notes',
51
+        'created_by',
52
+        'updated_by',
53
+    ];
54
+
55
+    protected $casts = [
56
+        'date' => 'date',
57
+        'due_date' => 'date',
58
+        'paid_at' => 'datetime',
59
+        'status' => BillStatus::class,
60
+        'subtotal' => MoneyCast::class,
61
+        'tax_total' => MoneyCast::class,
62
+        'discount_total' => MoneyCast::class,
63
+        'total' => MoneyCast::class,
64
+        'amount_paid' => MoneyCast::class,
65
+        'amount_due' => MoneyCast::class,
66
+    ];
67
+
68
+    public function vendor(): BelongsTo
69
+    {
70
+        return $this->belongsTo(Vendor::class);
71
+    }
72
+
73
+    public function lineItems(): MorphMany
74
+    {
75
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
76
+    }
77
+
78
+    public function transactions(): MorphMany
79
+    {
80
+        return $this->morphMany(Transaction::class, 'transactionable');
81
+    }
82
+
83
+    public function payments(): MorphMany
84
+    {
85
+        return $this->transactions()->where('is_payment', true);
86
+    }
87
+
88
+    public function deposits(): MorphMany
89
+    {
90
+        return $this->transactions()->where('type', TransactionType::Deposit)->where('is_payment', true);
91
+    }
92
+
93
+    public function withdrawals(): MorphMany
94
+    {
95
+        return $this->transactions()->where('type', TransactionType::Withdrawal)->where('is_payment', true);
96
+    }
97
+
98
+    public function initialTransaction(): MorphOne
99
+    {
100
+        return $this->morphOne(Transaction::class, 'transactionable')
101
+            ->where('type', TransactionType::Journal);
102
+    }
103
+
104
+    protected function isCurrentlyOverdue(): Attribute
105
+    {
106
+        return Attribute::get(function () {
107
+            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
108
+        });
109
+    }
110
+
111
+    public function canBeOverdue(): bool
112
+    {
113
+        return in_array($this->status, BillStatus::canBeOverdue());
114
+    }
115
+
116
+    public function canRecordPayment(): bool
117
+    {
118
+        return ! in_array($this->status, [
119
+            BillStatus::Paid,
120
+            BillStatus::Void,
121
+        ]);
122
+    }
123
+
124
+    public function hasPayments(): bool
125
+    {
126
+        return $this->payments->isNotEmpty();
127
+    }
128
+
129
+    public static function getNextDocumentNumber(): string
130
+    {
131
+        $company = auth()->user()->currentCompany;
132
+
133
+        if (! $company) {
134
+            throw new \RuntimeException('No current company is set for the user.');
135
+        }
136
+
137
+        $defaultBillSettings = $company->defaultBill;
138
+
139
+        $numberPrefix = $defaultBillSettings->number_prefix;
140
+        $numberDigits = $defaultBillSettings->number_digits;
141
+
142
+        $latestDocument = static::query()
143
+            ->whereNotNull('bill_number')
144
+            ->latest('bill_number')
145
+            ->first();
146
+
147
+        $lastNumberNumericPart = $latestDocument
148
+            ? (int) substr($latestDocument->bill_number, strlen($numberPrefix))
149
+            : 0;
150
+
151
+        $numberNext = $lastNumberNumericPart + 1;
152
+
153
+        return $defaultBillSettings->getNumberNext(
154
+            padded: true,
155
+            format: true,
156
+            prefix: $numberPrefix,
157
+            digits: $numberDigits,
158
+            next: $numberNext
159
+        );
160
+    }
161
+
162
+    public function hasInitialTransaction(): bool
163
+    {
164
+        return $this->initialTransaction()->exists();
165
+    }
166
+
167
+    public function scopeOutstanding(Builder $query): Builder
168
+    {
169
+        return $query->whereIn('status', [
170
+            BillStatus::Unpaid,
171
+            BillStatus::Partial,
172
+            BillStatus::Overdue,
173
+        ]);
174
+    }
175
+
176
+    public function recordPayment(array $data): void
177
+    {
178
+        $transactionType = TransactionType::Withdrawal;
179
+        $transactionDescription = "Bill #{$this->bill_number}: Payment to {$this->vendor->name}";
180
+
181
+        // Create transaction
182
+        $this->transactions()->create([
183
+            'company_id' => $this->company_id,
184
+            'type' => $transactionType,
185
+            'is_payment' => true,
186
+            'posted_at' => $data['posted_at'],
187
+            'amount' => $data['amount'],
188
+            'payment_method' => $data['payment_method'],
189
+            'bank_account_id' => $data['bank_account_id'],
190
+            'account_id' => Account::getAccountsPayableAccount()->id,
191
+            'description' => $transactionDescription,
192
+            'notes' => $data['notes'] ?? null,
193
+        ]);
194
+    }
195
+
196
+    public function createInitialTransaction(?Carbon $postedAt = null): void
197
+    {
198
+        $postedAt ??= $this->date;
199
+
200
+        $transaction = $this->transactions()->create([
201
+            'company_id' => $this->company_id,
202
+            'type' => TransactionType::Journal,
203
+            'posted_at' => $postedAt,
204
+            'amount' => $this->total,
205
+            'description' => 'Bill Creation for Bill #' . $this->bill_number,
206
+        ]);
207
+
208
+        $baseDescription = "{$this->vendor->name}: Bill #{$this->bill_number}";
209
+
210
+        $transaction->journalEntries()->create([
211
+            'company_id' => $this->company_id,
212
+            'type' => JournalEntryType::Credit,
213
+            'account_id' => Account::getAccountsPayableAccount()->id,
214
+            'amount' => $this->total,
215
+            'description' => $baseDescription,
216
+        ]);
217
+
218
+        foreach ($this->lineItems as $lineItem) {
219
+            $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
220
+
221
+            $transaction->journalEntries()->create([
222
+                'company_id' => $this->company_id,
223
+                'type' => JournalEntryType::Debit,
224
+                'account_id' => $lineItem->offering->expense_account_id,
225
+                'amount' => $lineItem->subtotal,
226
+                'description' => $lineItemDescription,
227
+            ]);
228
+
229
+            foreach ($lineItem->adjustments as $adjustment) {
230
+                if ($adjustment->isNonRecoverablePurchaseTax()) {
231
+                    $transaction->journalEntries()->create([
232
+                        'company_id' => $this->company_id,
233
+                        'type' => JournalEntryType::Debit,
234
+                        'account_id' => $lineItem->offering->expense_account_id,
235
+                        'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
236
+                        'description' => "{$lineItemDescription} ({$adjustment->name})",
237
+                    ]);
238
+                } elseif ($adjustment->account_id) {
239
+                    $transaction->journalEntries()->create([
240
+                        'company_id' => $this->company_id,
241
+                        'type' => $adjustment->category->isDiscount() ? JournalEntryType::Credit : JournalEntryType::Debit,
242
+                        'account_id' => $adjustment->account_id,
243
+                        'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
244
+                        'description' => $lineItemDescription,
245
+                    ]);
246
+                }
247
+            }
248
+        }
249
+    }
250
+
251
+    public function updateInitialTransaction(): void
252
+    {
253
+        $transaction = $this->initialTransaction;
254
+
255
+        if ($transaction) {
256
+            $transaction->delete();
257
+        }
258
+
259
+        $this->createInitialTransaction();
260
+    }
261
+
262
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
263
+    {
264
+        return $action::make()
265
+            ->excludeAttributes([
266
+                'status',
267
+                'amount_paid',
268
+                'amount_due',
269
+                'created_by',
270
+                'updated_by',
271
+                'created_at',
272
+                'updated_at',
273
+                'bill_number',
274
+                'date',
275
+                'due_date',
276
+                'paid_at',
277
+            ])
278
+            ->modal(false)
279
+            ->beforeReplicaSaved(function (self $original, self $replica) {
280
+                $replica->status = BillStatus::Unpaid;
281
+                $replica->bill_number = self::getNextDocumentNumber();
282
+                $replica->date = now();
283
+                $replica->due_date = now()->addDays($original->company->defaultBill->payment_terms->getDays());
284
+            })
285
+            ->databaseTransaction()
286
+            ->after(function (self $original, self $replica) {
287
+                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
288
+                    $replicaLineItem = $lineItem->replicate([
289
+                        'documentable_id',
290
+                        'documentable_type',
291
+                        'subtotal',
292
+                        'total',
293
+                        'created_by',
294
+                        'updated_by',
295
+                        'created_at',
296
+                        'updated_at',
297
+                    ]);
298
+
299
+                    $replicaLineItem->documentable_id = $replica->id;
300
+                    $replicaLineItem->documentable_type = $replica->getMorphClass();
301
+
302
+                    $replicaLineItem->save();
303
+
304
+                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
305
+                });
306
+            })
307
+            ->successRedirectUrl(static function (self $replica) {
308
+                return BillResource::getUrl('edit', ['record' => $replica]);
309
+            });
310
+    }
311
+}

+ 132
- 0
app/Models/Accounting/DocumentLineItem.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use Akaunting\Money\Money;
6
+use App\Casts\DocumentMoneyCast;
7
+use App\Casts\MoneyCast;
8
+use App\Concerns\Blamable;
9
+use App\Concerns\CompanyOwned;
10
+use App\Enums\Accounting\AdjustmentCategory;
11
+use App\Enums\Accounting\AdjustmentType;
12
+use App\Models\Common\Offering;
13
+use App\Observers\DocumentLineItemObserver;
14
+use App\Utilities\Currency\CurrencyAccessor;
15
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
16
+use Illuminate\Database\Eloquent\Factories\HasFactory;
17
+use Illuminate\Database\Eloquent\Model;
18
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
19
+use Illuminate\Database\Eloquent\Relations\MorphTo;
20
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
21
+
22
+#[ObservedBy(DocumentLineItemObserver::class)]
23
+class DocumentLineItem extends Model
24
+{
25
+    use Blamable;
26
+    use CompanyOwned;
27
+    use HasFactory;
28
+
29
+    protected $table = 'document_line_items';
30
+
31
+    protected $fillable = [
32
+        'company_id',
33
+        'offering_id',
34
+        'description',
35
+        'quantity',
36
+        'unit_price',
37
+        'tax_total',
38
+        'discount_total',
39
+        'created_by',
40
+        'updated_by',
41
+    ];
42
+
43
+    protected $casts = [
44
+        'unit_price' => MoneyCast::class,
45
+        'subtotal' => DocumentMoneyCast::class,
46
+        'tax_total' => MoneyCast::class,
47
+        'discount_total' => MoneyCast::class,
48
+        'total' => MoneyCast::class,
49
+    ];
50
+
51
+    public function documentable(): MorphTo
52
+    {
53
+        return $this->morphTo();
54
+    }
55
+
56
+    public function offering(): BelongsTo
57
+    {
58
+        return $this->belongsTo(Offering::class);
59
+    }
60
+
61
+    public function sellableOffering(): BelongsTo
62
+    {
63
+        return $this->offering()->where('sellable', true);
64
+    }
65
+
66
+    public function purchasableOffering(): BelongsTo
67
+    {
68
+        return $this->offering()->where('purchasable', true);
69
+    }
70
+
71
+    public function adjustments(): MorphToMany
72
+    {
73
+        return $this->morphToMany(Adjustment::class, 'adjustmentable', 'adjustmentables');
74
+    }
75
+
76
+    public function salesTaxes(): MorphToMany
77
+    {
78
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Sales);
79
+    }
80
+
81
+    public function purchaseTaxes(): MorphToMany
82
+    {
83
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Purchase);
84
+    }
85
+
86
+    public function salesDiscounts(): MorphToMany
87
+    {
88
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Sales);
89
+    }
90
+
91
+    public function purchaseDiscounts(): MorphToMany
92
+    {
93
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Purchase);
94
+    }
95
+
96
+    public function taxes(): MorphToMany
97
+    {
98
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax);
99
+    }
100
+
101
+    public function discounts(): MorphToMany
102
+    {
103
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount);
104
+    }
105
+
106
+    public function calculateTaxTotal(): Money
107
+    {
108
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
109
+
110
+        return $this->taxes->reduce(
111
+            fn (Money $carry, Adjustment $tax) => $carry->add($subtotal->multiply($tax->rate / 100)),
112
+            money(0, CurrencyAccessor::getDefaultCurrency())
113
+        );
114
+    }
115
+
116
+    public function calculateDiscountTotal(): Money
117
+    {
118
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
119
+
120
+        return $this->discounts->reduce(
121
+            fn (Money $carry, Adjustment $discount) => $carry->add($subtotal->multiply($discount->rate / 100)),
122
+            money(0, CurrencyAccessor::getDefaultCurrency())
123
+        );
124
+    }
125
+
126
+    public function calculateAdjustmentTotal(Adjustment $adjustment): Money
127
+    {
128
+        $subtotal = money($this->subtotal, CurrencyAccessor::getDefaultCurrency());
129
+
130
+        return $subtotal->multiply($adjustment->rate / 100);
131
+    }
132
+}

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

1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Collections\Accounting\InvoiceCollection;
7
+use App\Concerns\Blamable;
8
+use App\Concerns\CompanyOwned;
9
+use App\Enums\Accounting\InvoiceStatus;
10
+use App\Enums\Accounting\JournalEntryType;
11
+use App\Enums\Accounting\TransactionType;
12
+use App\Filament\Company\Resources\Sales\InvoiceResource;
13
+use App\Models\Common\Client;
14
+use App\Observers\InvoiceObserver;
15
+use Filament\Actions\Action;
16
+use Filament\Actions\MountableAction;
17
+use Filament\Actions\ReplicateAction;
18
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
19
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
20
+use Illuminate\Database\Eloquent\Builder;
21
+use Illuminate\Database\Eloquent\Casts\Attribute;
22
+use Illuminate\Database\Eloquent\Factories\HasFactory;
23
+use Illuminate\Database\Eloquent\Model;
24
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
25
+use Illuminate\Database\Eloquent\Relations\MorphMany;
26
+use Illuminate\Database\Eloquent\Relations\MorphOne;
27
+use Illuminate\Support\Carbon;
28
+
29
+#[ObservedBy(InvoiceObserver::class)]
30
+#[CollectedBy(InvoiceCollection::class)]
31
+class Invoice extends Model
32
+{
33
+    use Blamable;
34
+    use CompanyOwned;
35
+    use HasFactory;
36
+
37
+    protected $table = 'invoices';
38
+
39
+    protected $fillable = [
40
+        'company_id',
41
+        'client_id',
42
+        'logo',
43
+        'header',
44
+        'subheader',
45
+        'invoice_number',
46
+        'order_number',
47
+        'date',
48
+        'due_date',
49
+        'approved_at',
50
+        'paid_at',
51
+        'last_sent',
52
+        'status',
53
+        'currency_code',
54
+        'subtotal',
55
+        'tax_total',
56
+        'discount_total',
57
+        'total',
58
+        'amount_paid',
59
+        'terms',
60
+        'footer',
61
+        'created_by',
62
+        'updated_by',
63
+    ];
64
+
65
+    protected $casts = [
66
+        'date' => 'date',
67
+        'due_date' => 'date',
68
+        'approved_at' => 'datetime',
69
+        'paid_at' => 'datetime',
70
+        'last_sent' => 'datetime',
71
+        'status' => InvoiceStatus::class,
72
+        'subtotal' => MoneyCast::class,
73
+        'tax_total' => MoneyCast::class,
74
+        'discount_total' => MoneyCast::class,
75
+        'total' => MoneyCast::class,
76
+        'amount_paid' => MoneyCast::class,
77
+        'amount_due' => MoneyCast::class,
78
+    ];
79
+
80
+    public function client(): BelongsTo
81
+    {
82
+        return $this->belongsTo(Client::class);
83
+    }
84
+
85
+    public function lineItems(): MorphMany
86
+    {
87
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
88
+    }
89
+
90
+    public function transactions(): MorphMany
91
+    {
92
+        return $this->morphMany(Transaction::class, 'transactionable');
93
+    }
94
+
95
+    public function payments(): MorphMany
96
+    {
97
+        return $this->transactions()->where('is_payment', true);
98
+    }
99
+
100
+    public function deposits(): MorphMany
101
+    {
102
+        return $this->transactions()->where('type', TransactionType::Deposit)->where('is_payment', true);
103
+    }
104
+
105
+    public function withdrawals(): MorphMany
106
+    {
107
+        return $this->transactions()->where('type', TransactionType::Withdrawal)->where('is_payment', true);
108
+    }
109
+
110
+    public function approvalTransaction(): MorphOne
111
+    {
112
+        return $this->morphOne(Transaction::class, 'transactionable')
113
+            ->where('type', TransactionType::Journal);
114
+    }
115
+
116
+    public function scopeUnpaid(Builder $query): Builder
117
+    {
118
+        return $query->whereNotIn('status', [
119
+            InvoiceStatus::Paid,
120
+            InvoiceStatus::Void,
121
+            InvoiceStatus::Draft,
122
+            InvoiceStatus::Overpaid,
123
+        ]);
124
+    }
125
+
126
+    protected function isCurrentlyOverdue(): Attribute
127
+    {
128
+        return Attribute::get(function () {
129
+            return $this->due_date->isBefore(today()) && $this->canBeOverdue();
130
+        });
131
+    }
132
+
133
+    public function isDraft(): bool
134
+    {
135
+        return $this->status === InvoiceStatus::Draft;
136
+    }
137
+
138
+    public function canRecordPayment(): bool
139
+    {
140
+        return ! in_array($this->status, [
141
+            InvoiceStatus::Draft,
142
+            InvoiceStatus::Paid,
143
+            InvoiceStatus::Void,
144
+        ]);
145
+    }
146
+
147
+    public function canBulkRecordPayment(): bool
148
+    {
149
+        return ! in_array($this->status, [
150
+            InvoiceStatus::Draft,
151
+            InvoiceStatus::Paid,
152
+            InvoiceStatus::Void,
153
+            InvoiceStatus::Overpaid,
154
+        ]);
155
+    }
156
+
157
+    public function canBeOverdue(): bool
158
+    {
159
+        return in_array($this->status, InvoiceStatus::canBeOverdue());
160
+    }
161
+
162
+    public function hasPayments(): bool
163
+    {
164
+        return $this->payments->isNotEmpty();
165
+    }
166
+
167
+    public static function getNextDocumentNumber(): string
168
+    {
169
+        $company = auth()->user()->currentCompany;
170
+
171
+        if (! $company) {
172
+            throw new \RuntimeException('No current company is set for the user.');
173
+        }
174
+
175
+        $defaultInvoiceSettings = $company->defaultInvoice;
176
+
177
+        $numberPrefix = $defaultInvoiceSettings->number_prefix;
178
+        $numberDigits = $defaultInvoiceSettings->number_digits;
179
+
180
+        $latestDocument = static::query()
181
+            ->whereNotNull('invoice_number')
182
+            ->latest('invoice_number')
183
+            ->first();
184
+
185
+        $lastNumberNumericPart = $latestDocument
186
+            ? (int) substr($latestDocument->invoice_number, strlen($numberPrefix))
187
+            : 0;
188
+
189
+        $numberNext = $lastNumberNumericPart + 1;
190
+
191
+        return $defaultInvoiceSettings->getNumberNext(
192
+            padded: true,
193
+            format: true,
194
+            prefix: $numberPrefix,
195
+            digits: $numberDigits,
196
+            next: $numberNext
197
+        );
198
+    }
199
+
200
+    public function recordPayment(array $data): void
201
+    {
202
+        $isRefund = $this->status === InvoiceStatus::Overpaid;
203
+
204
+        if ($isRefund) {
205
+            $transactionType = TransactionType::Withdrawal;
206
+            $transactionDescription = "Invoice #{$this->invoice_number}: Refund to {$this->client->name}";
207
+        } else {
208
+            $transactionType = TransactionType::Deposit;
209
+            $transactionDescription = "Invoice #{$this->invoice_number}: Payment from {$this->client->name}";
210
+        }
211
+
212
+        // Create transaction
213
+        $this->transactions()->create([
214
+            'company_id' => $this->company_id,
215
+            'type' => $transactionType,
216
+            'is_payment' => true,
217
+            'posted_at' => $data['posted_at'],
218
+            'amount' => $data['amount'],
219
+            'payment_method' => $data['payment_method'],
220
+            'bank_account_id' => $data['bank_account_id'],
221
+            'account_id' => Account::getAccountsReceivableAccount()->id,
222
+            'description' => $transactionDescription,
223
+            'notes' => $data['notes'] ?? null,
224
+        ]);
225
+    }
226
+
227
+    public function approveDraft(?Carbon $approvedAt = null): void
228
+    {
229
+        if (! $this->isDraft()) {
230
+            throw new \RuntimeException('Invoice is not in draft status.');
231
+        }
232
+
233
+        $this->createApprovalTransaction();
234
+
235
+        $approvedAt ??= now();
236
+
237
+        $this->update([
238
+            'approved_at' => $approvedAt,
239
+            'status' => InvoiceStatus::Unsent,
240
+        ]);
241
+    }
242
+
243
+    public function createApprovalTransaction(): void
244
+    {
245
+        $transaction = $this->transactions()->create([
246
+            'company_id' => $this->company_id,
247
+            'type' => TransactionType::Journal,
248
+            'posted_at' => $this->date,
249
+            'amount' => $this->total,
250
+            'description' => 'Invoice Approval for Invoice #' . $this->invoice_number,
251
+        ]);
252
+
253
+        $baseDescription = "{$this->client->name}: Invoice #{$this->invoice_number}";
254
+
255
+        $transaction->journalEntries()->create([
256
+            'company_id' => $this->company_id,
257
+            'type' => JournalEntryType::Debit,
258
+            'account_id' => Account::getAccountsReceivableAccount()->id,
259
+            'amount' => $this->total,
260
+            'description' => $baseDescription,
261
+        ]);
262
+
263
+        foreach ($this->lineItems as $lineItem) {
264
+            $lineItemDescription = "{$baseDescription} › {$lineItem->offering->name}";
265
+
266
+            $transaction->journalEntries()->create([
267
+                'company_id' => $this->company_id,
268
+                'type' => JournalEntryType::Credit,
269
+                'account_id' => $lineItem->offering->income_account_id,
270
+                'amount' => $lineItem->subtotal,
271
+                'description' => $lineItemDescription,
272
+            ]);
273
+
274
+            foreach ($lineItem->adjustments as $adjustment) {
275
+                $transaction->journalEntries()->create([
276
+                    'company_id' => $this->company_id,
277
+                    'type' => $adjustment->category->isDiscount() ? JournalEntryType::Debit : JournalEntryType::Credit,
278
+                    'account_id' => $adjustment->account_id,
279
+                    'amount' => $lineItem->calculateAdjustmentTotal($adjustment)->getAmount(),
280
+                    'description' => $lineItemDescription,
281
+                ]);
282
+            }
283
+        }
284
+    }
285
+
286
+    public function updateApprovalTransaction(): void
287
+    {
288
+        $transaction = $this->approvalTransaction;
289
+
290
+        if ($transaction) {
291
+            $transaction->delete();
292
+        }
293
+
294
+        $this->createApprovalTransaction();
295
+    }
296
+
297
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
298
+    {
299
+        return $action::make('approveDraft')
300
+            ->label('Approve')
301
+            ->icon('heroicon-o-check-circle')
302
+            ->visible(function (self $record) {
303
+                return $record->isDraft();
304
+            })
305
+            ->databaseTransaction()
306
+            ->successNotificationTitle('Invoice Approved')
307
+            ->action(function (self $record, MountableAction $action) {
308
+                $record->approveDraft();
309
+
310
+                $action->success();
311
+            });
312
+    }
313
+
314
+    public static function getMarkAsSentAction(string $action = Action::class): MountableAction
315
+    {
316
+        return $action::make('markAsSent')
317
+            ->label('Mark as Sent')
318
+            ->icon('heroicon-o-paper-airplane')
319
+            ->visible(static function (self $record) {
320
+                return ! $record->last_sent;
321
+            })
322
+            ->successNotificationTitle('Invoice Sent')
323
+            ->action(function (self $record, MountableAction $action) {
324
+                $record->update([
325
+                    'status' => InvoiceStatus::Sent,
326
+                    'last_sent' => now(),
327
+                ]);
328
+
329
+                $action->success();
330
+            });
331
+    }
332
+
333
+    public static function getReplicateAction(string $action = ReplicateAction::class): MountableAction
334
+    {
335
+        return $action::make()
336
+            ->excludeAttributes([
337
+                'status',
338
+                'amount_paid',
339
+                'amount_due',
340
+                'created_by',
341
+                'updated_by',
342
+                'created_at',
343
+                'updated_at',
344
+                'invoice_number',
345
+                'date',
346
+                'due_date',
347
+                'approved_at',
348
+                'paid_at',
349
+                'last_sent',
350
+            ])
351
+            ->modal(false)
352
+            ->beforeReplicaSaved(function (self $original, self $replica) {
353
+                $replica->status = InvoiceStatus::Draft;
354
+                $replica->invoice_number = self::getNextDocumentNumber();
355
+                $replica->date = now();
356
+                $replica->due_date = now()->addDays($original->company->defaultInvoice->payment_terms->getDays());
357
+            })
358
+            ->databaseTransaction()
359
+            ->after(function (self $original, self $replica) {
360
+                $original->lineItems->each(function (DocumentLineItem $lineItem) use ($replica) {
361
+                    $replicaLineItem = $lineItem->replicate([
362
+                        'documentable_id',
363
+                        'documentable_type',
364
+                        'subtotal',
365
+                        'total',
366
+                        'created_by',
367
+                        'updated_by',
368
+                        'created_at',
369
+                        'updated_at',
370
+                    ]);
371
+
372
+                    $replicaLineItem->documentable_id = $replica->id;
373
+                    $replicaLineItem->documentable_type = $replica->getMorphClass();
374
+
375
+                    $replicaLineItem->save();
376
+
377
+                    $replicaLineItem->adjustments()->sync($lineItem->adjustments->pluck('id'));
378
+                });
379
+            })
380
+            ->successRedirectUrl(static function (self $replica) {
381
+                return InvoiceResource::getUrl('edit', ['record' => $replica]);
382
+            });
383
+    }
384
+}

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

5
 use App\Casts\TransactionAmountCast;
5
 use App\Casts\TransactionAmountCast;
6
 use App\Concerns\Blamable;
6
 use App\Concerns\Blamable;
7
 use App\Concerns\CompanyOwned;
7
 use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\PaymentMethod;
8
 use App\Enums\Accounting\TransactionType;
9
 use App\Enums\Accounting\TransactionType;
9
 use App\Models\Banking\BankAccount;
10
 use App\Models\Banking\BankAccount;
10
 use App\Models\Common\Contact;
11
 use App\Models\Common\Contact;
16
 use Illuminate\Database\Eloquent\Model;
17
 use Illuminate\Database\Eloquent\Model;
17
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
18
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
18
 use Illuminate\Database\Eloquent\Relations\HasMany;
19
 use Illuminate\Database\Eloquent\Relations\HasMany;
20
+use Illuminate\Database\Eloquent\Relations\MorphTo;
19
 
21
 
20
 #[ObservedBy(TransactionObserver::class)]
22
 #[ObservedBy(TransactionObserver::class)]
21
 class Transaction extends Model
23
 class Transaction extends Model
32
         'contact_id',
34
         'contact_id',
33
         'type', // 'deposit', 'withdrawal', 'journal'
35
         'type', // 'deposit', 'withdrawal', 'journal'
34
         'payment_channel',
36
         'payment_channel',
37
+        'payment_method',
38
+        'is_payment',
35
         'description',
39
         'description',
36
         'notes',
40
         'notes',
37
         'reference',
41
         'reference',
45
 
49
 
46
     protected $casts = [
50
     protected $casts = [
47
         'type' => TransactionType::class,
51
         'type' => TransactionType::class,
52
+        'payment_method' => PaymentMethod::class,
48
         'amount' => TransactionAmountCast::class,
53
         'amount' => TransactionAmountCast::class,
49
         'pending' => 'boolean',
54
         'pending' => 'boolean',
50
         'reviewed' => 'boolean',
55
         'reviewed' => 'boolean',
71
         return $this->hasMany(JournalEntry::class, 'transaction_id');
76
         return $this->hasMany(JournalEntry::class, 'transaction_id');
72
     }
77
     }
73
 
78
 
79
+    public function transactionable(): MorphTo
80
+    {
81
+        return $this->morphTo();
82
+    }
83
+
74
     public function isUncategorized(): bool
84
     public function isUncategorized(): bool
75
     {
85
     {
76
         return $this->journalEntries->contains(fn (JournalEntry $entry) => $entry->account->isUncategorized());
86
         return $this->journalEntries->contains(fn (JournalEntry $entry) => $entry->account->isUncategorized());

+ 5
- 4
app/Models/Banking/BankAccount.php 查看文件

33
 
33
 
34
     protected $fillable = [
34
     protected $fillable = [
35
         'company_id',
35
         'company_id',
36
+        'account_id',
36
         'institution_id',
37
         'institution_id',
37
         'type',
38
         'type',
38
         'number',
39
         'number',
50
         'mask',
51
         'mask',
51
     ];
52
     ];
52
 
53
 
53
-    public function connectedBankAccount(): HasOne
54
+    public function account(): BelongsTo
54
     {
55
     {
55
-        return $this->hasOne(ConnectedBankAccount::class, 'bank_account_id');
56
+        return $this->belongsTo(Account::class, 'account_id');
56
     }
57
     }
57
 
58
 
58
-    public function account(): HasOne
59
+    public function connectedBankAccount(): HasOne
59
     {
60
     {
60
-        return $this->hasOne(Account::class, 'bank_account_id');
61
+        return $this->hasOne(ConnectedBankAccount::class, 'bank_account_id');
61
     }
62
     }
62
 
63
 
63
     public function institution(): BelongsTo
64
     public function institution(): BelongsTo

+ 44
- 0
app/Models/Common/Address.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\AddressType;
8
+use Illuminate\Database\Eloquent\Factories\HasFactory;
9
+use Illuminate\Database\Eloquent\Model;
10
+use Illuminate\Database\Eloquent\Relations\MorphTo;
11
+
12
+class Address extends Model
13
+{
14
+    use Blamable;
15
+    use CompanyOwned;
16
+    use HasFactory;
17
+
18
+    protected $table = 'addresses';
19
+
20
+    protected $fillable = [
21
+        'company_id',
22
+        'type',
23
+        'recipient',
24
+        'phone',
25
+        'address_line_1',
26
+        'address_line_2',
27
+        'city',
28
+        'state',
29
+        'postal_code',
30
+        'country',
31
+        'notes',
32
+        'created_by',
33
+        'updated_by',
34
+    ];
35
+
36
+    protected $casts = [
37
+        'type' => AddressType::class,
38
+    ];
39
+
40
+    public function addressable(): MorphTo
41
+    {
42
+        return $this->morphTo();
43
+    }
44
+}

+ 79
- 0
app/Models/Common/Client.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\AddressType;
8
+use App\Models\Accounting\Invoice;
9
+use App\Models\Setting\Currency;
10
+use Illuminate\Database\Eloquent\Factories\HasFactory;
11
+use Illuminate\Database\Eloquent\Model;
12
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
13
+use Illuminate\Database\Eloquent\Relations\HasMany;
14
+use Illuminate\Database\Eloquent\Relations\MorphMany;
15
+use Illuminate\Database\Eloquent\Relations\MorphOne;
16
+
17
+class Client extends Model
18
+{
19
+    use Blamable;
20
+    use CompanyOwned;
21
+    use HasFactory;
22
+
23
+    protected $table = 'clients';
24
+
25
+    protected $fillable = [
26
+        'company_id',
27
+        'name',
28
+        'currency_code',
29
+        'account_number',
30
+        'website',
31
+        'notes',
32
+        'created_by',
33
+        'updated_by',
34
+    ];
35
+
36
+    public function contacts(): MorphMany
37
+    {
38
+        return $this->morphMany(Contact::class, 'contactable');
39
+    }
40
+
41
+    public function primaryContact(): MorphOne
42
+    {
43
+        return $this->morphOne(Contact::class, 'contactable')
44
+            ->where('is_primary', true);
45
+    }
46
+
47
+    public function secondaryContacts(): MorphMany
48
+    {
49
+        return $this->morphMany(Contact::class, 'contactable')
50
+            ->where('is_primary', false);
51
+    }
52
+
53
+    public function currency(): BelongsTo
54
+    {
55
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
56
+    }
57
+
58
+    public function addresses(): MorphMany
59
+    {
60
+        return $this->morphMany(Address::class, 'addressable');
61
+    }
62
+
63
+    public function billingAddress(): MorphOne
64
+    {
65
+        return $this->morphOne(Address::class, 'addressable')
66
+            ->where('type', AddressType::Billing);
67
+    }
68
+
69
+    public function shippingAddress(): MorphOne
70
+    {
71
+        return $this->morphOne(Address::class, 'addressable')
72
+            ->where('type', AddressType::Shipping);
73
+    }
74
+
75
+    public function invoices(): HasMany
76
+    {
77
+        return $this->hasMany(Invoice::class);
78
+    }
79
+}

+ 72
- 22
app/Models/Common/Contact.php 查看文件

5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Common\ContactType;
7
 use App\Enums\Common\ContactType;
8
-use App\Models\Setting\Currency;
9
 use Database\Factories\Common\ContactFactory;
8
 use Database\Factories\Common\ContactFactory;
9
+use Illuminate\Database\Eloquent\Casts\Attribute;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
12
 use Illuminate\Database\Eloquent\Model;
12
 use Illuminate\Database\Eloquent\Model;
13
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
-use Illuminate\Database\Eloquent\Relations\HasOne;
15
-use Wallo\FilamentCompanies\FilamentCompanies;
13
+use Illuminate\Database\Eloquent\Relations\MorphTo;
16
 
14
 
17
 class Contact extends Model
15
 class Contact extends Model
18
 {
16
 {
25
     protected $fillable = [
23
     protected $fillable = [
26
         'company_id',
24
         'company_id',
27
         'type',
25
         'type',
28
-        'name',
26
+        'first_name',
27
+        'last_name',
29
         'email',
28
         'email',
30
-        'address',
31
-        'city_id',
32
-        'zip_code',
33
-        'state_id',
34
-        'country',
35
-        'timezone',
36
-        'language',
37
-        'contact_method',
38
-        'phone_number',
39
-        'tax_id',
40
-        'currency_code',
41
-        'website',
42
-        'reference',
29
+        'phones',
30
+        'is_primary',
43
         'created_by',
31
         'created_by',
44
         'updated_by',
32
         'updated_by',
45
     ];
33
     ];
46
 
34
 
47
     protected $casts = [
35
     protected $casts = [
48
         'type' => ContactType::class,
36
         'type' => ContactType::class,
37
+        'phones' => 'array',
49
     ];
38
     ];
50
 
39
 
51
-    public function currency(): BelongsTo
40
+    public function contactable(): MorphTo
52
     {
41
     {
53
-        return $this->belongsTo(Currency::class, 'currency_code');
42
+        return $this->morphTo();
54
     }
43
     }
55
 
44
 
56
-    public function employeeship(): HasOne
45
+    protected function fullName(): Attribute
57
     {
46
     {
58
-        return $this->hasOne(FilamentCompanies::employeeshipModel(), 'contact_id');
47
+        return Attribute::get(function () {
48
+            return trim("{$this->first_name} {$this->last_name}");
49
+        });
50
+    }
51
+
52
+    protected function primaryPhone(): Attribute
53
+    {
54
+        return Attribute::get(function () {
55
+            return $this->getPhoneByType('primary');
56
+        });
57
+    }
58
+
59
+    protected function mobilePhone(): Attribute
60
+    {
61
+        return Attribute::get(function () {
62
+            return $this->getPhoneByType('mobile');
63
+        });
64
+    }
65
+
66
+    protected function faxPhone(): Attribute
67
+    {
68
+        return Attribute::get(function () {
69
+            return $this->getPhoneByType('fax');
70
+        });
71
+    }
72
+
73
+    protected function tollFreePhone(): Attribute
74
+    {
75
+        return Attribute::get(function () {
76
+            return $this->getPhoneByType('toll_free');
77
+        });
78
+    }
79
+
80
+    protected function firstAvailablePhone(): Attribute
81
+    {
82
+        return Attribute::get(function () {
83
+            $priority = ['primary', 'mobile', 'toll_free', 'fax'];
84
+
85
+            foreach ($priority as $type) {
86
+                $phone = $this->getPhoneByType($type);
87
+                if ($phone) {
88
+                    return $phone;
89
+                }
90
+            }
91
+
92
+            return null; // Return null if no phone numbers are available
93
+        });
94
+    }
95
+
96
+    private function getPhoneByType(string $type): ?string
97
+    {
98
+        if (! is_array($this->phones)) {
99
+            return null;
100
+        }
101
+
102
+        foreach ($this->phones as $phone) {
103
+            if ($phone['type'] === $type) {
104
+                return $phone['data']['number'] ?? null;
105
+            }
106
+        }
107
+
108
+        return null;
59
     }
109
     }
60
 
110
 
61
     protected static function newFactory(): Factory
111
     protected static function newFactory(): Factory

+ 114
- 0
app/Models/Common/Offering.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Concerns\Blamable;
7
+use App\Concerns\CompanyOwned;
8
+use App\Enums\Accounting\AdjustmentCategory;
9
+use App\Enums\Accounting\AdjustmentType;
10
+use App\Enums\Common\OfferingType;
11
+use App\Models\Accounting\Account;
12
+use App\Models\Accounting\Adjustment;
13
+use App\Observers\OfferingObserver;
14
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
15
+use Illuminate\Database\Eloquent\Factories\HasFactory;
16
+use Illuminate\Database\Eloquent\Model;
17
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
18
+use Illuminate\Database\Eloquent\Relations\MorphToMany;
19
+
20
+#[ObservedBy(OfferingObserver::class)]
21
+class Offering extends Model
22
+{
23
+    use Blamable;
24
+    use CompanyOwned;
25
+    use HasFactory;
26
+
27
+    protected $fillable = [
28
+        'company_id',
29
+        'name',
30
+        'description',
31
+        'type',
32
+        'price',
33
+        'sellable',
34
+        'purchasable',
35
+        'income_account_id',
36
+        'expense_account_id',
37
+        'created_by',
38
+        'updated_by',
39
+    ];
40
+
41
+    protected $casts = [
42
+        'type' => OfferingType::class,
43
+        'price' => MoneyCast::class,
44
+        'sellable' => 'boolean',
45
+        'purchasable' => 'boolean',
46
+    ];
47
+
48
+    public function clearSellableAdjustments(): void
49
+    {
50
+        if (! $this->sellable) {
51
+            $this->income_account_id = null;
52
+
53
+            $adjustmentIds = $this->salesAdjustments()->pluck('adjustment_id');
54
+
55
+            $this->adjustments()->detach($adjustmentIds);
56
+        }
57
+    }
58
+
59
+    public function clearPurchasableAdjustments(): void
60
+    {
61
+        if (! $this->purchasable) {
62
+            $this->expense_account_id = null;
63
+
64
+            $adjustmentIds = $this->purchaseAdjustments()->pluck('adjustment_id');
65
+
66
+            $this->adjustments()->detach($adjustmentIds);
67
+        }
68
+    }
69
+
70
+    public function incomeAccount(): BelongsTo
71
+    {
72
+        return $this->belongsTo(Account::class, 'income_account_id');
73
+    }
74
+
75
+    public function expenseAccount(): BelongsTo
76
+    {
77
+        return $this->belongsTo(Account::class, 'expense_account_id');
78
+    }
79
+
80
+    public function adjustments(): MorphToMany
81
+    {
82
+        return $this->morphToMany(Adjustment::class, 'adjustmentable', 'adjustmentables');
83
+    }
84
+
85
+    public function salesAdjustments(): MorphToMany
86
+    {
87
+        return $this->adjustments()->where('type', AdjustmentType::Sales);
88
+    }
89
+
90
+    public function purchaseAdjustments(): MorphToMany
91
+    {
92
+        return $this->adjustments()->where('type', AdjustmentType::Purchase);
93
+    }
94
+
95
+    public function salesTaxes(): MorphToMany
96
+    {
97
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Sales);
98
+    }
99
+
100
+    public function purchaseTaxes(): MorphToMany
101
+    {
102
+        return $this->adjustments()->where('category', AdjustmentCategory::Tax)->where('type', AdjustmentType::Purchase);
103
+    }
104
+
105
+    public function salesDiscounts(): MorphToMany
106
+    {
107
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Sales);
108
+    }
109
+
110
+    public function purchaseDiscounts(): MorphToMany
111
+    {
112
+        return $this->adjustments()->where('category', AdjustmentCategory::Discount)->where('type', AdjustmentType::Purchase);
113
+    }
114
+}

+ 66
- 0
app/Models/Common/Vendor.php 查看文件

1
+<?php
2
+
3
+namespace App\Models\Common;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Common\ContractorType;
8
+use App\Enums\Common\VendorType;
9
+use App\Models\Accounting\Bill;
10
+use App\Models\Setting\Currency;
11
+use Illuminate\Database\Eloquent\Factories\HasFactory;
12
+use Illuminate\Database\Eloquent\Model;
13
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\HasMany;
15
+use Illuminate\Database\Eloquent\Relations\MorphOne;
16
+
17
+class Vendor extends Model
18
+{
19
+    use Blamable;
20
+    use CompanyOwned;
21
+    use HasFactory;
22
+
23
+    protected $table = 'vendors';
24
+
25
+    protected $fillable = [
26
+        'company_id',
27
+        'name',
28
+        'type',
29
+        'contractor_type',
30
+        'ssn',
31
+        'ein',
32
+        'currency_code',
33
+        'account_number',
34
+        'website',
35
+        'notes',
36
+        'created_by',
37
+        'updated_by',
38
+    ];
39
+
40
+    protected $casts = [
41
+        'type' => VendorType::class,
42
+        'contractor_type' => ContractorType::class,
43
+        'ssn' => 'encrypted',
44
+        'ein' => 'encrypted',
45
+    ];
46
+
47
+    public function bills(): HasMany
48
+    {
49
+        return $this->hasMany(Bill::class);
50
+    }
51
+
52
+    public function currency(): BelongsTo
53
+    {
54
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
55
+    }
56
+
57
+    public function address(): MorphOne
58
+    {
59
+        return $this->morphOne(Address::class, 'addressable');
60
+    }
61
+
62
+    public function contact(): MorphOne
63
+    {
64
+        return $this->morphOne(Contact::class, 'contactable');
65
+    }
66
+}

+ 33
- 8
app/Models/Company.php 查看文件

6
 use App\Models\Accounting\AccountSubtype;
6
 use App\Models\Accounting\AccountSubtype;
7
 use App\Models\Banking\BankAccount;
7
 use App\Models\Banking\BankAccount;
8
 use App\Models\Banking\ConnectedBankAccount;
8
 use App\Models\Banking\ConnectedBankAccount;
9
+use App\Models\Common\Client;
9
 use App\Models\Common\Contact;
10
 use App\Models\Common\Contact;
11
+use App\Models\Common\Offering;
10
 use App\Models\Core\Department;
12
 use App\Models\Core\Department;
11
 use App\Models\Setting\Appearance;
13
 use App\Models\Setting\Appearance;
12
 use App\Models\Setting\CompanyDefault;
14
 use App\Models\Setting\CompanyDefault;
13
 use App\Models\Setting\CompanyProfile;
15
 use App\Models\Setting\CompanyProfile;
14
 use App\Models\Setting\Currency;
16
 use App\Models\Setting\Currency;
15
-use App\Models\Setting\Discount;
16
 use App\Models\Setting\DocumentDefault;
17
 use App\Models\Setting\DocumentDefault;
17
 use App\Models\Setting\Localization;
18
 use App\Models\Setting\Localization;
18
-use App\Models\Setting\Tax;
19
 use Filament\Models\Contracts\HasAvatar;
19
 use Filament\Models\Contracts\HasAvatar;
20
 use Illuminate\Database\Eloquent\Factories\HasFactory;
20
 use Illuminate\Database\Eloquent\Factories\HasFactory;
21
 use Illuminate\Database\Eloquent\Relations\HasMany;
21
 use Illuminate\Database\Eloquent\Relations\HasMany;
74
         return $this->hasMany(Accounting\Account::class, 'company_id');
74
         return $this->hasMany(Accounting\Account::class, 'company_id');
75
     }
75
     }
76
 
76
 
77
+    public function addresses(): HasMany
78
+    {
79
+        return $this->hasMany(Common\Address::class, 'company_id');
80
+    }
81
+
82
+    public function adjustments(): HasMany
83
+    {
84
+        return $this->hasMany(Accounting\Adjustment::class, 'company_id');
85
+    }
86
+
77
     public function bankAccounts(): HasMany
87
     public function bankAccounts(): HasMany
78
     {
88
     {
79
         return $this->hasMany(BankAccount::class, 'company_id');
89
         return $this->hasMany(BankAccount::class, 'company_id');
80
     }
90
     }
81
 
91
 
92
+    public function bills(): HasMany
93
+    {
94
+        return $this->hasMany(Accounting\Bill::class, 'company_id');
95
+    }
96
+
82
     public function appearance(): HasOne
97
     public function appearance(): HasOne
83
     {
98
     {
84
         return $this->hasOne(Appearance::class, 'company_id');
99
         return $this->hasOne(Appearance::class, 'company_id');
90
 
105
 
91
     }
106
     }
92
 
107
 
108
+    public function clients(): HasMany
109
+    {
110
+        return $this->hasMany(Client::class, 'company_id');
111
+    }
112
+
93
     public function contacts(): HasMany
113
     public function contacts(): HasMany
94
     {
114
     {
95
         return $this->hasMany(Contact::class, 'company_id');
115
         return $this->hasMany(Contact::class, 'company_id');
122
         return $this->hasMany(Department::class, 'company_id');
142
         return $this->hasMany(Department::class, 'company_id');
123
     }
143
     }
124
 
144
 
125
-    public function discounts(): HasMany
145
+    public function invoices(): HasMany
126
     {
146
     {
127
-        return $this->hasMany(Discount::class, 'company_id');
147
+        return $this->hasMany(Accounting\Invoice::class, 'company_id');
128
     }
148
     }
129
 
149
 
130
     public function locale(): HasOne
150
     public function locale(): HasOne
137
         return $this->hasOne(CompanyProfile::class, 'company_id');
157
         return $this->hasOne(CompanyProfile::class, 'company_id');
138
     }
158
     }
139
 
159
 
140
-    public function taxes(): HasMany
160
+    public function transactions(): HasMany
141
     {
161
     {
142
-        return $this->hasMany(Tax::class, 'company_id');
162
+        return $this->hasMany(Accounting\Transaction::class, 'company_id');
143
     }
163
     }
144
 
164
 
145
-    public function transactions(): HasMany
165
+    public function offerings(): HasMany
146
     {
166
     {
147
-        return $this->hasMany(Accounting\Transaction::class, 'company_id');
167
+        return $this->hasMany(Offering::class, 'company_id');
168
+    }
169
+
170
+    public function vendors(): HasMany
171
+    {
172
+        return $this->hasMany(Common\Vendor::class, 'company_id');
148
     }
173
     }
149
 }
174
 }

+ 0
- 6
app/Models/Setting/Appearance.php 查看文件

6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Setting\Font;
7
 use App\Enums\Setting\Font;
8
 use App\Enums\Setting\PrimaryColor;
8
 use App\Enums\Setting\PrimaryColor;
9
-use App\Enums\Setting\RecordsPerPage;
10
-use App\Enums\Setting\TableSortDirection;
11
 use Database\Factories\Setting\AppearanceFactory;
9
 use Database\Factories\Setting\AppearanceFactory;
12
 use Illuminate\Database\Eloquent\Factories\Factory;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
13
 use Illuminate\Database\Eloquent\Factories\HasFactory;
11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
25
         'company_id',
23
         'company_id',
26
         'primary_color',
24
         'primary_color',
27
         'font',
25
         'font',
28
-        'table_sort_direction',
29
-        'records_per_page',
30
         'created_by',
26
         'created_by',
31
         'updated_by',
27
         'updated_by',
32
     ];
28
     ];
34
     protected $casts = [
30
     protected $casts = [
35
         'primary_color' => PrimaryColor::class,
31
         'primary_color' => PrimaryColor::class,
36
         'font' => Font::class,
32
         'font' => Font::class,
37
-        'table_sort_direction' => TableSortDirection::class,
38
-        'records_per_page' => RecordsPerPage::class,
39
     ];
33
     ];
40
 
34
 
41
     protected static function newFactory(): Factory
35
     protected static function newFactory(): Factory

+ 0
- 30
app/Models/Setting/CompanyDefault.php 查看文件

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\Setting\DiscountType;
8
-use App\Enums\Setting\TaxType;
9
 use App\Models\Banking\BankAccount;
7
 use App\Models\Banking\BankAccount;
10
 use Database\Factories\Setting\CompanyDefaultFactory;
8
 use Database\Factories\Setting\CompanyDefaultFactory;
11
 use Illuminate\Database\Eloquent\Factories\Factory;
9
 use Illuminate\Database\Eloquent\Factories\Factory;
25
         'company_id',
23
         'company_id',
26
         'bank_account_id',
24
         'bank_account_id',
27
         'currency_code',
25
         'currency_code',
28
-        'sales_tax_id',
29
-        'purchase_tax_id',
30
-        'sales_discount_id',
31
-        'purchase_discount_id',
32
         'created_by',
26
         'created_by',
33
         'updated_by',
27
         'updated_by',
34
     ];
28
     ];
43
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
37
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
44
     }
38
     }
45
 
39
 
46
-    public function salesTax(): BelongsTo
47
-    {
48
-        return $this->belongsTo(Tax::class, 'sales_tax_id', 'id')
49
-            ->where('type', TaxType::Sales);
50
-    }
51
-
52
-    public function purchaseTax(): BelongsTo
53
-    {
54
-        return $this->belongsTo(Tax::class, 'purchase_tax_id', 'id')
55
-            ->where('type', TaxType::Purchase);
56
-    }
57
-
58
-    public function salesDiscount(): BelongsTo
59
-    {
60
-        return $this->belongsTo(Discount::class, 'sales_discount_id', 'id')
61
-            ->where('type', DiscountType::Sales);
62
-    }
63
-
64
-    public function purchaseDiscount(): BelongsTo
65
-    {
66
-        return $this->belongsTo(Discount::class, 'purchase_discount_id', 'id')
67
-            ->where('type', DiscountType::Purchase);
68
-    }
69
-
70
     protected static function newFactory(): Factory
40
     protected static function newFactory(): Factory
71
     {
41
     {
72
         return CompanyDefaultFactory::new();
42
         return CompanyDefaultFactory::new();

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


部分文件因文件數量過多而無法顯示

Loading…
取消
儲存