浏览代码

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

Development 3.x
3.x
Andrew Wallo 8 个月前
父节点
当前提交
85b6422972
没有帐户链接到提交者的电子邮件
共有 85 个文件被更改,包括 2226 次插入1494 次删除
  1. 27
    0
      app/Concerns/HasTabSpecificColumnToggles.php
  2. 1
    1
      app/Concerns/ManagesLineItems.php
  3. 24
    2
      app/DTO/ClientDTO.php
  4. 19
    0
      app/DTO/ClientPreviewDTO.php
  5. 32
    7
      app/DTO/CompanyDTO.php
  6. 40
    0
      app/DTO/DocumentColumnLabelDTO.php
  7. 26
    10
      app/DTO/DocumentDTO.php
  8. 64
    0
      app/DTO/DocumentPreviewDTO.php
  9. 1
    1
      app/DTO/LineItemDTO.php
  10. 33
    0
      app/DTO/LineItemPreviewDTO.php
  11. 5
    0
      app/Enums/Accounting/DayOfMonth.php
  12. 4
    4
      app/Enums/Concerns/ParsesEnum.php
  13. 3
    0
      app/Enums/Setting/PaymentTerms.php
  14. 25
    53
      app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php
  15. 1
    1
      app/Filament/Company/Clusters/Settings/Pages/Localization.php
  16. 10
    2
      app/Filament/Company/Pages/CreateCompany.php
  17. 6
    6
      app/Filament/Company/Resources/Purchases/BillResource.php
  18. 18
    0
      app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php
  19. 57
    32
      app/Filament/Company/Resources/Purchases/VendorResource.php
  20. 62
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Pages/ViewVendor.php
  21. 29
    0
      app/Filament/Company/Resources/Purchases/VendorResource/RelationManagers/BillsRelationManager.php
  22. 66
    0
      app/Filament/Company/Resources/Purchases/VendorResource/Widgets/BillOverview.php
  23. 93
    52
      app/Filament/Company/Resources/Sales/ClientResource.php
  24. 52
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php
  25. 50
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/EditClient.php
  26. 69
    0
      app/Filament/Company/Resources/Sales/ClientResource/Pages/ViewClient.php
  27. 29
    0
      app/Filament/Company/Resources/Sales/ClientResource/RelationManagers/EstimatesRelationManager.php
  28. 29
    0
      app/Filament/Company/Resources/Sales/ClientResource/RelationManagers/InvoicesRelationManager.php
  29. 29
    0
      app/Filament/Company/Resources/Sales/ClientResource/RelationManagers/RecurringInvoicesRelationManager.php
  30. 65
    0
      app/Filament/Company/Resources/Sales/ClientResource/Widgets/InvoiceOverview.php
  31. 3
    6
      app/Filament/Company/Resources/Sales/EstimateResource.php
  32. 18
    0
      app/Filament/Company/Resources/Sales/EstimateResource/Pages/CreateEstimate.php
  33. 27
    16
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  34. 18
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php
  35. 2
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php
  36. 16
    8
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  37. 18
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php
  38. 3
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php
  39. 3
    3
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php
  40. 58
    0
      app/Filament/Forms/Components/AddressFields.php
  41. 47
    0
      app/Filament/Forms/Components/Banner.php
  42. 49
    0
      app/Filament/Forms/Components/CountrySelect.php
  43. 0
    45
      app/Filament/Forms/Components/LabeledField.php
  44. 0
    165
      app/Filament/Forms/Components/LineItemRepeater.php
  45. 23
    0
      app/Filament/Forms/Components/StateSelect.php
  46. 47
    0
      app/Filament/Infolists/Components/BannerEntry.php
  47. 17
    0
      app/Filament/Tables/Columns.php
  48. 7
    0
      app/Models/Accounting/Invoice.php
  49. 36
    23
      app/Models/Accounting/RecurringInvoice.php
  50. 52
    2
      app/Models/Common/Address.php
  51. 12
    0
      app/Models/Common/Client.php
  52. 39
    8
      app/Models/Locale/Country.php
  53. 35
    5
      app/Models/Locale/State.php
  54. 4
    26
      app/Models/Setting/CompanyProfile.php
  55. 17
    0
      app/Models/Setting/DocumentDefault.php
  56. 4
    0
      app/Providers/FilamentCompaniesServiceProvider.php
  57. 54
    5
      app/Providers/MacroServiceProvider.php
  58. 6
    1
      app/Services/ReportService.php
  59. 8
    2
      app/Utilities/Currency/CurrencyConverter.php
  60. 0
    209
      app/View/Models/InvoiceViewModel.php
  61. 0
    1
      composer.json
  62. 62
    133
      composer.lock
  63. 1
    4
      config/aws.php
  64. 5
    5
      database/factories/Accounting/RecurringInvoiceFactory.php
  65. 2
    2
      database/factories/Common/AddressFactory.php
  66. 6
    10
      database/factories/Common/ContactFactory.php
  67. 2
    2
      database/factories/CompanyFactory.php
  68. 7
    16
      database/factories/Setting/CompanyProfileFactory.php
  69. 4
    8
      database/migrations/2023_09_03_100000_create_accounting_tables.php
  70. 0
    5
      database/migrations/2023_09_14_034800_create_company_profiles_table.php
  71. 0
    2
      database/migrations/2024_11_14_230753_create_adjustments_table.php
  72. 5
    4
      database/migrations/2024_11_19_225812_create_addresses_table.php
  73. 91
    91
      package-lock.json
  74. 9
    0
      resources/css/filament/company/theme.css
  75. 39
    1
      resources/data/lang/es.json
  76. 2
    2
      resources/views/components/report-summary-section.blade.php
  77. 49
    59
      resources/views/filament/company/components/invoice-layouts/classic.blade.php
  78. 47
    57
      resources/views/filament/company/components/invoice-layouts/default.blade.php
  79. 85
    76
      resources/views/filament/company/components/invoice-layouts/modern.blade.php
  80. 0
    35
      resources/views/filament/forms/components/labeled-field.blade.php
  81. 0
    251
      resources/views/filament/forms/components/line-item-repeater.blade.php
  82. 16
    21
      resources/views/filament/infolists/components/document-preview.blade.php
  83. 187
    13
      tests/Feature/Accounting/RecurringInvoiceTest.php
  84. 1
    1
      tests/Feature/CompanySetupAndBehaviorTest.php
  85. 14
    0
      tests/TestCase.php

+ 27
- 0
app/Concerns/HasTabSpecificColumnToggles.php 查看文件

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\Concerns;
4
+
5
+trait HasTabSpecificColumnToggles
6
+{
7
+    public function getTableColumnToggleFormStateSessionKey(): string
8
+    {
9
+        $table = class_basename($this::class);
10
+        $tab = $this->activeTab;
11
+
12
+        return "tables.{$table}_{$tab}_toggled_columns";
13
+    }
14
+
15
+    public function updatedActiveTab(): void
16
+    {
17
+        parent::updatedActiveTab();
18
+
19
+        // Load saved state for new tab or fall back to defaults
20
+        $this->toggledTableColumns = session(
21
+            $this->getTableColumnToggleFormStateSessionKey(),
22
+            $this->getDefaultTableColumnToggleState()
23
+        );
24
+
25
+        $this->updatedToggledTableColumns();
26
+    }
27
+}

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

@@ -81,7 +81,7 @@ trait ManagesLineItems
81 81
 
82 82
     protected function updateDocumentTotals(Model $record, array $data): array
83 83
     {
84
-        $currencyCode = $data['currency_code'] ?? CurrencyAccessor::getDefaultCurrency();
84
+        $currencyCode = $data['currency_code'] ?? $record->currency_code ?? CurrencyAccessor::getDefaultCurrency();
85 85
         $subtotalCents = $record->lineItems()->sum('subtotal');
86 86
         $taxTotalCents = $record->lineItems()->sum('tax_total');
87 87
         $discountTotalCents = $this->calculateDiscountTotal(

+ 24
- 2
app/DTO/ClientDTO.php 查看文件

@@ -25,9 +25,31 @@ readonly class ClientDTO
25 25
             addressLine1: $address?->address_line_1 ?? '',
26 26
             addressLine2: $address?->address_line_2 ?? '',
27 27
             city: $address?->city ?? '',
28
-            state: $address?->state ?? '',
28
+            state: $address?->state?->name ?? '',
29 29
             postalCode: $address?->postal_code ?? '',
30
-            country: $address?->country ?? '',
30
+            country: $address?->country?->name ?? '',
31 31
         );
32 32
     }
33
+
34
+    public function getFormattedAddressHtml(): ?string
35
+    {
36
+        if (empty($this->addressLine1)) {
37
+            return null;
38
+        }
39
+
40
+        $lines = array_filter([
41
+            $this->addressLine1,
42
+            $this->addressLine2,
43
+            implode(', ', array_filter([
44
+                $this->city,
45
+                $this->state,
46
+                $this->postalCode,
47
+            ])),
48
+            $this->country,
49
+        ]);
50
+
51
+        return collect($lines)
52
+            ->map(static fn ($line) => "<p>{$line}</p>")
53
+            ->join('');
54
+    }
33 55
 }

+ 19
- 0
app/DTO/ClientPreviewDTO.php 查看文件

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class ClientPreviewDTO extends ClientDTO
6
+{
7
+    public static function fake(): self
8
+    {
9
+        return new self(
10
+            name: 'John Doe',
11
+            addressLine1: '1234 Elm St',
12
+            addressLine2: 'Suite 123',
13
+            city: 'Springfield',
14
+            state: 'Illinois',
15
+            postalCode: '62701',
16
+            country: 'United States',
17
+        );
18
+    }
19
+}

+ 32
- 7
app/DTO/CompanyDTO.php 查看文件

@@ -8,24 +8,49 @@ readonly class CompanyDTO
8 8
 {
9 9
     public function __construct(
10 10
         public string $name,
11
-        public string $address,
11
+        public string $addressLine1,
12
+        public string $addressLine2,
12 13
         public string $city,
13 14
         public string $state,
14
-        public string $zipCode,
15
+        public string $postalCode,
15 16
         public string $country,
16 17
     ) {}
17 18
 
18 19
     public static function fromModel(Company $company): self
19 20
     {
20 21
         $profile = $company->profile;
22
+        $address = $profile->address ?? null;
21 23
 
22 24
         return new self(
23 25
             name: $company->name,
24
-            address: $profile->address ?? '',
25
-            city: $profile->city?->name ?? '',
26
-            state: $profile->state?->name ?? '',
27
-            zipCode: $profile->zip_code ?? '',
28
-            country: $profile->state?->country->name ?? '',
26
+            addressLine1: $address?->address_line_1 ?? '',
27
+            addressLine2: $address?->address_line_2 ?? '',
28
+            city: $address?->city ?? '',
29
+            state: $address?->state?->name ?? '',
30
+            postalCode: $address?->postal_code ?? '',
31
+            country: $address?->country?->name ?? '',
29 32
         );
30 33
     }
34
+
35
+    public function getFormattedAddressHtml(): ?string
36
+    {
37
+        if (empty($this->addressLine1)) {
38
+            return null;
39
+        }
40
+
41
+        $lines = array_filter([
42
+            $this->addressLine1,
43
+            $this->addressLine2,
44
+            implode(', ', array_filter([
45
+                $this->city,
46
+                $this->state,
47
+                $this->postalCode,
48
+            ])),
49
+            $this->country,
50
+        ]);
51
+
52
+        return collect($lines)
53
+            ->map(static fn ($line) => "<p>{$line}</p>")
54
+            ->join('');
55
+    }
31 56
 }

+ 40
- 0
app/DTO/DocumentColumnLabelDTO.php 查看文件

@@ -0,0 +1,40 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Models\Setting\DocumentDefault;
6
+
7
+readonly class DocumentColumnLabelDTO
8
+{
9
+    public function __construct(
10
+        public string $items = 'Items',
11
+        public string $units = 'Quantity',
12
+        public string $price = 'Price',
13
+        public string $amount = 'Amount',
14
+    ) {}
15
+
16
+    public function toArray(): array
17
+    {
18
+        return [
19
+            'items' => $this->items,
20
+            'units' => $this->units,
21
+            'price' => $this->price,
22
+            'amount' => $this->amount,
23
+        ];
24
+    }
25
+
26
+    public static function fromModel(DocumentDefault $settings): self
27
+    {
28
+        return new self(
29
+            items: $settings->resolveColumnLabel('item_name', 'Items'),
30
+            units: $settings->resolveColumnLabel('unit_name', 'Quantity'),
31
+            price: $settings->resolveColumnLabel('price_name', 'Price'),
32
+            amount: $settings->resolveColumnLabel('amount_name', 'Amount'),
33
+        );
34
+    }
35
+
36
+    public static function getDefaultLabels(): self
37
+    {
38
+        return new self;
39
+    }
40
+}

+ 26
- 10
app/DTO/DocumentDTO.php 查看文件

@@ -2,10 +2,13 @@
2 2
 
3 3
 namespace App\DTO;
4 4
 
5
+use App\Enums\Setting\Font;
5 6
 use App\Models\Accounting\Document;
6 7
 use App\Models\Setting\DocumentDefault;
7 8
 use App\Utilities\Currency\CurrencyAccessor;
8 9
 use App\Utilities\Currency\CurrencyConverter;
10
+use Filament\FontProviders\BunnyFontProvider;
11
+use Illuminate\Contracts\Support\Htmlable;
9 12
 
10 13
 readonly class DocumentDTO
11 14
 {
@@ -13,7 +16,8 @@ readonly class DocumentDTO
13 16
      * @param  LineItemDTO[]  $lineItems
14 17
      */
15 18
     public function __construct(
16
-        public ?string $header,
19
+        public string $header,
20
+        public ?string $subheader,
17 21
         public ?string $footer,
18 22
         public ?string $terms,
19 23
         public ?string $logo,
@@ -23,16 +27,18 @@ readonly class DocumentDTO
23 27
         public string $dueDate,
24 28
         public string $currencyCode,
25 29
         public string $subtotal,
26
-        public string $discount,
27
-        public string $tax,
30
+        public ?string $discount,
31
+        public ?string $tax,
28 32
         public string $total,
29 33
         public string $amountDue,
30 34
         public CompanyDTO $company,
31 35
         public ClientDTO $client,
32 36
         public iterable $lineItems,
33 37
         public DocumentLabelDTO $label,
38
+        public DocumentColumnLabelDTO $columnLabel,
34 39
         public string $accentColor = '#000000',
35 40
         public bool $showLogo = true,
41
+        public Font $font = Font::Inter,
36 42
     ) {}
37 43
 
38 44
     public static function fromModel(Document $document): self
@@ -40,8 +46,11 @@ readonly class DocumentDTO
40 46
         /** @var DocumentDefault $settings */
41 47
         $settings = $document->company->defaultInvoice;
42 48
 
49
+        $currencyCode = $document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
50
+
43 51
         return new self(
44 52
             header: $document->header,
53
+            subheader: $document->subheader,
45 54
             footer: $document->footer,
46 55
             terms: $document->terms,
47 56
             logo: $document->logo,
@@ -49,23 +58,30 @@ readonly class DocumentDTO
49 58
             referenceNumber: $document->referenceNumber(),
50 59
             date: $document->documentDate(),
51 60
             dueDate: $document->dueDate(),
52
-            currencyCode: $document->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
53
-            subtotal: self::formatToMoney($document->subtotal, $document->currency_code),
54
-            discount: self::formatToMoney($document->discount_total, $document->currency_code),
55
-            tax: self::formatToMoney($document->tax_total, $document->currency_code),
56
-            total: self::formatToMoney($document->total, $document->currency_code),
57
-            amountDue: self::formatToMoney($document->amountDue(), $document->currency_code),
61
+            currencyCode: $currencyCode,
62
+            subtotal: self::formatToMoney($document->subtotal, $currencyCode),
63
+            discount: self::formatToMoney($document->discount_total, $currencyCode),
64
+            tax: self::formatToMoney($document->tax_total, $currencyCode),
65
+            total: self::formatToMoney($document->total, $currencyCode),
66
+            amountDue: self::formatToMoney($document->amountDue(), $currencyCode),
58 67
             company: CompanyDTO::fromModel($document->company),
59 68
             client: ClientDTO::fromModel($document->client),
60 69
             lineItems: $document->lineItems->map(fn ($item) => LineItemDTO::fromModel($item)),
61 70
             label: $document->documentType()->getLabels(),
71
+            columnLabel: DocumentColumnLabelDTO::fromModel($settings),
62 72
             accentColor: $settings->accent_color ?? '#000000',
63 73
             showLogo: $settings->show_logo ?? false,
74
+            font: $settings->font ?? Font::Inter,
64 75
         );
65 76
     }
66 77
 
67
-    private static function formatToMoney(float | string $value, ?string $currencyCode): string
78
+    protected static function formatToMoney(float | string $value, ?string $currencyCode): string
68 79
     {
69 80
         return CurrencyConverter::formatToMoney($value, $currencyCode);
70 81
     }
82
+
83
+    public function getFontHtml(): Htmlable
84
+    {
85
+        return app(BunnyFontProvider::class)->getHtml($this->font->getLabel());
86
+    }
71 87
 }

+ 64
- 0
app/DTO/DocumentPreviewDTO.php 查看文件

@@ -0,0 +1,64 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Enums\Accounting\DocumentType;
6
+use App\Enums\Setting\Font;
7
+use App\Enums\Setting\PaymentTerms;
8
+use App\Models\Setting\DocumentDefault;
9
+use App\Utilities\Currency\CurrencyAccessor;
10
+
11
+readonly class DocumentPreviewDTO extends DocumentDTO
12
+{
13
+    public static function fromSettings(DocumentDefault $settings, ?array $data = null): self
14
+    {
15
+        $company = $settings->company;
16
+
17
+        $paymentTerms = PaymentTerms::parse($data['payment_terms']) ?? $settings->payment_terms;
18
+
19
+        return new self(
20
+            header: $data['header'] ?? $settings->header ?? 'Invoice',
21
+            subheader: $data['subheader'] ?? $settings->subheader,
22
+            footer: $data['footer'] ?? $settings->footer,
23
+            terms: $data['terms'] ?? $settings->terms,
24
+            logo: $settings->logo_url,
25
+            number: self::generatePreviewNumber($settings, $data),
26
+            referenceNumber: 'ORD-00001',
27
+            date: $company->locale->date_format->getLabel(),
28
+            dueDate: $paymentTerms->getDueDate($company->locale->date_format->value),
29
+            currencyCode: CurrencyAccessor::getDefaultCurrency(),
30
+            subtotal: self::formatToMoney('1000', null),
31
+            discount: self::formatToMoney('100', null),
32
+            tax: self::formatToMoney('50', null),
33
+            total: self::formatToMoney('950', null),
34
+            amountDue: self::formatToMoney('950', null),
35
+            company: CompanyDTO::fromModel($company),
36
+            client: ClientPreviewDTO::fake(),
37
+            lineItems: LineItemPreviewDTO::fakeItems(),
38
+            label: DocumentType::Invoice->getLabels(),
39
+            columnLabel: self::generateColumnLabels($settings, $data),
40
+            accentColor: $data['accent_color'] ?? $settings->accent_color ?? '#000000',
41
+            showLogo: $data['show_logo'] ?? $settings->show_logo ?? true,
42
+            font: Font::tryFrom($data['font']) ?? $settings->font ?? Font::Inter,
43
+        );
44
+    }
45
+
46
+    protected static function generatePreviewNumber(DocumentDefault $settings, ?array $data): string
47
+    {
48
+        $prefix = $data['number_prefix'] ?? $settings->number_prefix ?? 'INV-';
49
+        $digits = $data['number_digits'] ?? $settings->number_digits ?? 5;
50
+        $next = $data['number_next'] ?? $settings->number_next;
51
+
52
+        return $settings->getNumberNext(padded: true, format: true, prefix: $prefix, digits: $digits, next: $next);
53
+    }
54
+
55
+    protected static function generateColumnLabels(DocumentDefault $settings, ?array $data): DocumentColumnLabelDTO
56
+    {
57
+        return new DocumentColumnLabelDTO(
58
+            items: $settings->resolveColumnLabel('item_name', 'Items', $data),
59
+            units: $settings->resolveColumnLabel('unit_name', 'Quantity', $data),
60
+            price: $settings->resolveColumnLabel('price_name', 'Price', $data),
61
+            amount: $settings->resolveColumnLabel('amount_name', 'Amount', $data),
62
+        );
63
+    }
64
+}

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

@@ -26,7 +26,7 @@ readonly class LineItemDTO
26 26
         );
27 27
     }
28 28
 
29
-    private static function formatToMoney(float | string $value, ?string $currencyCode): string
29
+    protected static function formatToMoney(float | string $value, ?string $currencyCode): string
30 30
     {
31 31
         return CurrencyConverter::formatToMoney($value, $currencyCode);
32 32
     }

+ 33
- 0
app/DTO/LineItemPreviewDTO.php 查看文件

@@ -0,0 +1,33 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class LineItemPreviewDTO extends LineItemDTO
6
+{
7
+    public static function fakeItems(): array
8
+    {
9
+        return [
10
+            new self(
11
+                name: 'Item 1',
12
+                description: 'Sample item description',
13
+                quantity: 2,
14
+                unitPrice: self::formatToMoney(150.00, null),
15
+                subtotal: self::formatToMoney(300.00, null),
16
+            ),
17
+            new self(
18
+                name: 'Item 2',
19
+                description: 'Another sample item description',
20
+                quantity: 3,
21
+                unitPrice: self::formatToMoney(200.00, null),
22
+                subtotal: self::formatToMoney(600.00, null),
23
+            ),
24
+            new self(
25
+                name: 'Item 3',
26
+                description: 'Yet another sample item description',
27
+                quantity: 1,
28
+                unitPrice: self::formatToMoney(180.00, null),
29
+                subtotal: self::formatToMoney(180.00, null),
30
+            ),
31
+        ];
32
+    }
33
+}

+ 5
- 0
app/Enums/Accounting/DayOfMonth.php 查看文件

@@ -100,4 +100,9 @@ enum DayOfMonth: int implements HasLabel
100 100
 
101 101
         return $date->day(min($this->value, $date->daysInMonth));
102 102
     }
103
+
104
+    public function mayExceedMonthLength(): bool
105
+    {
106
+        return $this->value > 28;
107
+    }
103 108
 }

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

@@ -4,16 +4,16 @@ namespace App\Enums\Concerns;
4 4
 
5 5
 trait ParsesEnum
6 6
 {
7
-    public static function parse(string | self | null $value): ?self
7
+    public static function parse(string | self | null $value): ?static
8 8
     {
9
-        if ($value === null) {
9
+        if (! $value) {
10 10
             return null;
11 11
         }
12 12
 
13
-        if ($value instanceof self) {
13
+        if ($value instanceof static) {
14 14
             return $value;
15 15
         }
16 16
 
17
-        return self::from($value);
17
+        return static::tryFrom($value);
18 18
     }
19 19
 }

+ 3
- 0
app/Enums/Setting/PaymentTerms.php 查看文件

@@ -2,10 +2,13 @@
2 2
 
3 3
 namespace App\Enums\Setting;
4 4
 
5
+use App\Enums\Concerns\ParsesEnum;
5 6
 use Filament\Support\Contracts\HasLabel;
6 7
 
7 8
 enum PaymentTerms: string implements HasLabel
8 9
 {
10
+    use ParsesEnum;
11
+
9 12
     case DueUponReceipt = 'due_upon_receipt';
10 13
     case Net7 = 'net_7';
11 14
     case Net10 = 'net_10';

+ 25
- 53
app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php 查看文件

@@ -4,9 +4,8 @@ namespace App\Filament\Company\Clusters\Settings\Pages;
4 4
 
5 5
 use App\Enums\Setting\EntityType;
6 6
 use App\Filament\Company\Clusters\Settings;
7
-use App\Models\Locale\City;
8
-use App\Models\Locale\Country;
9
-use App\Models\Locale\State;
7
+use App\Filament\Forms\Components\AddressFields;
8
+use App\Filament\Forms\Components\Banner;
10 9
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
11 10
 use App\Utilities\Localization\Timezone;
12 11
 use Filament\Actions\Action;
@@ -14,12 +13,11 @@ use Filament\Actions\ActionGroup;
14 13
 use Filament\Forms\Components\Component;
15 14
 use Filament\Forms\Components\FileUpload;
16 15
 use Filament\Forms\Components\Group;
16
+use Filament\Forms\Components\Hidden;
17 17
 use Filament\Forms\Components\Section;
18 18
 use Filament\Forms\Components\Select;
19 19
 use Filament\Forms\Components\TextInput;
20 20
 use Filament\Forms\Form;
21
-use Filament\Forms\Get;
22
-use Filament\Forms\Set;
23 21
 use Filament\Notifications\Notification;
24 22
 use Filament\Pages\Concerns\InteractsWithFormActions;
25 23
 use Filament\Pages\Page;
@@ -95,18 +93,7 @@ class CompanyProfile extends Page
95 93
             return;
96 94
         }
97 95
 
98
-        $countryChanged = $this->record->wasChanged('country');
99
-        $stateChanged = $this->record->wasChanged('state_id');
100
-
101 96
         $this->getSavedNotification()->send();
102
-
103
-        if ($countryChanged || $stateChanged) {
104
-            if ($countryChanged) {
105
-                $this->updateTimezone($this->record->country);
106
-            }
107
-
108
-            $this->getTimezoneChangeNotification()->send();
109
-        }
110 97
     }
111 98
 
112 99
     protected function updateTimezone(string $countryCode): void
@@ -149,6 +136,7 @@ class CompanyProfile extends Page
149 136
         return $form
150 137
             ->schema([
151 138
                 $this->getIdentificationSection(),
139
+                $this->getNeedsAddressCompletionAlert(),
152 140
                 $this->getLocationDetailsSection(),
153 141
                 $this->getLegalAndComplianceSection(),
154 142
             ])
@@ -167,10 +155,9 @@ class CompanyProfile extends Page
167 155
                             ->email()
168 156
                             ->localizeLabel()
169 157
                             ->maxLength(255)
170
-                            ->required(),
158
+                            ->softRequired(),
171 159
                         TextInput::make('phone_number')
172 160
                             ->tel()
173
-                            ->nullable()
174 161
                             ->localizeLabel(),
175 162
                     ])->columns(1),
176 163
                 FileUpload::make('logo')
@@ -196,41 +183,27 @@ class CompanyProfile extends Page
196 183
             ])->columns();
197 184
     }
198 185
 
186
+    protected function getNeedsAddressCompletionAlert(): Component
187
+    {
188
+        return Banner::make('needsAddressCompletion')
189
+            ->warning()
190
+            ->title('Address Information Incomplete')
191
+            ->description('Please complete the required address information for proper business operations.')
192
+            ->visible(fn (CompanyProfileModel $record) => $record->address->isIncomplete())
193
+            ->columnSpanFull();
194
+    }
195
+
199 196
     protected function getLocationDetailsSection(): Component
200 197
     {
201
-        return Section::make('Location Details')
198
+        return Section::make('Address Information')
199
+            ->relationship('address')
202 200
             ->schema([
203
-                Select::make('country')
204
-                    ->searchable()
205
-                    ->localizeLabel()
206
-                    ->live()
207
-                    ->options(Country::getAvailableCountryOptions())
208
-                    ->afterStateUpdated(static function (Set $set) {
209
-                        $set('state_id', null);
210
-                        $set('city_id', null);
211
-                    })
212
-                    ->required(),
213
-                Select::make('state_id')
214
-                    ->localizeLabel('State / Province')
215
-                    ->searchable()
216
-                    ->live()
217
-                    ->options(static fn (Get $get) => State::getStateOptions($get('country')))
218
-                    ->afterStateUpdated(static fn (Set $set) => $set('city_id', null))
219
-                    ->nullable(),
220
-                TextInput::make('address')
221
-                    ->localizeLabel('Street Address')
222
-                    ->maxLength(255)
223
-                    ->nullable(),
224
-                Select::make('city_id')
225
-                    ->localizeLabel('City / Town')
226
-                    ->searchable()
227
-                    ->options(static fn (Get $get) => City::getCityOptions($get('country'), $get('state_id')))
228
-                    ->nullable(),
229
-                TextInput::make('zip_code')
230
-                    ->localizeLabel('Zip / Postal Code')
231
-                    ->maxLength(20)
232
-                    ->nullable(),
233
-            ])->columns();
201
+                Hidden::make('type')
202
+                    ->default('general'),
203
+                AddressFields::make()
204
+                    ->softRequired(),
205
+            ])
206
+            ->columns(2);
234 207
     }
235 208
 
236 209
     protected function getLegalAndComplianceSection(): Component
@@ -240,11 +213,10 @@ class CompanyProfile extends Page
240 213
                 Select::make('entity_type')
241 214
                     ->localizeLabel()
242 215
                     ->options(EntityType::class)
243
-                    ->required(),
216
+                    ->softRequired(),
244 217
                 TextInput::make('tax_id')
245 218
                     ->localizeLabel('Tax ID')
246
-                    ->maxLength(50)
247
-                    ->nullable(),
219
+                    ->maxLength(50),
248 220
             ])->columns();
249 221
     }
250 222
 

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

@@ -130,7 +130,7 @@ class Localization extends Page
130 130
                 Select::make('timezone')
131 131
                     ->softRequired()
132 132
                     ->localizeLabel()
133
-                    ->options(Timezone::getTimezoneOptions(CompanyProfileModel::first()->country))
133
+                    ->options(Timezone::getTimezoneOptions(CompanyProfileModel::first()->address->country_code))
134 134
                     ->searchable(),
135 135
             ])->columns();
136 136
     }

+ 10
- 2
app/Filament/Company/Pages/CreateCompany.php 查看文件

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Pages;
4 4
 
5
+use App\Enums\Common\AddressType;
5 6
 use App\Enums\Setting\EntityType;
6 7
 use App\Models\Company;
7 8
 use App\Models\Locale\Country;
@@ -66,6 +67,8 @@ class CreateCompany extends FilamentCreateCompany
66 67
                     ->live()
67 68
                     ->searchable()
68 69
                     ->options(Country::getAvailableCountryOptions())
70
+                    ->getSearchResultsUsing(fn (string $search): array => Country::getSearchResultsUsing($search))
71
+                    ->getOptionLabelUsing(fn ($value): ?string => Country::find($value)?->name . ' ' . Country::find($value)?->flag)
69 72
                     ->softRequired(),
70 73
                 Select::make('locale.language')
71 74
                     ->label('Language')
@@ -101,10 +104,15 @@ class CreateCompany extends FilamentCreateCompany
101 104
                 'personal_company' => $personalCompany,
102 105
             ]);
103 106
 
104
-            $company->profile()->create([
107
+            $profile = $company->profile()->create([
105 108
                 'email' => $data['profile']['email'],
106 109
                 'entity_type' => $data['profile']['entity_type'],
107
-                'country' => $data['profile']['country'],
110
+            ]);
111
+
112
+            $profile->address()->create([
113
+                'company_id' => $company->id,
114
+                'type' => AddressType::General,
115
+                'country_code' => $data['profile']['country'],
108 116
             ]);
109 117
 
110 118
             $user?->switchCompany($company);

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

@@ -7,9 +7,11 @@ use App\Enums\Accounting\DocumentDiscountMethod;
7 7
 use App\Enums\Accounting\DocumentType;
8 8
 use App\Enums\Accounting\PaymentMethod;
9 9
 use App\Filament\Company\Resources\Purchases\BillResource\Pages;
10
+use App\Filament\Company\Resources\Purchases\VendorResource\RelationManagers\BillsRelationManager;
10 11
 use App\Filament\Forms\Components\CreateCurrencySelect;
11 12
 use App\Filament\Forms\Components\DocumentTotals;
12 13
 use App\Filament\Tables\Actions\ReplicateBulkAction;
14
+use App\Filament\Tables\Columns;
13 15
 use App\Filament\Tables\Filters\DateRangeFilter;
14 16
 use App\Models\Accounting\Adjustment;
15 17
 use App\Models\Accounting\Bill;
@@ -234,11 +236,7 @@ class BillResource extends Resource
234 236
         return $table
235 237
             ->defaultSort('due_date')
236 238
             ->columns([
237
-                Tables\Columns\TextColumn::make('id')
238
-                    ->label('ID')
239
-                    ->sortable()
240
-                    ->toggleable(isToggledHiddenByDefault: true)
241
-                    ->searchable(),
239
+                Columns::id(),
242 240
                 Tables\Columns\TextColumn::make('status')
243 241
                     ->badge()
244 242
                     ->searchable(),
@@ -254,7 +252,9 @@ class BillResource extends Resource
254 252
                     ->searchable()
255 253
                     ->sortable(),
256 254
                 Tables\Columns\TextColumn::make('vendor.name')
257
-                    ->sortable(),
255
+                    ->sortable()
256
+                    ->searchable()
257
+                    ->hiddenOn(BillsRelationManager::class),
258 258
                 Tables\Columns\TextColumn::make('total')
259 259
                     ->currencyWithConversion(static fn (Bill $record) => $record->currency_code)
260 260
                     ->sortable()

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

@@ -6,9 +6,11 @@ use App\Concerns\ManagesLineItems;
6 6
 use App\Concerns\RedirectToListPage;
7 7
 use App\Filament\Company\Resources\Purchases\BillResource;
8 8
 use App\Models\Accounting\Bill;
9
+use App\Models\Common\Vendor;
9 10
 use Filament\Resources\Pages\CreateRecord;
10 11
 use Filament\Support\Enums\MaxWidth;
11 12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12 14
 
13 15
 class CreateBill extends CreateRecord
14 16
 {
@@ -17,6 +19,22 @@ class CreateBill extends CreateRecord
17 19
 
18 20
     protected static string $resource = BillResource::class;
19 21
 
22
+    #[Url(as: 'vendor')]
23
+    public ?int $vendorId = null;
24
+
25
+    public function mount(): void
26
+    {
27
+        parent::mount();
28
+
29
+        if ($this->vendorId) {
30
+            $this->data['vendor_id'] = $this->vendorId;
31
+
32
+            if ($currencyCode = Vendor::find($this->vendorId)?->currency_code) {
33
+                $this->data['currency_code'] = $currencyCode;
34
+            }
35
+        }
36
+    }
37
+
20 38
     public function getMaxContentWidth(): MaxWidth | string | null
21 39
     {
22 40
         return MaxWidth::Full;

+ 57
- 32
app/Filament/Company/Resources/Purchases/VendorResource.php 查看文件

@@ -2,18 +2,23 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Purchases;
4 4
 
5
+use App\Enums\Accounting\BillStatus;
5 6
 use App\Enums\Common\ContractorType;
6 7
 use App\Enums\Common\VendorType;
7 8
 use App\Filament\Company\Resources\Purchases\VendorResource\Pages;
9
+use App\Filament\Forms\Components\AddressFields;
8 10
 use App\Filament\Forms\Components\CreateCurrencySelect;
9 11
 use App\Filament\Forms\Components\CustomSection;
10 12
 use App\Filament\Forms\Components\PhoneBuilder;
13
+use App\Filament\Tables\Columns;
11 14
 use App\Models\Common\Vendor;
15
+use App\Utilities\Currency\CurrencyConverter;
12 16
 use Filament\Forms;
13 17
 use Filament\Forms\Form;
14 18
 use Filament\Resources\Resource;
15 19
 use Filament\Tables;
16 20
 use Filament\Tables\Table;
21
+use Illuminate\Database\Eloquent\Builder;
17 22
 
18 23
 class VendorResource extends Resource
19 24
 {
@@ -41,12 +46,12 @@ class VendorResource extends Resource
41 46
                                     ->columnSpanFull(),
42 47
                                 CreateCurrencySelect::make('currency_code')
43 48
                                     ->nullable()
44
-                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Regular),
49
+                                    ->visible(static fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Regular),
45 50
                                 Forms\Components\Select::make('contractor_type')
46 51
                                     ->label('Contractor Type')
47 52
                                     ->required()
48 53
                                     ->live()
49
-                                    ->visible(fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Contractor)
54
+                                    ->visible(static fn (Forms\Get $get) => VendorType::parse($get('type')) === VendorType::Contractor)
50 55
                                     ->options(ContractorType::class),
51 56
                                 Forms\Components\TextInput::make('ssn')
52 57
                                     ->label('Social Security Number')
@@ -55,7 +60,7 @@ class VendorResource extends Resource
55 60
                                     ->mask('999-99-9999')
56 61
                                     ->stripCharacters('-')
57 62
                                     ->maxLength(11)
58
-                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Individual)
63
+                                    ->visible(static fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Individual)
59 64
                                     ->maxLength(255),
60 65
                                 Forms\Components\TextInput::make('ein')
61 66
                                     ->label('Employer Identification Number')
@@ -64,7 +69,7 @@ class VendorResource extends Resource
64 69
                                     ->mask('99-9999999')
65 70
                                     ->stripCharacters('-')
66 71
                                     ->maxLength(10)
67
-                                    ->visible(fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Business)
72
+                                    ->visible(static fn (Forms\Get $get) => ContractorType::parse($get('contractor_type')) === ContractorType::Business)
68 73
                                     ->maxLength(255),
69 74
                                 Forms\Components\TextInput::make('account_number')
70 75
                                     ->maxLength(255),
@@ -141,29 +146,7 @@ class VendorResource extends Resource
141 146
                     ->schema([
142 147
                         Forms\Components\Hidden::make('type')
143 148
                             ->default('general'),
144
-                        Forms\Components\TextInput::make('address_line_1')
145
-                            ->label('Address Line 1')
146
-                            ->required()
147
-                            ->maxLength(255),
148
-                        Forms\Components\TextInput::make('address_line_2')
149
-                            ->label('Address Line 2')
150
-                            ->maxLength(255),
151
-                        Forms\Components\TextInput::make('city')
152
-                            ->label('City')
153
-                            ->required()
154
-                            ->maxLength(255),
155
-                        Forms\Components\TextInput::make('state')
156
-                            ->label('State')
157
-                            ->required()
158
-                            ->maxLength(255),
159
-                        Forms\Components\TextInput::make('postal_code')
160
-                            ->label('Postal Code / Zip Code')
161
-                            ->required()
162
-                            ->maxLength(255),
163
-                        Forms\Components\TextInput::make('country')
164
-                            ->label('Country')
165
-                            ->required()
166
-                            ->maxLength(255),
149
+                        AddressFields::make(),
167 150
                     ])
168 151
                     ->columns(2),
169 152
             ]);
@@ -173,24 +156,65 @@ class VendorResource extends Resource
173 156
     {
174 157
         return $table
175 158
             ->columns([
159
+                Columns::id(),
176 160
                 Tables\Columns\TextColumn::make('type')
177 161
                     ->badge()
178
-                    ->searchable(),
162
+                    ->searchable()
163
+                    ->sortable(),
179 164
                 Tables\Columns\TextColumn::make('name')
180 165
                     ->searchable()
181
-                    ->description(fn (Vendor $vendor) => $vendor->contact?->full_name),
166
+                    ->sortable()
167
+                    ->description(static fn (Vendor $vendor) => $vendor->contact?->full_name),
182 168
                 Tables\Columns\TextColumn::make('contact.email')
183 169
                     ->label('Email')
184 170
                     ->searchable(),
185
-                Tables\Columns\TextColumn::make('primaryContact.phones')
171
+                Tables\Columns\TextColumn::make('contact.first_available_phone')
186 172
                     ->label('Phone')
187
-                    ->state(fn (Vendor $vendor) => $vendor->contact?->first_available_phone),
173
+                    ->state(static fn (Vendor $vendor) => $vendor->contact?->first_available_phone),
174
+                Tables\Columns\TextColumn::make('address.address_string')
175
+                    ->label('Address')
176
+                    ->searchable()
177
+                    ->toggleable(isToggledHiddenByDefault: true)
178
+                    ->listWithLineBreaks(),
179
+                Tables\Columns\TextColumn::make('payable_balance')
180
+                    ->label('Payable Balance')
181
+                    ->getStateUsing(function (Vendor $vendor) {
182
+                        return $vendor->bills()
183
+                            ->outstanding()
184
+                            ->get()
185
+                            ->sumMoneyInDefaultCurrency('amount_due');
186
+                    })
187
+                    ->coloredDescription(function (Vendor $vendor) {
188
+                        $overdue = $vendor->bills()
189
+                            ->where('status', BillStatus::Overdue)
190
+                            ->get()
191
+                            ->sumMoneyInDefaultCurrency('amount_due');
192
+
193
+                        if ($overdue <= 0) {
194
+                            return null;
195
+                        }
196
+
197
+                        $formattedOverdue = CurrencyConverter::formatCentsToMoney($overdue);
198
+
199
+                        return "Overdue: {$formattedOverdue}";
200
+                    })
201
+                    ->sortable(query: function (Builder $query, string $direction) {
202
+                        return $query
203
+                            ->withSum(['bills' => fn (Builder $query) => $query->outstanding()], 'amount_due')
204
+                            ->orderBy('bills_sum_amount_due', $direction);
205
+                    })
206
+                    ->currency(convert: false)
207
+                    ->alignEnd(),
208
+
188 209
             ])
189 210
             ->filters([
190 211
                 //
191 212
             ])
192 213
             ->actions([
193
-                Tables\Actions\EditAction::make(),
214
+                Tables\Actions\ActionGroup::make([
215
+                    Tables\Actions\EditAction::make(),
216
+                    Tables\Actions\ViewAction::make(),
217
+                ]),
194 218
             ])
195 219
             ->bulkActions([
196 220
                 Tables\Actions\BulkActionGroup::make([
@@ -211,6 +235,7 @@ class VendorResource extends Resource
211 235
         return [
212 236
             'index' => Pages\ListVendors::route('/'),
213 237
             'create' => Pages\CreateVendor::route('/create'),
238
+            'view' => Pages\ViewVendor::route('/{record}'),
214 239
             'edit' => Pages\EditVendor::route('/{record}/edit'),
215 240
         ];
216 241
     }

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

@@ -0,0 +1,62 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Purchases\VendorResource;
6
+use App\Filament\Company\Resources\Purchases\VendorResource\RelationManagers;
7
+use Filament\Infolists\Components\Section;
8
+use Filament\Infolists\Components\TextEntry;
9
+use Filament\Infolists\Infolist;
10
+use Filament\Resources\Pages\ViewRecord;
11
+
12
+class ViewVendor extends ViewRecord
13
+{
14
+    protected static string $resource = VendorResource::class;
15
+
16
+    public function getRelationManagers(): array
17
+    {
18
+        return [
19
+            RelationManagers\BillsRelationManager::class,
20
+        ];
21
+    }
22
+
23
+    public function getTitle(): string
24
+    {
25
+        return $this->record->name;
26
+    }
27
+
28
+    protected function getHeaderWidgets(): array
29
+    {
30
+        return [
31
+            VendorResource\Widgets\BillOverview::class,
32
+        ];
33
+    }
34
+
35
+    public function infolist(Infolist $infolist): Infolist
36
+    {
37
+        return $infolist
38
+            ->schema([
39
+                Section::make('General')
40
+                    ->columns()
41
+                    ->schema([
42
+                        TextEntry::make('contact.full_name')
43
+                            ->label('Contact'),
44
+                        TextEntry::make('contact.email')
45
+                            ->label('Email'),
46
+                        TextEntry::make('contact.first_available_phone')
47
+                            ->label('Primary Phone'),
48
+                        TextEntry::make('website')
49
+                            ->label('Website')
50
+                            ->url(static fn ($state) => $state, true),
51
+                    ]),
52
+                Section::make('Additional Details')
53
+                    ->columns()
54
+                    ->schema([
55
+                        TextEntry::make('address.address_string')
56
+                            ->label('Billing Address')
57
+                            ->listWithLineBreaks(),
58
+                        TextEntry::make('notes'),
59
+                    ]),
60
+            ]);
61
+    }
62
+}

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

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Purchases\VendorResource\RelationManagers;
4
+
5
+use App\Filament\Company\Resources\Purchases\BillResource;
6
+use Filament\Resources\RelationManagers\RelationManager;
7
+use Filament\Tables;
8
+use Filament\Tables\Table;
9
+
10
+class BillsRelationManager extends RelationManager
11
+{
12
+    protected static string $relationship = 'bills';
13
+
14
+    protected static bool $isLazy = false;
15
+
16
+    public function isReadOnly(): bool
17
+    {
18
+        return false;
19
+    }
20
+
21
+    public function table(Table $table): Table
22
+    {
23
+        return BillResource::table($table)
24
+            ->headerActions([
25
+                Tables\Actions\CreateAction::make()
26
+                    ->url(BillResource\Pages\CreateBill::getUrl(['vendor' => $this->getOwnerRecord()->getKey()])),
27
+            ]);
28
+    }
29
+}

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

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

+ 93
- 52
app/Filament/Company/Resources/Sales/ClientResource.php 查看文件

@@ -3,15 +3,22 @@
3 3
 namespace App\Filament\Company\Resources\Sales;
4 4
 
5 5
 use App\Filament\Company\Resources\Sales\ClientResource\Pages;
6
+use App\Filament\Forms\Components\AddressFields;
6 7
 use App\Filament\Forms\Components\CreateCurrencySelect;
7 8
 use App\Filament\Forms\Components\CustomSection;
8 9
 use App\Filament\Forms\Components\PhoneBuilder;
10
+use App\Filament\Tables\Columns;
11
+use App\Models\Common\Address;
9 12
 use App\Models\Common\Client;
13
+use App\Utilities\Currency\CurrencyConverter;
10 14
 use Filament\Forms;
11 15
 use Filament\Forms\Form;
16
+use Filament\Forms\Get;
17
+use Filament\Forms\Set;
12 18
 use Filament\Resources\Resource;
13 19
 use Filament\Tables;
14 20
 use Filament\Tables\Table;
21
+use Illuminate\Database\Eloquent\Builder;
15 22
 
16 23
 class ClientResource extends Resource
17 24
 {
@@ -170,45 +177,27 @@ class ClientResource extends Resource
170 177
                         CreateCurrencySelect::make('currency_code'),
171 178
                         CustomSection::make('Billing Address')
172 179
                             ->relationship('billingAddress')
180
+                            ->saveRelationshipsUsing(null)
181
+                            ->dehydrated(true)
173 182
                             ->contained(false)
174 183
                             ->schema([
175 184
                                 Forms\Components\Hidden::make('type')
176 185
                                     ->default('billing'),
177
-                                Forms\Components\TextInput::make('address_line_1')
178
-                                    ->label('Address Line 1')
179
-                                    ->required()
180
-                                    ->maxLength(255),
181
-                                Forms\Components\TextInput::make('address_line_2')
182
-                                    ->label('Address Line 2')
183
-                                    ->maxLength(255),
184
-                                Forms\Components\TextInput::make('city')
185
-                                    ->label('City')
186
-                                    ->required()
187
-                                    ->maxLength(255),
188
-                                Forms\Components\TextInput::make('state')
189
-                                    ->label('State')
190
-                                    ->required()
191
-                                    ->maxLength(255),
192
-                                Forms\Components\TextInput::make('postal_code')
193
-                                    ->label('Postal Code / Zip Code')
194
-                                    ->required()
195
-                                    ->maxLength(255),
196
-                                Forms\Components\TextInput::make('country')
197
-                                    ->label('Country')
198
-                                    ->required()
199
-                                    ->maxLength(255),
186
+                                AddressFields::make(),
200 187
                             ])->columns(),
201 188
                     ])
202 189
                     ->columns(1),
203 190
                 Forms\Components\Section::make('Shipping')
204 191
                     ->relationship('shippingAddress')
192
+                    ->saveRelationshipsUsing(null)
193
+                    ->dehydrated(true)
205 194
                     ->schema([
195
+                        Forms\Components\Hidden::make('type')
196
+                            ->default('shipping'),
206 197
                         Forms\Components\TextInput::make('recipient')
207 198
                             ->label('Recipient')
208 199
                             ->required()
209 200
                             ->maxLength(255),
210
-                        Forms\Components\Hidden::make('type')
211
-                            ->default('shipping'),
212 201
                         Forms\Components\TextInput::make('phone')
213 202
                             ->label('Phone')
214 203
                             ->required()
@@ -216,29 +205,39 @@ class ClientResource extends Resource
216 205
                         CustomSection::make('Shipping Address')
217 206
                             ->contained(false)
218 207
                             ->schema([
219
-                                Forms\Components\TextInput::make('address_line_1')
220
-                                    ->label('Address Line 1')
221
-                                    ->required()
222
-                                    ->maxLength(255),
223
-                                Forms\Components\TextInput::make('address_line_2')
224
-                                    ->label('Address Line 2')
225
-                                    ->maxLength(255),
226
-                                Forms\Components\TextInput::make('city')
227
-                                    ->label('City')
228
-                                    ->required()
229
-                                    ->maxLength(255),
230
-                                Forms\Components\TextInput::make('state')
231
-                                    ->label('State')
232
-                                    ->required()
233
-                                    ->maxLength(255),
234
-                                Forms\Components\TextInput::make('postal_code')
235
-                                    ->label('Postal Code / Zip Code')
236
-                                    ->required()
237
-                                    ->maxLength(255),
238
-                                Forms\Components\TextInput::make('country')
239
-                                    ->label('Country')
240
-                                    ->required()
241
-                                    ->maxLength(255),
208
+                                Forms\Components\Checkbox::make('same_as_billing')
209
+                                    ->label('Same as Billing Address')
210
+                                    ->live()
211
+                                    ->afterStateHydrated(function (?Address $record, Forms\Components\Checkbox $component) {
212
+                                        if (! $record || $record->parent_address_id) {
213
+                                            return $component->state(true);
214
+                                        }
215
+
216
+                                        return $component->state(false);
217
+                                    })
218
+                                    ->afterStateUpdated(static function (Get $get, Set $set, $state) {
219
+                                        if ($state) {
220
+                                            return;
221
+                                        }
222
+
223
+                                        $billingAddress = $get('../billingAddress');
224
+
225
+                                        $fieldsToSync = [
226
+                                            'address_line_1',
227
+                                            'address_line_2',
228
+                                            'country',
229
+                                            'state_id',
230
+                                            'city',
231
+                                            'postal_code',
232
+                                        ];
233
+
234
+                                        foreach ($fieldsToSync as $field) {
235
+                                            $set($field, $billingAddress[$field]);
236
+                                        }
237
+                                    })
238
+                                    ->columnSpanFull(),
239
+                                AddressFields::make()
240
+                                    ->visible(static fn (Get $get) => ! $get('same_as_billing')),
242 241
                                 Forms\Components\Textarea::make('notes')
243 242
                                     ->label('Delivery Instructions')
244 243
                                     ->maxLength(255)
@@ -252,21 +251,62 @@ class ClientResource extends Resource
252 251
     {
253 252
         return $table
254 253
             ->columns([
254
+                Columns::id(),
255 255
                 Tables\Columns\TextColumn::make('name')
256 256
                     ->searchable()
257
-                    ->description(fn (Client $client) => $client->primaryContact->full_name),
257
+                    ->sortable()
258
+                    ->description(static fn (Client $client) => $client->primaryContact->full_name),
258 259
                 Tables\Columns\TextColumn::make('primaryContact.email')
259 260
                     ->label('Email')
260
-                    ->searchable(),
261
+                    ->searchable()
262
+                    ->toggleable(),
261 263
                 Tables\Columns\TextColumn::make('primaryContact.phones')
262 264
                     ->label('Phone')
263
-                    ->state(fn (Client $client) => $client->primaryContact->first_available_phone),
265
+                    ->toggleable()
266
+                    ->state(static fn (Client $client) => $client->primaryContact->first_available_phone),
267
+                Tables\Columns\TextColumn::make('billingAddress.address_string')
268
+                    ->label('Billing Address')
269
+                    ->searchable()
270
+                    ->toggleable(isToggledHiddenByDefault: true)
271
+                    ->listWithLineBreaks(),
272
+                Tables\Columns\TextColumn::make('balance')
273
+                    ->label('Balance')
274
+                    ->getStateUsing(function (Client $client) {
275
+                        return $client->invoices()
276
+                            ->unpaid()
277
+                            ->get()
278
+                            ->sumMoneyInDefaultCurrency('amount_due');
279
+                    })
280
+                    ->coloredDescription(function (Client $client) {
281
+                        $overdue = $client->invoices()
282
+                            ->overdue()
283
+                            ->get()
284
+                            ->sumMoneyInDefaultCurrency('amount_due');
285
+
286
+                        if ($overdue <= 0) {
287
+                            return null;
288
+                        }
289
+
290
+                        $formattedOverdue = CurrencyConverter::formatCentsToMoney($overdue);
291
+
292
+                        return "Overdue: {$formattedOverdue}";
293
+                    })
294
+                    ->sortable(query: function (Builder $query, string $direction) {
295
+                        return $query
296
+                            ->withSum(['invoices' => fn (Builder $query) => $query->unpaid()], 'amount_due')
297
+                            ->orderBy('invoices_sum_amount_due', $direction);
298
+                    })
299
+                    ->currency(convert: false)
300
+                    ->alignEnd(),
264 301
             ])
265 302
             ->filters([
266 303
                 //
267 304
             ])
268 305
             ->actions([
269
-                Tables\Actions\EditAction::make(),
306
+                Tables\Actions\ActionGroup::make([
307
+                    Tables\Actions\EditAction::make(),
308
+                    Tables\Actions\ViewAction::make(),
309
+                ]),
270 310
             ])
271 311
             ->bulkActions([
272 312
                 Tables\Actions\BulkActionGroup::make([
@@ -287,6 +327,7 @@ class ClientResource extends Resource
287 327
         return [
288 328
             'index' => Pages\ListClients::route('/'),
289 329
             'create' => Pages\CreateClient::route('/create'),
330
+            'view' => Pages\ViewClient::route('/{record}'),
290 331
             'edit' => Pages\EditClient::route('/{record}/edit'),
291 332
         ];
292 333
     }

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

@@ -3,9 +3,12 @@
3 3
 namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4 4
 
5 5
 use App\Concerns\RedirectToListPage;
6
+use App\Enums\Common\AddressType;
6 7
 use App\Filament\Company\Resources\Sales\ClientResource;
8
+use App\Models\Common\Client;
7 9
 use Filament\Resources\Pages\CreateRecord;
8 10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
9 12
 
10 13
 class CreateClient extends CreateRecord
11 14
 {
@@ -17,4 +20,53 @@ class CreateClient extends CreateRecord
17 20
     {
18 21
         return MaxWidth::FiveExtraLarge;
19 22
     }
23
+
24
+    protected function handleRecordCreation(array $data): Model
25
+    {
26
+        /** @var Client $record */
27
+        $record = parent::handleRecordCreation($data);
28
+
29
+        // Create billing address first
30
+        $billingAddress = $record->addresses()->create([
31
+            ...$data['billingAddress'],
32
+            'type' => AddressType::Billing,
33
+        ]);
34
+
35
+        // Create shipping address with reference to billing if needed
36
+        $shippingData = $data['shippingAddress'];
37
+
38
+        $shippingAddress = [
39
+            'type' => AddressType::Shipping,
40
+            'recipient' => $shippingData['recipient'],
41
+            'phone' => $shippingData['phone'],
42
+            'notes' => $shippingData['notes'],
43
+        ];
44
+
45
+        if ($shippingData['same_as_billing']) {
46
+            $shippingAddress = [
47
+                ...$shippingAddress,
48
+                'parent_address_id' => $billingAddress->id,
49
+                'address_line_1' => $billingAddress->address_line_1,
50
+                'address_line_2' => $billingAddress->address_line_2,
51
+                'country_code' => $billingAddress->country_code,
52
+                'state_id' => $billingAddress->state_id,
53
+                'city' => $billingAddress->city,
54
+                'postal_code' => $billingAddress->postal_code,
55
+            ];
56
+        } else {
57
+            $shippingAddress = [
58
+                ...$shippingAddress,
59
+                'address_line_1' => $shippingData['address_line_1'],
60
+                'address_line_2' => $shippingData['address_line_2'],
61
+                'country_code' => $shippingData['country_code'],
62
+                'state_id' => $shippingData['state_id'],
63
+                'city' => $shippingData['city'],
64
+                'postal_code' => $shippingData['postal_code'],
65
+            ];
66
+        }
67
+
68
+        $record->addresses()->create($shippingAddress);
69
+
70
+        return $record;
71
+    }
20 72
 }

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

@@ -4,9 +4,11 @@ namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4 4
 
5 5
 use App\Concerns\RedirectToListPage;
6 6
 use App\Filament\Company\Resources\Sales\ClientResource;
7
+use App\Models\Common\Client;
7 8
 use Filament\Actions;
8 9
 use Filament\Resources\Pages\EditRecord;
9 10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
10 12
 
11 13
 class EditClient extends EditRecord
12 14
 {
@@ -25,4 +27,52 @@ class EditClient extends EditRecord
25 27
     {
26 28
         return MaxWidth::FiveExtraLarge;
27 29
     }
30
+
31
+    protected function handleRecordUpdate(Model $record, array $data): Model
32
+    {
33
+        /** @var Client $record */
34
+        $record = parent::handleRecordUpdate($record, $data);
35
+
36
+        // Update billing address
37
+        $billingAddress = $record->billingAddress;
38
+        $billingAddress->update($data['billingAddress']);
39
+
40
+        // Update shipping address
41
+        $shippingAddress = $record->shippingAddress;
42
+        $shippingData = $data['shippingAddress'];
43
+
44
+        $shippingUpdateData = [
45
+            'recipient' => $shippingData['recipient'],
46
+            'phone' => $shippingData['phone'],
47
+            'notes' => $shippingData['notes'],
48
+        ];
49
+
50
+        if ($shippingData['same_as_billing']) {
51
+            $shippingUpdateData = [
52
+                ...$shippingUpdateData,
53
+                'parent_address_id' => $billingAddress->id,
54
+                'address_line_1' => $billingAddress->address_line_1,
55
+                'address_line_2' => $billingAddress->address_line_2,
56
+                'country_code' => $billingAddress->country_code,
57
+                'state_id' => $billingAddress->state_id,
58
+                'city' => $billingAddress->city,
59
+                'postal_code' => $billingAddress->postal_code,
60
+            ];
61
+        } else {
62
+            $shippingUpdateData = [
63
+                ...$shippingUpdateData,
64
+                'parent_address_id' => null,
65
+                'address_line_1' => $shippingData['address_line_1'],
66
+                'address_line_2' => $shippingData['address_line_2'],
67
+                'country_code' => $shippingData['country_code'],
68
+                'state_id' => $shippingData['state_id'],
69
+                'city' => $shippingData['city'],
70
+                'postal_code' => $shippingData['postal_code'],
71
+            ];
72
+        }
73
+
74
+        $shippingAddress->update($shippingUpdateData);
75
+
76
+        return $record;
77
+    }
28 78
 }

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

@@ -0,0 +1,69 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
+
5
+use App\Filament\Company\Resources\Sales\ClientResource;
6
+use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers;
7
+use Filament\Infolists\Components\Section;
8
+use Filament\Infolists\Components\TextEntry;
9
+use Filament\Infolists\Infolist;
10
+use Filament\Resources\Pages\ViewRecord;
11
+use Illuminate\Contracts\Support\Htmlable;
12
+
13
+class ViewClient extends ViewRecord
14
+{
15
+    protected static string $resource = ClientResource::class;
16
+
17
+    public function getRelationManagers(): array
18
+    {
19
+        return [
20
+            RelationManagers\InvoicesRelationManager::class,
21
+            RelationManagers\RecurringInvoicesRelationManager::class,
22
+            RelationManagers\EstimatesRelationManager::class,
23
+        ];
24
+    }
25
+
26
+    public function getTitle(): string | Htmlable
27
+    {
28
+        return $this->record->name;
29
+    }
30
+
31
+    protected function getHeaderWidgets(): array
32
+    {
33
+        return [
34
+            ClientResource\Widgets\InvoiceOverview::class,
35
+        ];
36
+    }
37
+
38
+    public function infolist(Infolist $infolist): Infolist
39
+    {
40
+        return $infolist
41
+            ->schema([
42
+                Section::make('General')
43
+                    ->columns()
44
+                    ->schema([
45
+                        TextEntry::make('primaryContact.full_name')
46
+                            ->label('Primary Contact'),
47
+                        TextEntry::make('primaryContact.email')
48
+                            ->label('Primary Email'),
49
+                        TextEntry::make('primaryContact.first_available_phone')
50
+                            ->label('Primary Phone'),
51
+                        TextEntry::make('website')
52
+                            ->label('Website')
53
+                            ->url(static fn ($state) => $state, true),
54
+                    ]),
55
+                Section::make('Additional Details')
56
+                    ->columns()
57
+                    ->schema([
58
+                        TextEntry::make('billingAddress.address_string')
59
+                            ->label('Billing Address')
60
+                            ->listWithLineBreaks(),
61
+                        TextEntry::make('shippingAddress.address_string')
62
+                            ->label('Shipping Address')
63
+                            ->listWithLineBreaks(),
64
+                        TextEntry::make('notes')
65
+                            ->label('Delivery Instructions'),
66
+                    ]),
67
+            ]);
68
+    }
69
+}

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

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\RelationManagers;
4
+
5
+use App\Filament\Company\Resources\Sales\EstimateResource;
6
+use Filament\Resources\RelationManagers\RelationManager;
7
+use Filament\Tables;
8
+use Filament\Tables\Table;
9
+
10
+class EstimatesRelationManager extends RelationManager
11
+{
12
+    protected static string $relationship = 'estimates';
13
+
14
+    protected static bool $isLazy = false;
15
+
16
+    public function isReadOnly(): bool
17
+    {
18
+        return false;
19
+    }
20
+
21
+    public function table(Table $table): Table
22
+    {
23
+        return EstimateResource::table($table)
24
+            ->headerActions([
25
+                Tables\Actions\CreateAction::make()
26
+                    ->url(EstimateResource\Pages\CreateEstimate::getUrl(['client' => $this->getOwnerRecord()->getKey()])),
27
+            ]);
28
+    }
29
+}

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

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\RelationManagers;
4
+
5
+use App\Filament\Company\Resources\Sales\InvoiceResource;
6
+use Filament\Resources\RelationManagers\RelationManager;
7
+use Filament\Tables;
8
+use Filament\Tables\Table;
9
+
10
+class InvoicesRelationManager extends RelationManager
11
+{
12
+    protected static string $relationship = 'invoices';
13
+
14
+    protected static bool $isLazy = false;
15
+
16
+    public function isReadOnly(): bool
17
+    {
18
+        return false;
19
+    }
20
+
21
+    public function table(Table $table): Table
22
+    {
23
+        return InvoiceResource::table($table)
24
+            ->headerActions([
25
+                Tables\Actions\CreateAction::make()
26
+                    ->url(InvoiceResource\Pages\CreateInvoice::getUrl(['client' => $this->getOwnerRecord()->getKey()])),
27
+            ]);
28
+    }
29
+}

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

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\ClientResource\RelationManagers;
4
+
5
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
6
+use Filament\Resources\RelationManagers\RelationManager;
7
+use Filament\Tables;
8
+use Filament\Tables\Table;
9
+
10
+class RecurringInvoicesRelationManager extends RelationManager
11
+{
12
+    protected static string $relationship = 'recurringInvoices';
13
+
14
+    protected static bool $isLazy = false;
15
+
16
+    public function isReadOnly(): bool
17
+    {
18
+        return false;
19
+    }
20
+
21
+    public function table(Table $table): Table
22
+    {
23
+        return RecurringInvoiceResource::table($table)
24
+            ->headerActions([
25
+                Tables\Actions\CreateAction::make()
26
+                    ->url(RecurringInvoiceResource\Pages\CreateRecurringInvoice::getUrl(['client' => $this->getOwnerRecord()->getKey()])),
27
+            ]);
28
+    }
29
+}

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

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

+ 3
- 6
app/Filament/Company/Resources/Sales/EstimateResource.php 查看文件

@@ -10,6 +10,7 @@ use App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
10 10
 use App\Filament\Forms\Components\CreateCurrencySelect;
11 11
 use App\Filament\Forms\Components\DocumentTotals;
12 12
 use App\Filament\Tables\Actions\ReplicateBulkAction;
13
+use App\Filament\Tables\Columns;
13 14
 use App\Filament\Tables\Filters\DateRangeFilter;
14 15
 use App\Models\Accounting\Adjustment;
15 16
 use App\Models\Accounting\Estimate;
@@ -286,11 +287,7 @@ class EstimateResource extends Resource
286 287
         return $table
287 288
             ->defaultSort('expiration_date')
288 289
             ->columns([
289
-                Tables\Columns\TextColumn::make('id')
290
-                    ->label('ID')
291
-                    ->sortable()
292
-                    ->toggleable(isToggledHiddenByDefault: true)
293
-                    ->searchable(),
290
+                Columns::id(),
294 291
                 Tables\Columns\TextColumn::make('status')
295 292
                     ->badge()
296 293
                     ->searchable(),
@@ -311,7 +308,7 @@ class EstimateResource extends Resource
311 308
                 Tables\Columns\TextColumn::make('total')
312 309
                     ->currencyWithConversion(static fn (Estimate $record) => $record->currency_code)
313 310
                     ->sortable()
314
-                    ->toggleable(),
311
+                    ->alignEnd(),
315 312
             ])
316 313
             ->filters([
317 314
                 Tables\Filters\SelectFilter::make('client')

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

@@ -6,9 +6,11 @@ use App\Concerns\ManagesLineItems;
6 6
 use App\Concerns\RedirectToListPage;
7 7
 use App\Filament\Company\Resources\Sales\EstimateResource;
8 8
 use App\Models\Accounting\Estimate;
9
+use App\Models\Common\Client;
9 10
 use Filament\Resources\Pages\CreateRecord;
10 11
 use Filament\Support\Enums\MaxWidth;
11 12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12 14
 
13 15
 class CreateEstimate extends CreateRecord
14 16
 {
@@ -17,6 +19,22 @@ class CreateEstimate extends CreateRecord
17 19
 
18 20
     protected static string $resource = EstimateResource::class;
19 21
 
22
+    #[Url(as: 'client')]
23
+    public ?int $clientId = null;
24
+
25
+    public function mount(): void
26
+    {
27
+        parent::mount();
28
+
29
+        if ($this->clientId) {
30
+            $this->data['client_id'] = $this->clientId;
31
+
32
+            if ($currencyCode = Client::find($this->clientId)?->currency_code) {
33
+                $this->data['currency_code'] = $currencyCode;
34
+            }
35
+        }
36
+    }
37
+
20 38
     public function getMaxContentWidth(): MaxWidth | string | null
21 39
     {
22 40
         return MaxWidth::Full;

+ 27
- 16
app/Filament/Company/Resources/Sales/InvoiceResource.php 查看文件

@@ -7,12 +7,14 @@ use App\Enums\Accounting\DocumentDiscountMethod;
7 7
 use App\Enums\Accounting\DocumentType;
8 8
 use App\Enums\Accounting\InvoiceStatus;
9 9
 use App\Enums\Accounting\PaymentMethod;
10
+use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\InvoicesRelationManager;
10 11
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
11 12
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
12 13
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
13 14
 use App\Filament\Forms\Components\CreateCurrencySelect;
14 15
 use App\Filament\Forms\Components\DocumentTotals;
15 16
 use App\Filament\Tables\Actions\ReplicateBulkAction;
17
+use App\Filament\Tables\Columns;
16 18
 use App\Filament\Tables\Filters\DateRangeFilter;
17 19
 use App\Models\Accounting\Adjustment;
18 20
 use App\Models\Accounting\Invoice;
@@ -114,7 +116,10 @@ class InvoiceResource extends Resource
114 116
                                             $set('currency_code', $currencyCode);
115 117
                                         }
116 118
                                     }),
117
-                                CreateCurrencySelect::make('currency_code'),
119
+                                CreateCurrencySelect::make('currency_code')
120
+                                    ->disabled(function (?Invoice $record) {
121
+                                        return $record?->hasPayments();
122
+                                    }),
118 123
                             ]),
119 124
                             Forms\Components\Group::make([
120 125
                                 Forms\Components\TextInput::make('invoice_number')
@@ -297,27 +302,26 @@ class InvoiceResource extends Resource
297 302
         return $table
298 303
             ->defaultSort('due_date')
299 304
             ->modifyQueryUsing(function (Builder $query, Tables\Contracts\HasTable $livewire) {
300
-                $recurringInvoiceId = $livewire->recurringInvoice;
305
+                if (property_exists($livewire, 'recurringInvoice')) {
306
+                    $recurringInvoiceId = $livewire->recurringInvoice;
301 307
 
302
-                if (! empty($recurringInvoiceId)) {
303
-                    $query->where('recurring_invoice_id', $recurringInvoiceId);
308
+                    if (! empty($recurringInvoiceId)) {
309
+                        $query->where('recurring_invoice_id', $recurringInvoiceId);
310
+                    }
304 311
                 }
305 312
 
306 313
                 return $query;
307 314
             })
308 315
             ->columns([
309
-                Tables\Columns\TextColumn::make('id')
310
-                    ->label('ID')
311
-                    ->sortable()
312
-                    ->toggleable(isToggledHiddenByDefault: true)
313
-                    ->searchable(),
316
+                Columns::id(),
314 317
                 Tables\Columns\TextColumn::make('status')
315 318
                     ->badge()
316 319
                     ->searchable(),
317 320
                 Tables\Columns\TextColumn::make('due_date')
318 321
                     ->label('Due')
319 322
                     ->asRelativeDay()
320
-                    ->sortable(),
323
+                    ->sortable()
324
+                    ->hideOnTabs(['draft']),
321 325
                 Tables\Columns\TextColumn::make('date')
322 326
                     ->date()
323 327
                     ->sortable(),
@@ -330,20 +334,25 @@ class InvoiceResource extends Resource
330 334
                     ->sortable(),
331 335
                 Tables\Columns\TextColumn::make('client.name')
332 336
                     ->sortable()
333
-                    ->searchable(),
337
+                    ->searchable()
338
+                    ->hiddenOn(InvoicesRelationManager::class),
334 339
                 Tables\Columns\TextColumn::make('total')
335 340
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
336 341
                     ->sortable()
337
-                    ->toggleable(),
342
+                    ->toggleable()
343
+                    ->alignEnd(),
338 344
                 Tables\Columns\TextColumn::make('amount_paid')
339 345
                     ->label('Amount Paid')
340 346
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
341 347
                     ->sortable()
342
-                    ->toggleable(),
348
+                    ->alignEnd()
349
+                    ->showOnTabs(['unpaid']),
343 350
                 Tables\Columns\TextColumn::make('amount_due')
344 351
                     ->label('Amount Due')
345 352
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
346
-                    ->sortable(),
353
+                    ->sortable()
354
+                    ->alignEnd()
355
+                    ->hideOnTabs(['draft']),
347 356
             ])
348 357
             ->filters([
349 358
                 Tables\Filters\SelectFilter::make('client')
@@ -386,8 +395,10 @@ class InvoiceResource extends Resource
386 395
             ])
387 396
             ->actions([
388 397
                 Tables\Actions\ActionGroup::make([
389
-                    Tables\Actions\EditAction::make(),
390
-                    Tables\Actions\ViewAction::make(),
398
+                    Tables\Actions\EditAction::make()
399
+                        ->url(static fn (Invoice $record) => Pages\EditInvoice::getUrl(['record' => $record])),
400
+                    Tables\Actions\ViewAction::make()
401
+                        ->url(static fn (Invoice $record) => Pages\ViewInvoice::getUrl(['record' => $record])),
391 402
                     Tables\Actions\DeleteAction::make(),
392 403
                     Invoice::getReplicateAction(Tables\Actions\ReplicateAction::class),
393 404
                     Invoice::getApproveDraftAction(Tables\Actions\Action::class),

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

@@ -6,9 +6,11 @@ use App\Concerns\ManagesLineItems;
6 6
 use App\Concerns\RedirectToListPage;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8 8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Common\Client;
9 10
 use Filament\Resources\Pages\CreateRecord;
10 11
 use Filament\Support\Enums\MaxWidth;
11 12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12 14
 
13 15
 class CreateInvoice extends CreateRecord
14 16
 {
@@ -17,6 +19,22 @@ class CreateInvoice extends CreateRecord
17 19
 
18 20
     protected static string $resource = InvoiceResource::class;
19 21
 
22
+    #[Url(as: 'client')]
23
+    public ?int $clientId = null;
24
+
25
+    public function mount(): void
26
+    {
27
+        parent::mount();
28
+
29
+        if ($this->clientId) {
30
+            $this->data['client_id'] = $this->clientId;
31
+
32
+            if ($currencyCode = Client::find($this->clientId)?->currency_code) {
33
+                $this->data['currency_code'] = $currencyCode;
34
+            }
35
+        }
36
+    }
37
+
20 38
     public function getMaxContentWidth(): MaxWidth | string | null
21 39
     {
22 40
         return MaxWidth::Full;

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

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
4 4
 
5
+use App\Concerns\HasTabSpecificColumnToggles;
5 6
 use App\Enums\Accounting\InvoiceStatus;
6 7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
7 8
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
@@ -23,6 +24,7 @@ use Livewire\Attributes\Url;
23 24
 class ListInvoices extends ListRecords
24 25
 {
25 26
     use ExposesTableToWidgets;
27
+    use HasTabSpecificColumnToggles;
26 28
 
27 29
     protected static string $resource = InvoiceResource::class;
28 30
 

+ 16
- 8
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php 查看文件

@@ -9,6 +9,7 @@ use App\Enums\Setting\PaymentTerms;
9 9
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
10 10
 use App\Filament\Forms\Components\CreateCurrencySelect;
11 11
 use App\Filament\Forms\Components\DocumentTotals;
12
+use App\Filament\Tables\Columns;
12 13
 use App\Models\Accounting\Adjustment;
13 14
 use App\Models\Accounting\RecurringInvoice;
14 15
 use App\Models\Common\Client;
@@ -272,11 +273,7 @@ class RecurringInvoiceResource extends Resource
272 273
         return $table
273 274
             ->defaultSort('next_date')
274 275
             ->columns([
275
-                Tables\Columns\TextColumn::make('id')
276
-                    ->label('ID')
277
-                    ->sortable()
278
-                    ->toggleable(isToggledHiddenByDefault: true)
279
-                    ->searchable(),
276
+                Columns::id(),
280 277
                 Tables\Columns\TextColumn::make('status')
281 278
                     ->badge()
282 279
                     ->searchable(),
@@ -291,20 +288,31 @@ class RecurringInvoiceResource extends Resource
291 288
                     ->description(function (RecurringInvoice $record) {
292 289
                         return $record->getTimelineDescription();
293 290
                     }),
291
+                Tables\Columns\TextColumn::make('created_at')
292
+                    ->label('Created')
293
+                    ->date()
294
+                    ->sortable()
295
+                    ->showOnTabs(['draft']),
296
+                Tables\Columns\TextColumn::make('start_date')
297
+                    ->label('First Invoice')
298
+                    ->date()
299
+                    ->sortable()
300
+                    ->showOnTabs(['draft']),
294 301
                 Tables\Columns\TextColumn::make('last_date')
295 302
                     ->label('Last Invoice')
296 303
                     ->date()
297 304
                     ->sortable()
298
-                    ->searchable(),
305
+                    ->hideOnTabs(['draft']),
299 306
                 Tables\Columns\TextColumn::make('next_date')
300 307
                     ->label('Next Invoice')
301 308
                     ->date()
302 309
                     ->sortable()
303
-                    ->searchable(),
310
+                    ->hideOnTabs(['draft']),
304 311
                 Tables\Columns\TextColumn::make('total')
305 312
                     ->currencyWithConversion(static fn (RecurringInvoice $record) => $record->currency_code)
306 313
                     ->sortable()
307
-                    ->toggleable(),
314
+                    ->toggleable()
315
+                    ->alignEnd(),
308 316
             ])
309 317
             ->filters([
310 318
                 Tables\Filters\SelectFilter::make('client')

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

@@ -5,9 +5,11 @@ namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
5 5
 use App\Concerns\ManagesLineItems;
6 6
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7 7
 use App\Models\Accounting\RecurringInvoice;
8
+use App\Models\Common\Client;
8 9
 use Filament\Resources\Pages\CreateRecord;
9 10
 use Filament\Support\Enums\MaxWidth;
10 11
 use Illuminate\Database\Eloquent\Model;
12
+use Livewire\Attributes\Url;
11 13
 
12 14
 class CreateRecurringInvoice extends CreateRecord
13 15
 {
@@ -15,6 +17,22 @@ class CreateRecurringInvoice extends CreateRecord
15 17
 
16 18
     protected static string $resource = RecurringInvoiceResource::class;
17 19
 
20
+    #[Url(as: 'client')]
21
+    public ?int $clientId = null;
22
+
23
+    public function mount(): void
24
+    {
25
+        parent::mount();
26
+
27
+        if ($this->clientId) {
28
+            $this->data['client_id'] = $this->clientId;
29
+
30
+            if ($currencyCode = Client::find($this->clientId)?->currency_code) {
31
+                $this->data['currency_code'] = $currencyCode;
32
+            }
33
+        }
34
+    }
35
+
18 36
     public function getMaxContentWidth(): MaxWidth | string | null
19 37
     {
20 38
         return MaxWidth::Full;

+ 3
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php 查看文件

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4 4
 
5
+use App\Concerns\HasTabSpecificColumnToggles;
5 6
 use App\Enums\Accounting\RecurringInvoiceStatus;
6 7
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7 8
 use Filament\Actions;
@@ -12,6 +13,8 @@ use Illuminate\Database\Eloquent\Builder;
12 13
 
13 14
 class ListRecurringInvoices extends ListRecords
14 15
 {
16
+    use HasTabSpecificColumnToggles;
17
+
15 18
     protected static string $resource = RecurringInvoiceResource::class;
16 19
 
17 20
     protected function getHeaderActions(): array

+ 3
- 3
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php 查看文件

@@ -6,9 +6,9 @@ use App\Enums\Accounting\DocumentType;
6 6
 use App\Filament\Company\Resources\Sales\ClientResource;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
8 8
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
9
+use App\Filament\Infolists\Components\BannerEntry;
9 10
 use App\Filament\Infolists\Components\DocumentPreview;
10 11
 use App\Models\Accounting\RecurringInvoice;
11
-use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
12 12
 use Filament\Actions;
13 13
 use Filament\Infolists\Components\Actions\Action;
14 14
 use Filament\Infolists\Components\Grid;
@@ -54,7 +54,7 @@ class ViewRecurringInvoice extends ViewRecord
54 54
     {
55 55
         return $infolist
56 56
             ->schema([
57
-                SimpleAlert::make('scheduleIsNotSet')
57
+                BannerEntry::make('scheduleIsNotSet')
58 58
                     ->info()
59 59
                     ->title('Schedule Not Set')
60 60
                     ->description('The schedule for this recurring invoice has not been set. You must set a schedule before you can approve this draft and start creating invoices.')
@@ -64,7 +64,7 @@ class ViewRecurringInvoice extends ViewRecord
64 64
                         RecurringInvoice::getUpdateScheduleAction(Action::class)
65 65
                             ->outlined(),
66 66
                     ]),
67
-                SimpleAlert::make('readyToApprove')
67
+                BannerEntry::make('readyToApprove')
68 68
                     ->info()
69 69
                     ->title('Ready to Approve')
70 70
                     ->description('This recurring invoice is ready for approval. Review the details, and approve it when you’re ready to start generating invoices.')

+ 58
- 0
app/Filament/Forms/Components/AddressFields.php 查看文件

@@ -0,0 +1,58 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use Filament\Forms\Components\Field;
6
+use Filament\Forms\Components\Grid;
7
+use Filament\Forms\Components\TextInput;
8
+
9
+class AddressFields extends Grid
10
+{
11
+    protected bool $isSoftRequired = false;
12
+
13
+    protected function setUp(): void
14
+    {
15
+        parent::setUp();
16
+
17
+        $this->schema([
18
+            TextInput::make('address_line_1')
19
+                ->label('Address Line 1')
20
+                ->required()
21
+                ->maxLength(255),
22
+            TextInput::make('address_line_2')
23
+                ->label('Address Line 2')
24
+                ->maxLength(255),
25
+            CountrySelect::make('country_code')
26
+                ->clearStateField()
27
+                ->required(),
28
+            StateSelect::make('state_id'),
29
+            TextInput::make('city')
30
+                ->label('City')
31
+                ->required()
32
+                ->maxLength(255),
33
+            TextInput::make('postal_code')
34
+                ->label('Postal Code / Zip Code')
35
+                ->maxLength(255),
36
+        ]);
37
+    }
38
+
39
+    public function softRequired(bool $condition = true): static
40
+    {
41
+        $this->setSoftRequired($condition);
42
+
43
+        return $this;
44
+    }
45
+
46
+    protected function setSoftRequired(bool $condition): void
47
+    {
48
+        $this->isSoftRequired = $condition;
49
+
50
+        $childComponents = $this->getChildComponents();
51
+
52
+        foreach ($childComponents as $component) {
53
+            if ($component instanceof Field && $component->isRequired()) {
54
+                $component->markAsRequired(! $condition);
55
+            }
56
+        }
57
+    }
58
+}

+ 47
- 0
app/Filament/Forms/Components/Banner.php 查看文件

@@ -0,0 +1,47 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use CodeWithDennis\SimpleAlert\Components\Forms\SimpleAlert;
6
+
7
+class Banner extends SimpleAlert
8
+{
9
+    protected function setUp(): void
10
+    {
11
+        parent::setUp();
12
+
13
+        $this->border();
14
+    }
15
+
16
+    public function danger(): static
17
+    {
18
+        $this->color = 'danger';
19
+        $this->icon = 'heroicon-o-x-circle';
20
+
21
+        return $this;
22
+    }
23
+
24
+    public function info(): static
25
+    {
26
+        $this->color = 'info';
27
+        $this->icon = 'heroicon-o-information-circle';
28
+
29
+        return $this;
30
+    }
31
+
32
+    public function success(): static
33
+    {
34
+        $this->color = 'success';
35
+        $this->icon = 'heroicon-o-check-circle';
36
+
37
+        return $this;
38
+    }
39
+
40
+    public function warning(): static
41
+    {
42
+        $this->color = 'warning';
43
+        $this->icon = 'heroicon-o-exclamation-triangle';
44
+
45
+        return $this;
46
+    }
47
+}

+ 49
- 0
app/Filament/Forms/Components/CountrySelect.php 查看文件

@@ -0,0 +1,49 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Models\Locale\Country;
6
+use Filament\Forms\Components\Select;
7
+use Filament\Forms\Set;
8
+
9
+class CountrySelect extends Select
10
+{
11
+    protected ?string $stateFieldName = null;
12
+
13
+    protected function setUp(): void
14
+    {
15
+        parent::setUp();
16
+
17
+        $this
18
+            ->localizeLabel('Country')
19
+            ->searchable()
20
+            ->options($options = Country::getAvailableCountryOptions())
21
+            ->getSearchResultsUsing(static fn (string $search): array => Country::getSearchResultsUsing($search))
22
+            ->getOptionLabelUsing(static fn (string $value): ?string => $options[$value] ?? $value);
23
+
24
+        $this->afterStateUpdated(function (self $component, Set $set) {
25
+            if ($component->shouldClearStateField()) {
26
+                $set($component->getStateFieldName(), null);
27
+            }
28
+        });
29
+    }
30
+
31
+    public function clearStateField(string $fieldName = 'state_id'): static
32
+    {
33
+        $this->stateFieldName = $fieldName;
34
+
35
+        $this->live();
36
+
37
+        return $this;
38
+    }
39
+
40
+    public function getStateFieldName(): ?string
41
+    {
42
+        return $this->stateFieldName;
43
+    }
44
+
45
+    public function shouldClearStateField(): bool
46
+    {
47
+        return (bool) $this->stateFieldName;
48
+    }
49
+}

+ 0
- 45
app/Filament/Forms/Components/LabeledField.php 查看文件

@@ -1,45 +0,0 @@
1
-<?php
2
-
3
-namespace App\Filament\Forms\Components;
4
-
5
-use Closure;
6
-use Filament\Forms\Components\Component;
7
-use Illuminate\Contracts\Support\Htmlable;
8
-
9
-class LabeledField extends Component
10
-{
11
-    protected string $view = 'filament.forms.components.labeled-field';
12
-
13
-    protected string | Htmlable | Closure | null $prefixLabel = null;
14
-
15
-    protected string | Htmlable | Closure | null $suffixLabel = null;
16
-
17
-    public static function make(): static
18
-    {
19
-        return app(static::class);
20
-    }
21
-
22
-    public function prefix(string | Htmlable | Closure | null $label): static
23
-    {
24
-        $this->prefixLabel = $label;
25
-
26
-        return $this;
27
-    }
28
-
29
-    public function suffix(string | Htmlable | Closure | null $label): static
30
-    {
31
-        $this->suffixLabel = $label;
32
-
33
-        return $this;
34
-    }
35
-
36
-    public function getPrefixLabel(): string | Htmlable | null
37
-    {
38
-        return $this->evaluate($this->prefixLabel);
39
-    }
40
-
41
-    public function getSuffixLabel(): string | Htmlable | null
42
-    {
43
-        return $this->evaluate($this->suffixLabel);
44
-    }
45
-}

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

@@ -1,165 +0,0 @@
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
-}

+ 23
- 0
app/Filament/Forms/Components/StateSelect.php 查看文件

@@ -0,0 +1,23 @@
1
+<?php
2
+
3
+namespace App\Filament\Forms\Components;
4
+
5
+use App\Models\Locale\State;
6
+use Filament\Forms\Components\Select;
7
+use Filament\Forms\Get;
8
+
9
+class StateSelect extends Select
10
+{
11
+    protected function setUp(): void
12
+    {
13
+        parent::setUp();
14
+
15
+        $this
16
+            ->localizeLabel('State / Province')
17
+            ->searchable()
18
+            ->options(static fn (Get $get) => State::getStateOptions($get('country_code')))
19
+            ->getSearchResultsUsing(static function (string $search, Get $get): array {
20
+                return State::getSearchResultsUsing($search, $get('country_code'));
21
+            });
22
+    }
23
+}

+ 47
- 0
app/Filament/Infolists/Components/BannerEntry.php 查看文件

@@ -0,0 +1,47 @@
1
+<?php
2
+
3
+namespace App\Filament\Infolists\Components;
4
+
5
+use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
6
+
7
+class BannerEntry extends SimpleAlert
8
+{
9
+    protected function setUp(): void
10
+    {
11
+        parent::setUp();
12
+
13
+        $this->border();
14
+    }
15
+
16
+    public function danger(): static
17
+    {
18
+        $this->color = 'danger';
19
+        $this->icon = 'heroicon-o-x-circle';
20
+
21
+        return $this;
22
+    }
23
+
24
+    public function info(): static
25
+    {
26
+        $this->color = 'info';
27
+        $this->icon = 'heroicon-o-information-circle';
28
+
29
+        return $this;
30
+    }
31
+
32
+    public function success(): static
33
+    {
34
+        $this->color = 'success';
35
+        $this->icon = 'heroicon-o-check-circle';
36
+
37
+        return $this;
38
+    }
39
+
40
+    public function warning(): static
41
+    {
42
+        $this->color = 'warning';
43
+        $this->icon = 'heroicon-o-exclamation-triangle';
44
+
45
+        return $this;
46
+    }
47
+}

+ 17
- 0
app/Filament/Tables/Columns.php 查看文件

@@ -0,0 +1,17 @@
1
+<?php
2
+
3
+namespace App\Filament\Tables;
4
+
5
+use Filament\Tables\Columns\TextColumn;
6
+
7
+class Columns
8
+{
9
+    public static function id(): TextColumn
10
+    {
11
+        return TextColumn::make('id')
12
+            ->label('ID')
13
+            ->sortable()
14
+            ->toggleable(isToggledHiddenByDefault: true)
15
+            ->searchable();
16
+    }
17
+}

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

@@ -180,6 +180,13 @@ class Invoice extends Document
180 180
         ]);
181 181
     }
182 182
 
183
+    public function scopeOverdue(Builder $query): Builder
184
+    {
185
+        return $query
186
+            ->unpaid()
187
+            ->where('status', InvoiceStatus::Overdue);
188
+    }
189
+
183 190
     protected function isCurrentlyOverdue(): Attribute
184 191
     {
185 192
         return Attribute::get(function () {

+ 36
- 23
app/Models/Accounting/RecurringInvoice.php 查看文件

@@ -17,13 +17,13 @@ use App\Enums\Accounting\InvoiceStatus;
17 17
 use App\Enums\Accounting\Month;
18 18
 use App\Enums\Accounting\RecurringInvoiceStatus;
19 19
 use App\Enums\Setting\PaymentTerms;
20
+use App\Filament\Forms\Components\Banner;
20 21
 use App\Filament\Forms\Components\CustomSection;
21 22
 use App\Models\Common\Client;
22 23
 use App\Models\Setting\CompanyProfile;
23 24
 use App\Observers\RecurringInvoiceObserver;
24 25
 use App\Support\ScheduleHandler;
25 26
 use App\Utilities\Localization\Timezone;
26
-use CodeWithDennis\SimpleAlert\Components\Forms\SimpleAlert;
27 27
 use Filament\Actions\Action;
28 28
 use Filament\Actions\MountableAction;
29 29
 use Filament\Forms;
@@ -200,8 +200,12 @@ class RecurringInvoice extends Document
200 200
         return $this->start_date?->gte(today()) ?? false;
201 201
     }
202 202
 
203
-    public function getScheduleDescription(): string
203
+    public function getScheduleDescription(): ?string
204 204
     {
205
+        if (! $this->hasSchedule()) {
206
+            return null;
207
+        }
208
+
205 209
         $frequency = $this->frequency;
206 210
 
207 211
         return match (true) {
@@ -215,7 +219,7 @@ class RecurringInvoice extends Document
215 219
 
216 220
             $frequency->isCustom() => $this->getCustomScheduleDescription(),
217 221
 
218
-            default => 'Not Configured',
222
+            default => null,
219 223
         };
220 224
     }
221 225
 
@@ -238,10 +242,10 @@ class RecurringInvoice extends Document
238 242
         return "Repeat every {$interval}{$dayDescription}";
239 243
     }
240 244
 
241
-    public function getEndDescription(): string
245
+    public function getEndDescription(): ?string
242 246
     {
243 247
         if (! $this->end_type) {
244
-            return 'Not configured';
248
+            return null;
245 249
         }
246 250
 
247 251
         return match (true) {
@@ -251,12 +255,16 @@ class RecurringInvoice extends Document
251 255
 
252 256
             $this->end_type->isOn() && $this->end_date => 'On ' . $this->end_date->toDefaultDateFormat(),
253 257
 
254
-            default => 'Not configured'
258
+            default => null,
255 259
         };
256 260
     }
257 261
 
258
-    public function getTimelineDescription(): string
262
+    public function getTimelineDescription(): ?string
259 263
     {
264
+        if (! $this->hasSchedule()) {
265
+            return null;
266
+        }
267
+
260 268
         $parts = [];
261 269
 
262 270
         if ($this->start_date) {
@@ -303,19 +311,25 @@ class RecurringInvoice extends Document
303 311
         return $nextDate;
304 312
     }
305 313
 
306
-    public function calculateNextWeeklyDate(Carbon $lastDate): ?Carbon
314
+    public function calculateNextWeeklyDate(Carbon $lastDate, int $interval = 1): ?Carbon
307 315
     {
308
-        return $lastDate->copy()->next($this->day_of_week->name);
316
+        return $lastDate->copy()
317
+            ->addWeeks($interval - 1)
318
+            ->next($this->day_of_week->value);
309 319
     }
310 320
 
311
-    public function calculateNextMonthlyDate(Carbon $lastDate): ?Carbon
321
+    public function calculateNextMonthlyDate(Carbon $lastDate, int $interval = 1): ?Carbon
312 322
     {
313
-        return $this->day_of_month->resolveDate($lastDate->copy()->addMonth());
323
+        return $this->day_of_month->resolveDate(
324
+            $lastDate->copy()->addMonthsNoOverflow($interval)
325
+        );
314 326
     }
315 327
 
316
-    public function calculateNextYearlyDate(Carbon $lastDate): ?Carbon
328
+    public function calculateNextYearlyDate(Carbon $lastDate, int $interval = 1): ?Carbon
317 329
     {
318
-        return $this->day_of_month->resolveDate($lastDate->copy()->addYear()->month($this->month->value));
330
+        return $this->day_of_month->resolveDate(
331
+            $lastDate->copy()->addYears($interval)->month($this->month->value)
332
+        );
319 333
     }
320 334
 
321 335
     protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon
@@ -325,11 +339,11 @@ class RecurringInvoice extends Document
325 339
         return match ($this->interval_type) {
326 340
             IntervalType::Day => $lastDate->copy()->addDays($interval),
327 341
 
328
-            IntervalType::Week => $lastDate->copy()->addWeeks($interval),
342
+            IntervalType::Week => $this->calculateNextWeeklyDate($lastDate, $interval),
329 343
 
330
-            IntervalType::Month => $this->day_of_month->resolveDate($lastDate->copy()->addMonths($interval)),
344
+            IntervalType::Month => $this->calculateNextMonthlyDate($lastDate, $interval),
331 345
 
332
-            IntervalType::Year => $this->day_of_month->resolveDate($lastDate->copy()->addYears($interval)->month($this->month->value)),
346
+            IntervalType::Year => $this->calculateNextYearlyDate($lastDate, $interval),
333 347
 
334 348
             default => null
335 349
         };
@@ -400,7 +414,6 @@ class RecurringInvoice extends Document
400 414
                                     $handler->handleFrequencyChange($state);
401 415
                                 }),
402 416
 
403
-                            // Custom frequency fields in a nested grid
404 417
                             Cluster::make([
405 418
                                 Forms\Components\TextInput::make('interval_value')
406 419
                                     ->softRequired()
@@ -422,7 +435,6 @@ class RecurringInvoice extends Document
422 435
                                 ->markAsRequired(false)
423 436
                                 ->visible($frequency->isCustom()),
424 437
 
425
-                            // Specific schedule details
426 438
                             Forms\Components\Select::make('month')
427 439
                                 ->label('Month')
428 440
                                 ->options(Month::class)
@@ -455,18 +467,19 @@ class RecurringInvoice extends Document
455 467
                                     $handler->handleDateChange('day_of_month', $state);
456 468
                                 }),
457 469
 
458
-                            SimpleAlert::make('dayOfMonthNotice')
459
-                                ->title(function () use ($dayOfMonth) {
460
-                                    return "The invoice will be created on the {$dayOfMonth->getLabel()} day of each month, or on the last day for months ending earlier.";
470
+                            Banner::make('dayOfMonthNotice')
471
+                                ->info()
472
+                                ->title(static function () use ($dayOfMonth) {
473
+                                    return "For months with fewer than {$dayOfMonth->value} days, the last day of the month will be used.";
461 474
                                 })
462 475
                                 ->columnSpanFull()
463
-                                ->visible($dayOfMonth?->value > 28),
476
+                                ->visible($dayOfMonth?->mayExceedMonthLength() && ($frequency->isMonthly() || $intervalType?->isMonth())),
464 477
 
465 478
                             Forms\Components\Select::make('day_of_week')
466 479
                                 ->label('Day of Week')
467 480
                                 ->options(DayOfWeek::class)
468 481
                                 ->softRequired()
469
-                                ->visible($frequency->isWeekly() || $intervalType?->isWeek())
482
+                                ->visible(($frequency->isWeekly() || $intervalType?->isWeek()) ?? false)
470 483
                                 ->live()
471 484
                                 ->afterStateUpdated(function (Forms\Set $set, $state) {
472 485
                                     $handler = new ScheduleHandler($set);

+ 52
- 2
app/Models/Common/Address.php 查看文件

@@ -5,8 +5,13 @@ namespace App\Models\Common;
5 5
 use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7 7
 use App\Enums\Common\AddressType;
8
+use App\Models\Locale\Country;
9
+use App\Models\Locale\State;
10
+use Illuminate\Database\Eloquent\Casts\Attribute;
8 11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
9 12
 use Illuminate\Database\Eloquent\Model;
13
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\HasMany;
10 15
 use Illuminate\Database\Eloquent\Relations\MorphTo;
11 16
 
12 17
 class Address extends Model
@@ -19,15 +24,16 @@ class Address extends Model
19 24
 
20 25
     protected $fillable = [
21 26
         'company_id',
27
+        'parent_address_id',
22 28
         'type',
23 29
         'recipient',
24 30
         'phone',
25 31
         'address_line_1',
26 32
         'address_line_2',
27 33
         'city',
28
-        'state',
34
+        'state_id',
29 35
         'postal_code',
30
-        'country',
36
+        'country_code',
31 37
         'notes',
32 38
         'created_by',
33 39
         'updated_by',
@@ -41,4 +47,48 @@ class Address extends Model
41 47
     {
42 48
         return $this->morphTo();
43 49
     }
50
+
51
+    public function parentAddress(): BelongsTo
52
+    {
53
+        return $this->belongsTo(self::class, 'parent_address_id', 'id');
54
+    }
55
+
56
+    public function childAddresses(): HasMany
57
+    {
58
+        return $this->hasMany(self::class, 'parent_address_id', 'id');
59
+    }
60
+
61
+    public function country(): BelongsTo
62
+    {
63
+        return $this->belongsTo(Country::class, 'country_code', 'id');
64
+    }
65
+
66
+    public function state(): BelongsTo
67
+    {
68
+        return $this->belongsTo(State::class, 'state_id', 'id');
69
+    }
70
+
71
+    protected function addressString(): Attribute
72
+    {
73
+        return Attribute::get(function () {
74
+            $street = array_filter([
75
+                $this->address_line_1,
76
+                $this->address_line_2,
77
+            ]);
78
+
79
+            return array_filter([
80
+                implode(', ', $street), // Street 1 & 2 on same line if both exist
81
+                implode(', ', array_filter([
82
+                    $this->city,
83
+                    $this->state->name,
84
+                    $this->postal_code,
85
+                ])),
86
+            ]);
87
+        });
88
+    }
89
+
90
+    public function isIncomplete(): bool
91
+    {
92
+        return empty($this->address_line_1) || empty($this->city);
93
+    }
44 94
 }

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

@@ -5,7 +5,9 @@ namespace App\Models\Common;
5 5
 use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7 7
 use App\Enums\Common\AddressType;
8
+use App\Models\Accounting\Estimate;
8 9
 use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\RecurringInvoice;
9 11
 use App\Models\Setting\Currency;
10 12
 use Illuminate\Database\Eloquent\Factories\HasFactory;
11 13
 use Illuminate\Database\Eloquent\Model;
@@ -72,8 +74,18 @@ class Client extends Model
72 74
             ->where('type', AddressType::Shipping);
73 75
     }
74 76
 
77
+    public function estimates(): HasMany
78
+    {
79
+        return $this->hasMany(Estimate::class);
80
+    }
81
+
75 82
     public function invoices(): HasMany
76 83
     {
77 84
         return $this->hasMany(Invoice::class);
78 85
     }
86
+
87
+    public function recurringInvoices(): HasMany
88
+    {
89
+        return $this->hasMany(RecurringInvoice::class);
90
+    }
79 91
 }

+ 39
- 8
app/Models/Locale/Country.php 查看文件

@@ -2,7 +2,7 @@
2 2
 
3 3
 namespace App\Models\Locale;
4 4
 
5
-use App\Models\Setting\CompanyProfile;
5
+use App\Models\Common\Address;
6 6
 use Illuminate\Database\Eloquent\Casts\Attribute;
7 7
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
8 8
 use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -49,9 +49,9 @@ class Country extends Model
49 49
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
50 50
     }
51 51
 
52
-    public function profiles(): HasMany
52
+    public function addresses(): HasMany
53 53
     {
54
-        return $this->hasMany(CompanyProfile::class, 'country', 'id');
54
+        return $this->hasMany(Address::class, 'country_code', 'id');
55 55
     }
56 56
 
57 57
     public function states(): HasMany
@@ -80,19 +80,50 @@ class Country extends Model
80 80
 
81 81
     public static function getAllCountryCodes(): Collection
82 82
     {
83
-        return self::all()->pluck('id');
83
+        return self::query()
84
+            ->select('id')
85
+            ->pluck('id');
84 86
     }
85 87
 
86 88
     public static function getAvailableCountryOptions(): array
87 89
     {
88
-        return self::all()->mapWithKeys(static function ($country): array {
89
-            return [$country->id => $country->name . ' ' . $country->flag];
90
-        })->toArray();
90
+        return self::query()
91
+            ->select(['id', 'name', 'flag'])
92
+            ->orderBy('name')
93
+            ->get()
94
+            ->mapWithKeys(static fn ($country) => [
95
+                $country->id => $country->name . ' ' . $country->flag,
96
+            ])
97
+            ->toArray();
98
+    }
99
+
100
+    public static function getSearchResultsUsing(string $search): array
101
+    {
102
+        return self::query()
103
+            ->select(['id', 'name', 'flag'])
104
+            ->where(static function ($query) use ($search) {
105
+                $query->whereLike('name', "%{$search}%")
106
+                    ->orWhereLike('id', "%{$search}%");
107
+            })
108
+            ->orderByRaw('
109
+                CASE
110
+                    WHEN id = ? THEN 1
111
+                    WHEN id LIKE ? THEN 2
112
+                    WHEN name LIKE ? THEN 3
113
+                    ELSE 4
114
+                END
115
+            ', [$search, $search . '%', $search . '%'])
116
+            ->limit(50)
117
+            ->get()
118
+            ->mapWithKeys(static fn (self $country) => [
119
+                $country->id => $country->name . ' ' . $country->flag,
120
+            ])
121
+            ->toArray();
91 122
     }
92 123
 
93 124
     public static function getLanguagesByCountryCode(?string $code = null): array
94 125
     {
95
-        if ($code === null) {
126
+        if (! $code) {
96 127
             return Locales::getNames();
97 128
         }
98 129
 

+ 35
- 5
app/Models/Locale/State.php 查看文件

@@ -4,7 +4,6 @@ namespace App\Models\Locale;
4 4
 
5 5
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
6 6
 use Illuminate\Database\Eloquent\Relations\HasMany;
7
-use Illuminate\Support\Collection;
8 7
 use Squire\Model;
9 8
 
10 9
 /**
@@ -28,13 +27,44 @@ class State extends Model
28 27
         'longitude' => 'float',
29 28
     ];
30 29
 
31
-    public static function getStateOptions(?string $code = null): Collection
30
+    public static function getStateOptions(?string $code = null): array
32 31
     {
33
-        if ($code === null) {
34
-            return collect();
32
+        if (! $code) {
33
+            return [];
35 34
         }
36 35
 
37
-        return self::where('country_id', $code)->get()->pluck('name', 'id');
36
+        return self::query()
37
+            ->where('country_id', $code)
38
+            ->orderBy('name')
39
+            ->get()
40
+            ->pluck('name', 'id')
41
+            ->toArray();
42
+    }
43
+
44
+    public static function getSearchResultsUsing(string $search, ?string $countryCode = null): array
45
+    {
46
+        if (! $countryCode) {
47
+            return [];
48
+        }
49
+
50
+        return self::query()
51
+            ->where('country_id', $countryCode)
52
+            ->where(static function ($query) use ($search) {
53
+                $query->whereLike('name', "%{$search}%")
54
+                    ->orWhereLike('state_code', "%{$search}%");
55
+            })
56
+            ->orderByRaw('
57
+                CASE
58
+                    WHEN state_code = ? THEN 1
59
+                    WHEN state_code LIKE ? THEN 2
60
+                    WHEN name LIKE ? THEN 3
61
+                    ELSE 4
62
+                END
63
+            ', [$search, $search . '%', $search . '%'])
64
+            ->limit(50)
65
+            ->get()
66
+            ->pluck('name', 'id')
67
+            ->toArray();
38 68
     }
39 69
 
40 70
     public function country(): BelongsTo

+ 4
- 26
app/Models/Setting/CompanyProfile.php 查看文件

@@ -5,15 +5,13 @@ namespace App\Models\Setting;
5 5
 use App\Concerns\Blamable;
6 6
 use App\Concerns\CompanyOwned;
7 7
 use App\Enums\Setting\EntityType;
8
-use App\Models\Locale\City;
9
-use App\Models\Locale\Country;
10
-use App\Models\Locale\State;
8
+use App\Models\Common\Address;
11 9
 use Database\Factories\Setting\CompanyProfileFactory;
12 10
 use Illuminate\Database\Eloquent\Casts\Attribute;
13 11
 use Illuminate\Database\Eloquent\Factories\Factory;
14 12
 use Illuminate\Database\Eloquent\Factories\HasFactory;
15 13
 use Illuminate\Database\Eloquent\Model;
16
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\MorphOne;
17 15
 use Illuminate\Support\Facades\Storage;
18 16
 
19 17
 class CompanyProfile extends Model
@@ -27,11 +25,6 @@ class CompanyProfile extends Model
27 25
     protected $fillable = [
28 26
         'company_id',
29 27
         'logo',
30
-        'address',
31
-        'city_id',
32
-        'zip_code',
33
-        'state_id',
34
-        'country',
35 28
         'phone_number',
36 29
         'email',
37 30
         'tax_id',
@@ -59,24 +52,9 @@ class CompanyProfile extends Model
59 52
         });
60 53
     }
61 54
 
62
-    public function country(): BelongsTo
55
+    public function address(): MorphOne
63 56
     {
64
-        return $this->belongsTo(Country::class, 'country', 'id');
65
-    }
66
-
67
-    public function city(): BelongsTo
68
-    {
69
-        return $this->belongsTo(City::class, 'city_id', 'id');
70
-    }
71
-
72
-    public function state(): BelongsTo
73
-    {
74
-        return $this->belongsTo(State::class, 'state_id', 'id');
75
-    }
76
-
77
-    public function getCountryName(): string
78
-    {
79
-        return Country::findByIsoCode2($this->country)?->name ?? '';
57
+        return $this->morphOne(Address::class, 'addressable');
80 58
     }
81 59
 
82 60
     protected static function newFactory(): Factory

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

@@ -187,6 +187,23 @@ class DocumentDefault extends Model
187 187
         return $options[$optionValue] ?? null;
188 188
     }
189 189
 
190
+    public function resolveColumnLabel(string $column, string $default, ?array $data = null): string
191
+    {
192
+        if ($data) {
193
+            $custom = $data[$column]['custom'] ?? null;
194
+            $option = $data[$column]['option'] ?? null;
195
+        } else {
196
+            $custom = $this->{$column}['custom'] ?? null;
197
+            $option = $this->{$column}['option'] ?? null;
198
+        }
199
+
200
+        if ($custom) {
201
+            return $custom;
202
+        }
203
+
204
+        return $this->getLabelOptionFor($column, $option) ?? $default;
205
+    }
206
+
190 207
     protected static function newFactory(): Factory
191 208
     {
192 209
         return DocumentDefaultFactory::new();

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

@@ -276,6 +276,10 @@ class FilamentCompaniesServiceProvider extends PanelProvider
276 276
                 ->filtersFormWidth(MaxWidth::Small)
277 277
                 ->filtersTriggerAction(fn (Tables\Actions\Action $action) => $action->slideOver());
278 278
         }, isImportant: true);
279
+
280
+        Tables\Columns\TextColumn::configureUsing(function (Tables\Columns\TextColumn $column): void {
281
+            $column->placeholder('–');
282
+        });
279 283
     }
280 284
 
281 285
     /**

+ 54
- 5
app/Providers/MacroServiceProvider.php 查看文件

@@ -19,7 +19,10 @@ use Filament\Forms\Components\Field;
19 19
 use Filament\Forms\Components\TextInput;
20 20
 use Filament\Infolists\Components\TextEntry;
21 21
 use Filament\Tables\Columns\TextColumn;
22
+use Filament\Tables\Contracts\HasTable;
23
+use Illuminate\Contracts\Support\Htmlable;
22 24
 use Illuminate\Support\Carbon;
25
+use Illuminate\Support\HtmlString;
23 26
 use Illuminate\Support\ServiceProvider;
24 27
 
25 28
 class MacroServiceProvider extends ServiceProvider
@@ -113,6 +116,32 @@ class MacroServiceProvider extends ServiceProvider
113 116
                 });
114 117
         });
115 118
 
119
+        TextColumn::macro('coloredDescription', function (string | Htmlable | Closure | null $description, string $color = 'danger') {
120
+            $this->description(static function (TextColumn $column) use ($description, $color): Htmlable {
121
+                $description = $column->evaluate($description);
122
+
123
+                return new HtmlString("<span class='text-{$color}-700 dark:text-{$color}-400'>{$description}</span>");
124
+            });
125
+
126
+            return $this;
127
+        });
128
+
129
+        TextColumn::macro('hideOnTabs', function (array $tabs): static {
130
+            $this->toggleable(isToggledHiddenByDefault: function (HasTable $livewire) use ($tabs) {
131
+                return in_array($livewire->activeTab, $tabs);
132
+            });
133
+
134
+            return $this;
135
+        });
136
+
137
+        TextColumn::macro('showOnTabs', function (array $tabs): static {
138
+            $this->toggleable(isToggledHiddenByDefault: function (HasTable $livewire) use ($tabs) {
139
+                return ! in_array($livewire->activeTab, $tabs);
140
+            });
141
+
142
+            return $this;
143
+        });
144
+
116 145
         TextColumn::macro('defaultDateFormat', function (): static {
117 146
             $localization = Localization::firstOrFail();
118 147
 
@@ -172,20 +201,32 @@ class MacroServiceProvider extends ServiceProvider
172 201
             return $this;
173 202
         });
174 203
 
175
-        TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null): static {
204
+        TextColumn::macro('currencyWithConversion', function (string | Closure | null $currency = null, ?bool $convertFromCents = null): static {
176 205
             $currency ??= CurrencyAccessor::getDefaultCurrency();
206
+            $convertFromCents ??= false;
177 207
 
178
-            $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency): ?string {
208
+            $this->formatStateUsing(static function (TextColumn $column, $state) use ($currency, $convertFromCents): ?string {
179 209
                 if (blank($state)) {
180 210
                     return null;
181 211
                 }
182 212
 
183 213
                 $currency = $column->evaluate($currency);
214
+                $showCurrency = $currency !== CurrencyAccessor::getDefaultCurrency();
184 215
 
185
-                return CurrencyConverter::formatToMoney($state, $currency);
216
+                if ($convertFromCents) {
217
+                    $balanceInCents = $state;
218
+                } else {
219
+                    $balanceInCents = CurrencyConverter::convertToCents($state, $currency);
220
+                }
221
+
222
+                if ($balanceInCents < 0) {
223
+                    return '(' . CurrencyConverter::formatCentsToMoney(abs($balanceInCents), $currency, $showCurrency) . ')';
224
+                }
225
+
226
+                return CurrencyConverter::formatCentsToMoney($balanceInCents, $currency, $showCurrency);
186 227
             });
187 228
 
188
-            $this->description(static function (TextColumn $column, $state) use ($currency): ?string {
229
+            $this->description(static function (TextColumn $column, $state) use ($currency, $convertFromCents): ?string {
189 230
                 if (blank($state)) {
190 231
                     return null;
191 232
                 }
@@ -197,10 +238,18 @@ class MacroServiceProvider extends ServiceProvider
197 238
                     return null;
198 239
                 }
199 240
 
200
-                $balanceInCents = CurrencyConverter::convertToCents($state, $oldCurrency);
241
+                if ($convertFromCents) {
242
+                    $balanceInCents = $state;
243
+                } else {
244
+                    $balanceInCents = CurrencyConverter::convertToCents($state, $oldCurrency);
245
+                }
201 246
 
202 247
                 $convertedBalanceInCents = CurrencyConverter::convertBalance($balanceInCents, $oldCurrency, $newCurrency);
203 248
 
249
+                if ($convertedBalanceInCents < 0) {
250
+                    return '(' . CurrencyConverter::formatCentsToMoney(abs($convertedBalanceInCents), $newCurrency, true) . ')';
251
+                }
252
+
204 253
                 return CurrencyConverter::formatCentsToMoney($convertedBalanceInCents, $newCurrency, true);
205 254
             });
206 255
 

+ 6
- 1
app/Services/ReportService.php 查看文件

@@ -11,6 +11,7 @@ use App\DTO\CashFlowOverviewDTO;
11 11
 use App\DTO\ReportDTO;
12 12
 use App\Enums\Accounting\AccountCategory;
13 13
 use App\Enums\Accounting\AccountType;
14
+use App\Enums\Accounting\TransactionType;
14 15
 use App\Models\Accounting\Account;
15 16
 use App\Models\Accounting\Transaction;
16 17
 use App\Support\Column;
@@ -261,7 +262,11 @@ class ReportService
261 262
         if ($transaction->transactionable_type === null || $transaction->is_payment) {
262 263
             return [
263 264
                 'type' => 'transaction',
264
-                'action' => $transaction->type->isJournal() ? 'updateJournalTransaction' : 'updateTransaction',
265
+                'action' => match ($transaction->type) {
266
+                    TransactionType::Journal => 'updateJournalTransaction',
267
+                    TransactionType::Transfer => 'updateTransfer',
268
+                    default => 'updateTransaction',
269
+                },
265 270
                 'id' => $transaction->id,
266 271
             ];
267 272
         }

+ 8
- 2
app/Utilities/Currency/CurrencyConverter.php 查看文件

@@ -62,11 +62,17 @@ class CurrencyConverter
62 62
         return $money->format();
63 63
     }
64 64
 
65
-    public static function formatToMoney(string | float $amount, ?string $currency = null): string
65
+    public static function formatToMoney(string | float $amount, ?string $currency = null, bool $withCode = false): string
66 66
     {
67 67
         $currency ??= CurrencyAccessor::getDefaultCurrency();
68 68
 
69
-        return money($amount, $currency, true)->format();
69
+        $money = money($amount, $currency, true);
70
+
71
+        if ($withCode) {
72
+            return $money->formatWithCode();
73
+        }
74
+
75
+        return $money->format();
70 76
     }
71 77
 
72 78
     public static function convertCentsToFloat(int $amount, ?string $currency = null): float

+ 0
- 209
app/View/Models/InvoiceViewModel.php 查看文件

@@ -1,209 +0,0 @@
1
-<?php
2
-
3
-namespace App\View\Models;
4
-
5
-use App\Enums\Setting\Font;
6
-use App\Enums\Setting\PaymentTerms;
7
-use App\Models\Setting\DocumentDefault;
8
-use Filament\Panel\Concerns\HasFont;
9
-
10
-class InvoiceViewModel
11
-{
12
-    use HasFont;
13
-
14
-    public function __construct(
15
-        public DocumentDefault $invoice,
16
-        public ?array $data = null
17
-    ) {}
18
-
19
-    public function logo(): ?string
20
-    {
21
-        return $this->invoice->logo_url;
22
-    }
23
-
24
-    public function show_logo(): bool
25
-    {
26
-        return $this->data['show_logo'] ?? $this->invoice->show_logo ?? false;
27
-    }
28
-
29
-    // Company related methods
30
-    public function company_name(): string
31
-    {
32
-        return $this->invoice->company->name;
33
-    }
34
-
35
-    public function company_address(): ?string
36
-    {
37
-        return $this->invoice->company->profile->address ?? null;
38
-    }
39
-
40
-    public function company_phone(): ?string
41
-    {
42
-        return $this->invoice->company->profile->phone_number ?? null;
43
-    }
44
-
45
-    public function company_city(): ?string
46
-    {
47
-        return $this->invoice->company->profile->city->name ?? null;
48
-    }
49
-
50
-    public function company_state(): ?string
51
-    {
52
-        return $this->invoice->company->profile->state->name ?? null;
53
-    }
54
-
55
-    public function company_zip(): ?string
56
-    {
57
-        return $this->invoice->company->profile->zip_code ?? null;
58
-    }
59
-
60
-    public function company_country(): ?string
61
-    {
62
-        return $this->invoice->company->profile->state->country->name ?? null;
63
-    }
64
-
65
-    // Invoice numbering related methods
66
-    public function number_prefix(): string
67
-    {
68
-        return $this->data['number_prefix'] ?? $this->invoice->number_prefix ?? 'INV-';
69
-    }
70
-
71
-    public function number_digits(): int
72
-    {
73
-        return $this->data['number_digits'] ?? $this->invoice->number_digits ?? 5;
74
-    }
75
-
76
-    public function number_next(): string
77
-    {
78
-        return $this->data['number_next'] ?? $this->invoice->number_next;
79
-    }
80
-
81
-    public function invoice_number(): string
82
-    {
83
-        return $this->invoice->getNumberNext(padded: true, format: true, prefix: $this->number_prefix(), digits: $this->number_digits(), next: $this->number_next());
84
-    }
85
-
86
-    // Invoice date related methods
87
-    public function invoice_date(): string
88
-    {
89
-        return $this->invoice->company->locale->date_format->getLabel();
90
-    }
91
-
92
-    public function payment_terms(): string
93
-    {
94
-        return $this->data['payment_terms'] ?? $this->invoice->payment_terms?->value ?? PaymentTerms::DEFAULT;
95
-    }
96
-
97
-    public function invoice_due_date(): string
98
-    {
99
-        $dateFormat = $this->invoice->company->locale->date_format->value;
100
-
101
-        return PaymentTerms::from($this->payment_terms())->getDueDate($dateFormat);
102
-    }
103
-
104
-    // Invoice header related methods
105
-    public function header(): string
106
-    {
107
-        return $this->data['header'] ?? $this->invoice->header ?? 'Invoice';
108
-    }
109
-
110
-    public function subheader(): ?string
111
-    {
112
-        return $this->data['subheader'] ?? $this->invoice->subheader ?? null;
113
-    }
114
-
115
-    // Invoice styling
116
-    public function accent_color(): string
117
-    {
118
-        return $this->data['accent_color'] ?? $this->invoice->accent_color;
119
-    }
120
-
121
-    public function fontFamily(): string
122
-    {
123
-        if ($this->data['font']) {
124
-            return Font::from($this->data['font'])->getLabel();
125
-        }
126
-
127
-        if ($this->invoice->font) {
128
-            return $this->invoice->font->getLabel();
129
-        }
130
-
131
-        return Font::from(Font::DEFAULT)->getLabel();
132
-    }
133
-
134
-    public function footer(): ?string
135
-    {
136
-        return $this->data['footer'] ?? $this->invoice->footer ?? null;
137
-    }
138
-
139
-    public function terms(): ?string
140
-    {
141
-        return $this->data['terms'] ?? $this->invoice->terms ?? null;
142
-    }
143
-
144
-    public function getItemColumnName(string $column, string $default): string
145
-    {
146
-        $custom = $this->data[$column]['custom'] ?? $this->invoice->{$column . '_custom'} ?? null;
147
-
148
-        if ($custom) {
149
-            return $custom;
150
-        }
151
-
152
-        $option = $this->data[$column]['option'] ?? $this->invoice->{$column . '_option'} ?? null;
153
-
154
-        return $option ? $this->invoice->getLabelOptionFor($column, $option) : translate($default);
155
-    }
156
-
157
-    // Invoice column related methods
158
-    public function item_name(): string
159
-    {
160
-        return $this->getItemColumnName('item_name', 'Items');
161
-    }
162
-
163
-    public function unit_name(): string
164
-    {
165
-        return $this->getItemColumnName('unit_name', 'Quantity');
166
-    }
167
-
168
-    public function price_name(): string
169
-    {
170
-        return $this->getItemColumnName('price_name', 'Price');
171
-    }
172
-
173
-    public function amount_name(): string
174
-    {
175
-        return $this->getItemColumnName('amount_name', 'Amount');
176
-    }
177
-
178
-    public function buildViewData(): array
179
-    {
180
-        return [
181
-            'logo' => $this->logo(),
182
-            'show_logo' => $this->show_logo(),
183
-            'company_name' => $this->company_name(),
184
-            'company_address' => $this->company_address(),
185
-            'company_phone' => $this->company_phone(),
186
-            'company_city' => $this->company_city(),
187
-            'company_state' => $this->company_state(),
188
-            'company_zip' => $this->company_zip(),
189
-            'company_country' => $this->company_country(),
190
-            'number_prefix' => $this->number_prefix(),
191
-            'number_digits' => $this->number_digits(),
192
-            'number_next' => $this->number_next(),
193
-            'invoice_number' => $this->invoice_number(),
194
-            'invoice_date' => $this->invoice_date(),
195
-            'invoice_due_date' => $this->invoice_due_date(),
196
-            'header' => $this->header(),
197
-            'subheader' => $this->subheader(),
198
-            'accent_color' => $this->accent_color(),
199
-            'font_family' => $this->fontFamily(),
200
-            'font_html' => $this->font($this->fontFamily())->getFontHtml(),
201
-            'footer' => $this->footer(),
202
-            'terms' => $this->terms(),
203
-            'item_name' => $this->item_name(),
204
-            'unit_name' => $this->unit_name(),
205
-            'price_name' => $this->price_name(),
206
-            'amount_name' => $this->amount_name(),
207
-        ];
208
-    }
209
-}

+ 0
- 1
composer.json 查看文件

@@ -25,7 +25,6 @@
25 25
         "laravel/framework": "^11.0",
26 26
         "laravel/sanctum": "^4.0",
27 27
         "laravel/tinker": "^2.9",
28
-        "spatie/laravel-view-models": "^1.6",
29 28
         "squirephp/model": "^3.4",
30 29
         "squirephp/repository": "^3.4",
31 30
         "symfony/intl": "^6.3"

+ 62
- 133
composer.lock 查看文件

@@ -4,7 +4,7 @@
4 4
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5 5
         "This file is @generated automatically"
6 6
     ],
7
-    "content-hash": "decc627f2a7bd0c6546114ebb1f500f9",
7
+    "content-hash": "095bb4040f9910ddd128bd53c0670a55",
8 8
     "packages": [
9 9
         {
10 10
             "name": "akaunting/laravel-money",
@@ -497,16 +497,16 @@
497 497
         },
498 498
         {
499 499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.15",
500
+            "version": "3.337.3",
501 501
             "source": {
502 502
                 "type": "git",
503 503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "f8028ef4b8dcb0acfe86c33e207fd3cb0b9cbf3b"
504
+                "reference": "06dfc8f76423b49aaa181debd25bbdc724c346d6"
505 505
             },
506 506
             "dist": {
507 507
                 "type": "zip",
508
-                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/f8028ef4b8dcb0acfe86c33e207fd3cb0b9cbf3b",
509
-                "reference": "f8028ef4b8dcb0acfe86c33e207fd3cb0b9cbf3b",
508
+                "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/06dfc8f76423b49aaa181debd25bbdc724c346d6",
509
+                "reference": "06dfc8f76423b49aaa181debd25bbdc724c346d6",
510 510
                 "shasum": ""
511 511
             },
512 512
             "require": {
@@ -589,9 +589,9 @@
589 589
             "support": {
590 590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591 591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
592
-                "source": "https://github.com/aws/aws-sdk-php/tree/3.336.15"
592
+                "source": "https://github.com/aws/aws-sdk-php/tree/3.337.3"
593 593
             },
594
-            "time": "2025-01-14T19:03:58+00:00"
594
+            "time": "2025-01-21T19:10:05+00:00"
595 595
         },
596 596
         {
597 597
             "name": "aws/aws-sdk-php-laravel",
@@ -1029,16 +1029,16 @@
1029 1029
         },
1030 1030
         {
1031 1031
             "name": "codewithdennis/filament-simple-alert",
1032
-            "version": "v3.0.15",
1032
+            "version": "v3.0.16",
1033 1033
             "source": {
1034 1034
                 "type": "git",
1035 1035
                 "url": "https://github.com/CodeWithDennis/filament-simple-alert.git",
1036
-                "reference": "8fd7dfa48bb98061bcc3f5fbaacb82dce7a09c7b"
1036
+                "reference": "f29677d3a0d2b6fd9b1c3627152cd0107d2db337"
1037 1037
             },
1038 1038
             "dist": {
1039 1039
                 "type": "zip",
1040
-                "url": "https://api.github.com/repos/CodeWithDennis/filament-simple-alert/zipball/8fd7dfa48bb98061bcc3f5fbaacb82dce7a09c7b",
1041
-                "reference": "8fd7dfa48bb98061bcc3f5fbaacb82dce7a09c7b",
1040
+                "url": "https://api.github.com/repos/CodeWithDennis/filament-simple-alert/zipball/f29677d3a0d2b6fd9b1c3627152cd0107d2db337",
1041
+                "reference": "f29677d3a0d2b6fd9b1c3627152cd0107d2db337",
1042 1042
                 "shasum": ""
1043 1043
             },
1044 1044
             "require": {
@@ -1098,7 +1098,7 @@
1098 1098
                     "type": "github"
1099 1099
                 }
1100 1100
             ],
1101
-            "time": "2024-12-03T16:17:47+00:00"
1101
+            "time": "2025-01-19T17:37:34+00:00"
1102 1102
         },
1103 1103
         {
1104 1104
             "name": "danharrin/date-format-converter",
@@ -1282,16 +1282,16 @@
1282 1282
         },
1283 1283
         {
1284 1284
             "name": "doctrine/dbal",
1285
-            "version": "4.2.1",
1285
+            "version": "4.2.2",
1286 1286
             "source": {
1287 1287
                 "type": "git",
1288 1288
                 "url": "https://github.com/doctrine/dbal.git",
1289
-                "reference": "dadd35300837a3a2184bd47d403333b15d0a9bd0"
1289
+                "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec"
1290 1290
             },
1291 1291
             "dist": {
1292 1292
                 "type": "zip",
1293
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/dadd35300837a3a2184bd47d403333b15d0a9bd0",
1294
-                "reference": "dadd35300837a3a2184bd47d403333b15d0a9bd0",
1293
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/19a2b7deb5fe8c2df0ff817ecea305e50acb62ec",
1294
+                "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec",
1295 1295
                 "shasum": ""
1296 1296
             },
1297 1297
             "require": {
@@ -1304,16 +1304,14 @@
1304 1304
                 "doctrine/coding-standard": "12.0.0",
1305 1305
                 "fig/log-test": "^1",
1306 1306
                 "jetbrains/phpstorm-stubs": "2023.2",
1307
-                "phpstan/phpstan": "1.12.6",
1308
-                "phpstan/phpstan-phpunit": "1.4.0",
1309
-                "phpstan/phpstan-strict-rules": "^1.6",
1310
-                "phpunit/phpunit": "10.5.30",
1311
-                "psalm/plugin-phpunit": "0.19.0",
1307
+                "phpstan/phpstan": "2.1.1",
1308
+                "phpstan/phpstan-phpunit": "2.0.3",
1309
+                "phpstan/phpstan-strict-rules": "^2",
1310
+                "phpunit/phpunit": "10.5.39",
1312 1311
                 "slevomat/coding-standard": "8.13.1",
1313 1312
                 "squizlabs/php_codesniffer": "3.10.2",
1314 1313
                 "symfony/cache": "^6.3.8|^7.0",
1315
-                "symfony/console": "^5.4|^6.3|^7.0",
1316
-                "vimeo/psalm": "5.25.0"
1314
+                "symfony/console": "^5.4|^6.3|^7.0"
1317 1315
             },
1318 1316
             "suggest": {
1319 1317
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
@@ -1370,7 +1368,7 @@
1370 1368
             ],
1371 1369
             "support": {
1372 1370
                 "issues": "https://github.com/doctrine/dbal/issues",
1373
-                "source": "https://github.com/doctrine/dbal/tree/4.2.1"
1371
+                "source": "https://github.com/doctrine/dbal/tree/4.2.2"
1374 1372
             },
1375 1373
             "funding": [
1376 1374
                 {
@@ -1386,7 +1384,7 @@
1386 1384
                     "type": "tidelift"
1387 1385
                 }
1388 1386
             ],
1389
-            "time": "2024-10-10T18:01:27+00:00"
1387
+            "time": "2025-01-16T08:40:56+00:00"
1390 1388
         },
1391 1389
         {
1392 1390
             "name": "doctrine/deprecations",
@@ -3053,16 +3051,16 @@
3053 3051
         },
3054 3052
         {
3055 3053
             "name": "laravel/framework",
3056
-            "version": "v11.38.2",
3054
+            "version": "v11.39.0",
3057 3055
             "source": {
3058 3056
                 "type": "git",
3059 3057
                 "url": "https://github.com/laravel/framework.git",
3060
-                "reference": "9d290aa90fcad44048bedca5219d2b872e98772a"
3058
+                "reference": "996c96955f78e8a2b26a24c490a1721cfb14574f"
3061 3059
             },
3062 3060
             "dist": {
3063 3061
                 "type": "zip",
3064
-                "url": "https://api.github.com/repos/laravel/framework/zipball/9d290aa90fcad44048bedca5219d2b872e98772a",
3065
-                "reference": "9d290aa90fcad44048bedca5219d2b872e98772a",
3062
+                "url": "https://api.github.com/repos/laravel/framework/zipball/996c96955f78e8a2b26a24c490a1721cfb14574f",
3063
+                "reference": "996c96955f78e8a2b26a24c490a1721cfb14574f",
3066 3064
                 "shasum": ""
3067 3065
             },
3068 3066
             "require": {
@@ -3263,7 +3261,7 @@
3263 3261
                 "issues": "https://github.com/laravel/framework/issues",
3264 3262
                 "source": "https://github.com/laravel/framework"
3265 3263
             },
3266
-            "time": "2025-01-15T00:06:46+00:00"
3264
+            "time": "2025-01-21T15:02:43+00:00"
3267 3265
         },
3268 3266
         {
3269 3267
             "name": "laravel/prompts",
@@ -3451,16 +3449,16 @@
3451 3449
         },
3452 3450
         {
3453 3451
             "name": "laravel/socialite",
3454
-            "version": "v5.16.1",
3452
+            "version": "v5.17.0",
3455 3453
             "source": {
3456 3454
                 "type": "git",
3457 3455
                 "url": "https://github.com/laravel/socialite.git",
3458
-                "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71"
3456
+                "reference": "77be8be7ee5099aed8ca7cfddc1bf6f9ab3fc159"
3459 3457
             },
3460 3458
             "dist": {
3461 3459
                 "type": "zip",
3462
-                "url": "https://api.github.com/repos/laravel/socialite/zipball/4e5be83c0b3ecf81b2ffa47092e917d1f79dce71",
3463
-                "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71",
3460
+                "url": "https://api.github.com/repos/laravel/socialite/zipball/77be8be7ee5099aed8ca7cfddc1bf6f9ab3fc159",
3461
+                "reference": "77be8be7ee5099aed8ca7cfddc1bf6f9ab3fc159",
3464 3462
                 "shasum": ""
3465 3463
             },
3466 3464
             "require": {
@@ -3519,7 +3517,7 @@
3519 3517
                 "issues": "https://github.com/laravel/socialite/issues",
3520 3518
                 "source": "https://github.com/laravel/socialite"
3521 3519
             },
3522
-            "time": "2024-12-11T16:43:51+00:00"
3520
+            "time": "2025-01-17T15:17:00+00:00"
3523 3521
         },
3524 3522
         {
3525 3523
             "name": "laravel/tinker",
@@ -4446,16 +4444,16 @@
4446 4444
         },
4447 4445
         {
4448 4446
             "name": "matomo/device-detector",
4449
-            "version": "6.4.2",
4447
+            "version": "6.4.3",
4450 4448
             "source": {
4451 4449
                 "type": "git",
4452 4450
                 "url": "https://github.com/matomo-org/device-detector.git",
4453
-                "reference": "806e52d214b05ddead1a1d4304c7592f61f95976"
4451
+                "reference": "aa4586d495a7f59029d46d976f160b13eb769bb0"
4454 4452
             },
4455 4453
             "dist": {
4456 4454
                 "type": "zip",
4457
-                "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/806e52d214b05ddead1a1d4304c7592f61f95976",
4458
-                "reference": "806e52d214b05ddead1a1d4304c7592f61f95976",
4455
+                "url": "https://api.github.com/repos/matomo-org/device-detector/zipball/aa4586d495a7f59029d46d976f160b13eb769bb0",
4456
+                "reference": "aa4586d495a7f59029d46d976f160b13eb769bb0",
4459 4457
                 "shasum": ""
4460 4458
             },
4461 4459
             "require": {
@@ -4511,7 +4509,7 @@
4511 4509
                 "source": "https://github.com/matomo-org/matomo",
4512 4510
                 "wiki": "https://dev.matomo.org/"
4513 4511
             },
4514
-            "time": "2024-12-16T16:38:01+00:00"
4512
+            "time": "2025-01-17T09:59:39+00:00"
4515 4513
         },
4516 4514
         {
4517 4515
             "name": "monolog/monolog",
@@ -6493,16 +6491,16 @@
6493 6491
         },
6494 6492
         {
6495 6493
             "name": "spatie/laravel-package-tools",
6496
-            "version": "1.18.0",
6494
+            "version": "1.18.2",
6497 6495
             "source": {
6498 6496
                 "type": "git",
6499 6497
                 "url": "https://github.com/spatie/laravel-package-tools.git",
6500
-                "reference": "8332205b90d17164913244f4a8e13ab7e6761d29"
6498
+                "reference": "d41c44a7eab604c3eb0cad93210612d4c1429c20"
6501 6499
             },
6502 6500
             "dist": {
6503 6501
                 "type": "zip",
6504
-                "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/8332205b90d17164913244f4a8e13ab7e6761d29",
6505
-                "reference": "8332205b90d17164913244f4a8e13ab7e6761d29",
6502
+                "url": "https://api.github.com/repos/spatie/laravel-package-tools/zipball/d41c44a7eab604c3eb0cad93210612d4c1429c20",
6503
+                "reference": "d41c44a7eab604c3eb0cad93210612d4c1429c20",
6506 6504
                 "shasum": ""
6507 6505
             },
6508 6506
             "require": {
@@ -6541,7 +6539,7 @@
6541 6539
             ],
6542 6540
             "support": {
6543 6541
                 "issues": "https://github.com/spatie/laravel-package-tools/issues",
6544
-                "source": "https://github.com/spatie/laravel-package-tools/tree/1.18.0"
6542
+                "source": "https://github.com/spatie/laravel-package-tools/tree/1.18.2"
6545 6543
             },
6546 6544
             "funding": [
6547 6545
                 {
@@ -6549,76 +6547,7 @@
6549 6547
                     "type": "github"
6550 6548
                 }
6551 6549
             ],
6552
-            "time": "2024-12-30T13:13:39+00:00"
6553
-        },
6554
-        {
6555
-            "name": "spatie/laravel-view-models",
6556
-            "version": "1.6.0",
6557
-            "source": {
6558
-                "type": "git",
6559
-                "url": "https://github.com/spatie/laravel-view-models.git",
6560
-                "reference": "c8c74e26e2cc78d04e581867ce74c8b772279015"
6561
-            },
6562
-            "dist": {
6563
-                "type": "zip",
6564
-                "url": "https://api.github.com/repos/spatie/laravel-view-models/zipball/c8c74e26e2cc78d04e581867ce74c8b772279015",
6565
-                "reference": "c8c74e26e2cc78d04e581867ce74c8b772279015",
6566
-                "shasum": ""
6567
-            },
6568
-            "require": {
6569
-                "illuminate/support": "^8.0|^9.0|^10.0|^11.0",
6570
-                "php": "^7.3|^8.0"
6571
-            },
6572
-            "require-dev": {
6573
-                "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0",
6574
-                "pestphp/pest": "^1.22|^2.34"
6575
-            },
6576
-            "type": "library",
6577
-            "extra": {
6578
-                "laravel": {
6579
-                    "providers": [
6580
-                        "Spatie\\ViewModels\\Providers\\ViewModelsServiceProvider"
6581
-                    ]
6582
-                }
6583
-            },
6584
-            "autoload": {
6585
-                "psr-4": {
6586
-                    "Spatie\\ViewModels\\": "src"
6587
-                }
6588
-            },
6589
-            "notification-url": "https://packagist.org/downloads/",
6590
-            "license": [
6591
-                "MIT"
6592
-            ],
6593
-            "authors": [
6594
-                {
6595
-                    "name": "Brent Roose",
6596
-                    "email": "brent@spatie.be",
6597
-                    "homepage": "https://spatie.be",
6598
-                    "role": "Developer"
6599
-                }
6600
-            ],
6601
-            "description": "View models in Laravel",
6602
-            "homepage": "https://github.com/spatie/laravel-view-models",
6603
-            "keywords": [
6604
-                "laravel-view-models",
6605
-                "spatie"
6606
-            ],
6607
-            "support": {
6608
-                "issues": "https://github.com/spatie/laravel-view-models/issues",
6609
-                "source": "https://github.com/spatie/laravel-view-models/tree/1.6.0"
6610
-            },
6611
-            "funding": [
6612
-                {
6613
-                    "url": "https://spatie.be/open-source/support-us",
6614
-                    "type": "custom"
6615
-                },
6616
-                {
6617
-                    "url": "https://github.com/spatie",
6618
-                    "type": "github"
6619
-                }
6620
-            ],
6621
-            "time": "2024-03-13T17:58:20+00:00"
6550
+            "time": "2025-01-20T14:14:17+00:00"
6622 6551
         },
6623 6552
         {
6624 6553
             "name": "squirephp/model",
@@ -10151,16 +10080,16 @@
10151 10080
         },
10152 10081
         {
10153 10082
             "name": "pestphp/pest",
10154
-            "version": "v3.7.1",
10083
+            "version": "v3.7.2",
10155 10084
             "source": {
10156 10085
                 "type": "git",
10157 10086
                 "url": "https://github.com/pestphp/pest.git",
10158
-                "reference": "bf3178473dcaa53b0458f21dfdb271306ea62512"
10087
+                "reference": "709ecb1ba2641fc0c4653ebe1fd8a402bbf4d18b"
10159 10088
             },
10160 10089
             "dist": {
10161 10090
                 "type": "zip",
10162
-                "url": "https://api.github.com/repos/pestphp/pest/zipball/bf3178473dcaa53b0458f21dfdb271306ea62512",
10163
-                "reference": "bf3178473dcaa53b0458f21dfdb271306ea62512",
10091
+                "url": "https://api.github.com/repos/pestphp/pest/zipball/709ecb1ba2641fc0c4653ebe1fd8a402bbf4d18b",
10092
+                "reference": "709ecb1ba2641fc0c4653ebe1fd8a402bbf4d18b",
10164 10093
                 "shasum": ""
10165 10094
             },
10166 10095
             "require": {
@@ -10171,17 +10100,17 @@
10171 10100
                 "pestphp/pest-plugin-arch": "^3.0.0",
10172 10101
                 "pestphp/pest-plugin-mutate": "^3.0.5",
10173 10102
                 "php": "^8.2.0",
10174
-                "phpunit/phpunit": "^11.5.1"
10103
+                "phpunit/phpunit": "^11.5.3"
10175 10104
             },
10176 10105
             "conflict": {
10177 10106
                 "filp/whoops": "<2.16.0",
10178
-                "phpunit/phpunit": ">11.5.1",
10107
+                "phpunit/phpunit": ">11.5.3",
10179 10108
                 "sebastian/exporter": "<6.0.0",
10180 10109
                 "webmozart/assert": "<1.11.0"
10181 10110
             },
10182 10111
             "require-dev": {
10183 10112
                 "pestphp/pest-dev-tools": "^3.3.0",
10184
-                "pestphp/pest-plugin-type-coverage": "^3.2.0",
10113
+                "pestphp/pest-plugin-type-coverage": "^3.2.3",
10185 10114
                 "symfony/process": "^7.2.0"
10186 10115
             },
10187 10116
             "bin": [
@@ -10247,7 +10176,7 @@
10247 10176
             ],
10248 10177
             "support": {
10249 10178
                 "issues": "https://github.com/pestphp/pest/issues",
10250
-                "source": "https://github.com/pestphp/pest/tree/v3.7.1"
10179
+                "source": "https://github.com/pestphp/pest/tree/v3.7.2"
10251 10180
             },
10252 10181
             "funding": [
10253 10182
                 {
@@ -10259,7 +10188,7 @@
10259 10188
                     "type": "github"
10260 10189
                 }
10261 10190
             ],
10262
-            "time": "2024-12-12T11:52:01+00:00"
10191
+            "time": "2025-01-19T17:35:09+00:00"
10263 10192
         },
10264 10193
         {
10265 10194
             "name": "pestphp/pest-plugin",
@@ -11204,16 +11133,16 @@
11204 11133
         },
11205 11134
         {
11206 11135
             "name": "phpunit/phpunit",
11207
-            "version": "11.5.1",
11136
+            "version": "11.5.3",
11208 11137
             "source": {
11209 11138
                 "type": "git",
11210 11139
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
11211
-                "reference": "2b94d4f2450b9869fa64a46fd8a6a41997aef56a"
11140
+                "reference": "30e319e578a7b5da3543073e30002bf82042f701"
11212 11141
             },
11213 11142
             "dist": {
11214 11143
                 "type": "zip",
11215
-                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2b94d4f2450b9869fa64a46fd8a6a41997aef56a",
11216
-                "reference": "2b94d4f2450b9869fa64a46fd8a6a41997aef56a",
11144
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/30e319e578a7b5da3543073e30002bf82042f701",
11145
+                "reference": "30e319e578a7b5da3543073e30002bf82042f701",
11217 11146
                 "shasum": ""
11218 11147
             },
11219 11148
             "require": {
@@ -11227,14 +11156,14 @@
11227 11156
                 "phar-io/manifest": "^2.0.4",
11228 11157
                 "phar-io/version": "^3.2.1",
11229 11158
                 "php": ">=8.2",
11230
-                "phpunit/php-code-coverage": "^11.0.7",
11159
+                "phpunit/php-code-coverage": "^11.0.8",
11231 11160
                 "phpunit/php-file-iterator": "^5.1.0",
11232 11161
                 "phpunit/php-invoker": "^5.0.1",
11233 11162
                 "phpunit/php-text-template": "^4.0.1",
11234 11163
                 "phpunit/php-timer": "^7.0.1",
11235 11164
                 "sebastian/cli-parser": "^3.0.2",
11236
-                "sebastian/code-unit": "^3.0.1",
11237
-                "sebastian/comparator": "^6.2.1",
11165
+                "sebastian/code-unit": "^3.0.2",
11166
+                "sebastian/comparator": "^6.3.0",
11238 11167
                 "sebastian/diff": "^6.0.2",
11239 11168
                 "sebastian/environment": "^7.2.0",
11240 11169
                 "sebastian/exporter": "^6.3.0",
@@ -11285,7 +11214,7 @@
11285 11214
             "support": {
11286 11215
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
11287 11216
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
11288
-                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.1"
11217
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.3"
11289 11218
             },
11290 11219
             "funding": [
11291 11220
                 {
@@ -11301,7 +11230,7 @@
11301 11230
                     "type": "tidelift"
11302 11231
                 }
11303 11232
             ],
11304
-            "time": "2024-12-11T10:52:48+00:00"
11233
+            "time": "2025-01-13T09:36:00+00:00"
11305 11234
         },
11306 11235
         {
11307 11236
             "name": "pimple/pimple",

+ 1
- 4
config/aws.php 查看文件

@@ -16,10 +16,7 @@ return [
16 16
     | http://docs.aws.amazon.com/aws-sdk-php/v3/guide/guide/configuration.html
17 17
     |
18 18
     */
19
-    'credentials' => [
20
-        'key' => env('AWS_ACCESS_KEY_ID', ''),
21
-        'secret' => env('AWS_SECRET_ACCESS_KEY', ''),
22
-    ],
19
+    'credentials' => false,
23 20
     'region' => env('AWS_REGION', 'us-east-1'),
24 21
     'version' => 'latest',
25 22
     'ua_append' => [

+ 5
- 5
database/factories/Accounting/RecurringInvoiceFactory.php 查看文件

@@ -90,7 +90,7 @@ class RecurringInvoiceFactory extends Factory
90 90
         });
91 91
     }
92 92
 
93
-    protected function withDailySchedule(Carbon $startDate, EndType $endType): static
93
+    public function withDailySchedule(Carbon $startDate, EndType $endType): static
94 94
     {
95 95
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
96 96
             $this->ensureLineItems($recurringInvoice);
@@ -103,7 +103,7 @@ class RecurringInvoiceFactory extends Factory
103 103
         });
104 104
     }
105 105
 
106
-    protected function withWeeklySchedule(Carbon $startDate, EndType $endType): static
106
+    public function withWeeklySchedule(Carbon $startDate, EndType $endType): static
107 107
     {
108 108
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
109 109
             $this->ensureLineItems($recurringInvoice);
@@ -117,7 +117,7 @@ class RecurringInvoiceFactory extends Factory
117 117
         });
118 118
     }
119 119
 
120
-    protected function withMonthlySchedule(Carbon $startDate, EndType $endType): static
120
+    public function withMonthlySchedule(Carbon $startDate, EndType $endType): static
121 121
     {
122 122
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
123 123
             $this->ensureLineItems($recurringInvoice);
@@ -131,7 +131,7 @@ class RecurringInvoiceFactory extends Factory
131 131
         });
132 132
     }
133 133
 
134
-    protected function withYearlySchedule(Carbon $startDate, EndType $endType): static
134
+    public function withYearlySchedule(Carbon $startDate, EndType $endType): static
135 135
     {
136 136
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
137 137
             $this->ensureLineItems($recurringInvoice);
@@ -146,7 +146,7 @@ class RecurringInvoiceFactory extends Factory
146 146
         });
147 147
     }
148 148
 
149
-    protected function withCustomSchedule(
149
+    public function withCustomSchedule(
150 150
         Carbon $startDate,
151 151
         EndType $endType,
152 152
         ?IntervalType $intervalType = null,

+ 2
- 2
database/factories/Common/AddressFactory.php 查看文件

@@ -31,9 +31,9 @@ class AddressFactory extends Factory
31 31
             'address_line_1' => $this->faker->streetAddress,
32 32
             'address_line_2' => $this->faker->streetAddress,
33 33
             'city' => $this->faker->city,
34
-            'state' => $this->faker->state('US'),
34
+            'state_id' => $this->faker->state('US'),
35 35
             'postal_code' => $this->faker->postcode,
36
-            'country' => 'US',
36
+            'country_code' => 'US',
37 37
             'notes' => $this->faker->sentence,
38 38
             'created_by' => 1,
39 39
             'updated_by' => 1,

+ 6
- 10
database/factories/Common/ContactFactory.php 查看文件

@@ -71,19 +71,15 @@ class ContactFactory extends Factory
71 71
 
72 72
     public function primary(): self
73 73
     {
74
-        return $this->state(function (array $attributes) {
75
-            return [
76
-                'is_primary' => true,
77
-            ];
78
-        });
74
+        return $this->state([
75
+            'is_primary' => true,
76
+        ]);
79 77
     }
80 78
 
81 79
     public function secondary(): self
82 80
     {
83
-        return $this->state(function (array $attributes) {
84
-            return [
85
-                'is_primary' => false,
86
-            ];
87
-        });
81
+        return $this->state([
82
+            'is_primary' => false,
83
+        ]);
88 84
     }
89 85
 }

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

@@ -42,7 +42,7 @@ class CompanyFactory extends Factory
42 42
     public function withCompanyProfile(): self
43 43
     {
44 44
         return $this->afterCreating(function (Company $company) {
45
-            CompanyProfile::factory()->forCompany($company)->withCountry('US')->create();
45
+            CompanyProfile::factory()->forCompany($company)->withAddress()->create();
46 46
         });
47 47
     }
48 48
 
@@ -52,7 +52,7 @@ class CompanyFactory extends Factory
52 52
     public function withCompanyDefaults(): self
53 53
     {
54 54
         return $this->afterCreating(function (Company $company) {
55
-            $countryCode = $company->profile->country;
55
+            $countryCode = $company->profile->address->country_code;
56 56
             $companyDefaultService = app(CompanyDefaultService::class);
57 57
             $companyDefaultService->createCompanyDefaults($company, $company->owner, 'USD', $countryCode, 'en');
58 58
         });

+ 7
- 16
database/factories/Setting/CompanyProfileFactory.php 查看文件

@@ -4,6 +4,7 @@ namespace Database\Factories\Setting;
4 4
 
5 5
 use App\Enums\Setting\EntityType;
6 6
 use App\Faker\State;
7
+use App\Models\Common\Address;
7 8
 use App\Models\Company;
8 9
 use App\Models\Setting\CompanyProfile;
9 10
 use Illuminate\Database\Eloquent\Factories\Factory;
@@ -25,28 +26,13 @@ class CompanyProfileFactory extends Factory
25 26
      */
26 27
     public function definition(): array
27 28
     {
28
-        $countryCode = $this->faker->countryCode;
29
-
30 29
         return [
31
-            'address' => $this->faker->streetAddress,
32
-            'zip_code' => $this->faker->postcode,
33
-            'state_id' => $this->faker->state($countryCode),
34
-            'country' => $countryCode,
35
-            'phone_number' => $this->faker->phoneNumberForCountryCode($countryCode),
30
+            'phone_number' => $this->faker->phoneNumber,
36 31
             'email' => $this->faker->email,
37 32
             'entity_type' => $this->faker->randomElement(EntityType::class),
38 33
         ];
39 34
     }
40 35
 
41
-    public function withCountry(string $code): self
42
-    {
43
-        return $this->state([
44
-            'country' => $code,
45
-            'state_id' => $this->faker->state($code),
46
-            'phone_number' => $this->faker->phoneNumberForCountryCode($code),
47
-        ]);
48
-    }
49
-
50 36
     public function forCompany(Company $company): self
51 37
     {
52 38
         return $this->state([
@@ -55,4 +41,9 @@ class CompanyProfileFactory extends Factory
55 41
             'updated_by' => $company->owner->id,
56 42
         ]);
57 43
     }
44
+
45
+    public function withAddress(): self
46
+    {
47
+        return $this->has(Address::factory()->general());
48
+    }
58 49
 }

+ 4
- 8
database/migrations/2023_09_03_100000_create_accounting_tables.php 查看文件

@@ -51,8 +51,6 @@ return new class extends Migration
51 51
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
52 52
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
53 53
             $table->timestamps();
54
-
55
-            $table->unique(['company_id', 'code']);
56 54
         });
57 55
 
58 56
         Schema::create('bank_accounts', function (Blueprint $table) {
@@ -66,8 +64,6 @@ return new class extends Migration
66 64
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
67 65
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
68 66
             $table->timestamps();
69
-
70
-            $table->unique(['company_id', 'account_id']);
71 67
         });
72 68
 
73 69
         Schema::create('connected_bank_accounts', function (Blueprint $table) {
@@ -98,10 +94,10 @@ return new class extends Migration
98 94
      */
99 95
     public function down(): void
100 96
     {
101
-        Schema::dropIfExists('institutions');
102
-        Schema::dropIfExists('account_subtypes');
103
-        Schema::dropIfExists('accounts');
104
-        Schema::dropIfExists('bank_accounts');
105 97
         Schema::dropIfExists('connected_bank_accounts');
98
+        Schema::dropIfExists('bank_accounts');
99
+        Schema::dropIfExists('accounts');
100
+        Schema::dropIfExists('account_subtypes');
101
+        Schema::dropIfExists('institutions');
106 102
     }
107 103
 };

+ 0
- 5
database/migrations/2023_09_14_034800_create_company_profiles_table.php 查看文件

@@ -15,11 +15,6 @@ return new class extends Migration
15 15
             $table->id();
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->string('logo')->nullable();
18
-            $table->string('address', 255)->nullable();
19
-            $table->unsignedMediumInteger('city_id')->nullable()->index();
20
-            $table->string('zip_code', 20)->nullable();
21
-            $table->unsignedSmallInteger('state_id')->nullable()->index();
22
-            $table->string('country')->nullable();
23 18
             $table->string('phone_number', 30)->nullable();
24 19
             $table->string('email', 255)->nullable();
25 20
             $table->string('tax_id', 50)->nullable();

+ 0
- 2
database/migrations/2024_11_14_230753_create_adjustments_table.php 查看文件

@@ -28,8 +28,6 @@ return new class extends Migration
28 28
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29 29
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
30 30
             $table->timestamps();
31
-
32
-            $table->unique(['company_id', 'account_id']);
33 31
         });
34 32
     }
35 33
 

+ 5
- 4
database/migrations/2024_11_19_225812_create_addresses_table.php 查看文件

@@ -14,16 +14,17 @@ return new class extends Migration
14 14
         Schema::create('addresses', function (Blueprint $table) {
15 15
             $table->id();
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('parent_address_id')->nullable()->constrained('addresses')->nullOnDelete();
17 18
             $table->morphs('addressable');
18 19
             $table->string('type'); // billing, shipping, etc.
19 20
             $table->string('recipient')->nullable();
20 21
             $table->string('phone')->nullable();
21
-            $table->string('address_line_1');
22
+            $table->string('address_line_1')->nullable();
22 23
             $table->string('address_line_2')->nullable();
23
-            $table->string('city');
24
-            $table->string('state')->nullable();
24
+            $table->string('city')->nullable();
25
+            $table->smallInteger('state_id')->nullable();
25 26
             $table->string('postal_code')->nullable();
26
-            $table->string('country')->nullable();
27
+            $table->string('country_code');
27 28
             $table->text('notes')->nullable();
28 29
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29 30
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();

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

@@ -575,9 +575,9 @@
575 575
             }
576 576
         },
577 577
         "node_modules/@rollup/rollup-android-arm-eabi": {
578
-            "version": "4.30.1",
579
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.30.1.tgz",
580
-            "integrity": "sha512-pSWY+EVt3rJ9fQ3IqlrEUtXh3cGqGtPDH1FQlNZehO2yYxCHEX1SPsz1M//NXwYfbTlcKr9WObLnJX9FsS9K1Q==",
578
+            "version": "4.31.0",
579
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.31.0.tgz",
580
+            "integrity": "sha512-9NrR4033uCbUBRgvLcBrJofa2KY9DzxL2UKZ1/4xA/mnTNyhZCWBuD8X3tPm1n4KxcgaraOYgrFKSgwjASfmlA==",
581 581
             "cpu": [
582 582
                 "arm"
583 583
             ],
@@ -589,9 +589,9 @@
589 589
             ]
590 590
         },
591 591
         "node_modules/@rollup/rollup-android-arm64": {
592
-            "version": "4.30.1",
593
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.30.1.tgz",
594
-            "integrity": "sha512-/NA2qXxE3D/BRjOJM8wQblmArQq1YoBVJjrjoTSBS09jgUisq7bqxNHJ8kjCHeV21W/9WDGwJEWSN0KQ2mtD/w==",
592
+            "version": "4.31.0",
593
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.31.0.tgz",
594
+            "integrity": "sha512-iBbODqT86YBFHajxxF8ebj2hwKm1k8PTBQSojSt3d1FFt1gN+xf4CowE47iN0vOSdnd+5ierMHBbu/rHc7nq5g==",
595 595
             "cpu": [
596 596
                 "arm64"
597 597
             ],
@@ -603,9 +603,9 @@
603 603
             ]
604 604
         },
605 605
         "node_modules/@rollup/rollup-darwin-arm64": {
606
-            "version": "4.30.1",
607
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.30.1.tgz",
608
-            "integrity": "sha512-r7FQIXD7gB0WJ5mokTUgUWPl0eYIH0wnxqeSAhuIwvnnpjdVB8cRRClyKLQr7lgzjctkbp5KmswWszlwYln03Q==",
606
+            "version": "4.31.0",
607
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.31.0.tgz",
608
+            "integrity": "sha512-WHIZfXgVBX30SWuTMhlHPXTyN20AXrLH4TEeH/D0Bolvx9PjgZnn4H677PlSGvU6MKNsjCQJYczkpvBbrBnG6g==",
609 609
             "cpu": [
610 610
                 "arm64"
611 611
             ],
@@ -617,9 +617,9 @@
617 617
             ]
618 618
         },
619 619
         "node_modules/@rollup/rollup-darwin-x64": {
620
-            "version": "4.30.1",
621
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.30.1.tgz",
622
-            "integrity": "sha512-x78BavIwSH6sqfP2xeI1hd1GpHL8J4W2BXcVM/5KYKoAD3nNsfitQhvWSw+TFtQTLZ9OmlF+FEInEHyubut2OA==",
620
+            "version": "4.31.0",
621
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.31.0.tgz",
622
+            "integrity": "sha512-hrWL7uQacTEF8gdrQAqcDy9xllQ0w0zuL1wk1HV8wKGSGbKPVjVUv/DEwT2+Asabf8Dh/As+IvfdU+H8hhzrQQ==",
623 623
             "cpu": [
624 624
                 "x64"
625 625
             ],
@@ -631,9 +631,9 @@
631 631
             ]
632 632
         },
633 633
         "node_modules/@rollup/rollup-freebsd-arm64": {
634
-            "version": "4.30.1",
635
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.30.1.tgz",
636
-            "integrity": "sha512-HYTlUAjbO1z8ywxsDFWADfTRfTIIy/oUlfIDmlHYmjUP2QRDTzBuWXc9O4CXM+bo9qfiCclmHk1x4ogBjOUpUQ==",
634
+            "version": "4.31.0",
635
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.31.0.tgz",
636
+            "integrity": "sha512-S2oCsZ4hJviG1QjPY1h6sVJLBI6ekBeAEssYKad1soRFv3SocsQCzX6cwnk6fID6UQQACTjeIMB+hyYrFacRew==",
637 637
             "cpu": [
638 638
                 "arm64"
639 639
             ],
@@ -645,9 +645,9 @@
645 645
             ]
646 646
         },
647 647
         "node_modules/@rollup/rollup-freebsd-x64": {
648
-            "version": "4.30.1",
649
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.30.1.tgz",
650
-            "integrity": "sha512-1MEdGqogQLccphhX5myCJqeGNYTNcmTyaic9S7CG3JhwuIByJ7J05vGbZxsizQthP1xpVx7kd3o31eOogfEirw==",
648
+            "version": "4.31.0",
649
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.31.0.tgz",
650
+            "integrity": "sha512-pCANqpynRS4Jirn4IKZH4tnm2+2CqCNLKD7gAdEjzdLGbH1iO0zouHz4mxqg0uEMpO030ejJ0aA6e1PJo2xrPA==",
651 651
             "cpu": [
652 652
                 "x64"
653 653
             ],
@@ -659,9 +659,9 @@
659 659
             ]
660 660
         },
661 661
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
662
-            "version": "4.30.1",
663
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.30.1.tgz",
664
-            "integrity": "sha512-PaMRNBSqCx7K3Wc9QZkFx5+CX27WFpAMxJNiYGAXfmMIKC7jstlr32UhTgK6T07OtqR+wYlWm9IxzennjnvdJg==",
662
+            "version": "4.31.0",
663
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.31.0.tgz",
664
+            "integrity": "sha512-0O8ViX+QcBd3ZmGlcFTnYXZKGbFu09EhgD27tgTdGnkcYXLat4KIsBBQeKLR2xZDCXdIBAlWLkiXE1+rJpCxFw==",
665 665
             "cpu": [
666 666
                 "arm"
667 667
             ],
@@ -673,9 +673,9 @@
673 673
             ]
674 674
         },
675 675
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
676
-            "version": "4.30.1",
677
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.30.1.tgz",
678
-            "integrity": "sha512-B8Rcyj9AV7ZlEFqvB5BubG5iO6ANDsRKlhIxySXcF1axXYUyqwBok+XZPgIYGBgs7LDXfWfifxhw0Ik57T0Yug==",
676
+            "version": "4.31.0",
677
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.31.0.tgz",
678
+            "integrity": "sha512-w5IzG0wTVv7B0/SwDnMYmbr2uERQp999q8FMkKG1I+j8hpPX2BYFjWe69xbhbP6J9h2gId/7ogesl9hwblFwwg==",
679 679
             "cpu": [
680 680
                 "arm"
681 681
             ],
@@ -687,9 +687,9 @@
687 687
             ]
688 688
         },
689 689
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
690
-            "version": "4.30.1",
691
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.30.1.tgz",
692
-            "integrity": "sha512-hqVyueGxAj3cBKrAI4aFHLV+h0Lv5VgWZs9CUGqr1z0fZtlADVV1YPOij6AhcK5An33EXaxnDLmJdQikcn5NEw==",
690
+            "version": "4.31.0",
691
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.31.0.tgz",
692
+            "integrity": "sha512-JyFFshbN5xwy6fulZ8B/8qOqENRmDdEkcIMF0Zz+RsfamEW+Zabl5jAb0IozP/8UKnJ7g2FtZZPEUIAlUSX8cA==",
693 693
             "cpu": [
694 694
                 "arm64"
695 695
             ],
@@ -701,9 +701,9 @@
701 701
             ]
702 702
         },
703 703
         "node_modules/@rollup/rollup-linux-arm64-musl": {
704
-            "version": "4.30.1",
705
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.30.1.tgz",
706
-            "integrity": "sha512-i4Ab2vnvS1AE1PyOIGp2kXni69gU2DAUVt6FSXeIqUCPIR3ZlheMW3oP2JkukDfu3PsexYRbOiJrY+yVNSk9oA==",
704
+            "version": "4.31.0",
705
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.31.0.tgz",
706
+            "integrity": "sha512-kpQXQ0UPFeMPmPYksiBL9WS/BDiQEjRGMfklVIsA0Sng347H8W2iexch+IEwaR7OVSKtr2ZFxggt11zVIlZ25g==",
707 707
             "cpu": [
708 708
                 "arm64"
709 709
             ],
@@ -715,9 +715,9 @@
715 715
             ]
716 716
         },
717 717
         "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
718
-            "version": "4.30.1",
719
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.30.1.tgz",
720
-            "integrity": "sha512-fARcF5g296snX0oLGkVxPmysetwUk2zmHcca+e9ObOovBR++9ZPOhqFUM61UUZ2EYpXVPN1redgqVoBB34nTpQ==",
718
+            "version": "4.31.0",
719
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.31.0.tgz",
720
+            "integrity": "sha512-pMlxLjt60iQTzt9iBb3jZphFIl55a70wexvo8p+vVFK+7ifTRookdoXX3bOsRdmfD+OKnMozKO6XM4zR0sHRrQ==",
721 721
             "cpu": [
722 722
                 "loong64"
723 723
             ],
@@ -729,9 +729,9 @@
729 729
             ]
730 730
         },
731 731
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
732
-            "version": "4.30.1",
733
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.30.1.tgz",
734
-            "integrity": "sha512-GLrZraoO3wVT4uFXh67ElpwQY0DIygxdv0BNW9Hkm3X34wu+BkqrDrkcsIapAY+N2ATEbvak0XQ9gxZtCIA5Rw==",
732
+            "version": "4.31.0",
733
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.31.0.tgz",
734
+            "integrity": "sha512-D7TXT7I/uKEuWiRkEFbed1UUYZwcJDU4vZQdPTcepK7ecPhzKOYk4Er2YR4uHKme4qDeIh6N3XrLfpuM7vzRWQ==",
735 735
             "cpu": [
736 736
                 "ppc64"
737 737
             ],
@@ -743,9 +743,9 @@
743 743
             ]
744 744
         },
745 745
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
746
-            "version": "4.30.1",
747
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.30.1.tgz",
748
-            "integrity": "sha512-0WKLaAUUHKBtll0wvOmh6yh3S0wSU9+yas923JIChfxOaaBarmb/lBKPF0w/+jTVozFnOXJeRGZ8NvOxvk/jcw==",
746
+            "version": "4.31.0",
747
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.31.0.tgz",
748
+            "integrity": "sha512-wal2Tc8O5lMBtoePLBYRKj2CImUCJ4UNGJlLwspx7QApYny7K1cUYlzQ/4IGQBLmm+y0RS7dwc3TDO/pmcneTw==",
749 749
             "cpu": [
750 750
                 "riscv64"
751 751
             ],
@@ -757,9 +757,9 @@
757 757
             ]
758 758
         },
759 759
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
760
-            "version": "4.30.1",
761
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.30.1.tgz",
762
-            "integrity": "sha512-GWFs97Ruxo5Bt+cvVTQkOJ6TIx0xJDD/bMAOXWJg8TCSTEK8RnFeOeiFTxKniTc4vMIaWvCplMAFBt9miGxgkA==",
760
+            "version": "4.31.0",
761
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.31.0.tgz",
762
+            "integrity": "sha512-O1o5EUI0+RRMkK9wiTVpk2tyzXdXefHtRTIjBbmFREmNMy7pFeYXCFGbhKFwISA3UOExlo5GGUuuj3oMKdK6JQ==",
763 763
             "cpu": [
764 764
                 "s390x"
765 765
             ],
@@ -771,9 +771,9 @@
771 771
             ]
772 772
         },
773 773
         "node_modules/@rollup/rollup-linux-x64-gnu": {
774
-            "version": "4.30.1",
775
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.30.1.tgz",
776
-            "integrity": "sha512-UtgGb7QGgXDIO+tqqJ5oZRGHsDLO8SlpE4MhqpY9Llpzi5rJMvrK6ZGhsRCST2abZdBqIBeXW6WPD5fGK5SDwg==",
774
+            "version": "4.31.0",
775
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.31.0.tgz",
776
+            "integrity": "sha512-zSoHl356vKnNxwOWnLd60ixHNPRBglxpv2g7q0Cd3Pmr561gf0HiAcUBRL3S1vPqRC17Zo2CX/9cPkqTIiai1g==",
777 777
             "cpu": [
778 778
                 "x64"
779 779
             ],
@@ -785,9 +785,9 @@
785 785
             ]
786 786
         },
787 787
         "node_modules/@rollup/rollup-linux-x64-musl": {
788
-            "version": "4.30.1",
789
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.30.1.tgz",
790
-            "integrity": "sha512-V9U8Ey2UqmQsBT+xTOeMzPzwDzyXmnAoO4edZhL7INkwQcaW1Ckv3WJX3qrrp/VHaDkEWIBWhRwP47r8cdrOow==",
788
+            "version": "4.31.0",
789
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.31.0.tgz",
790
+            "integrity": "sha512-ypB/HMtcSGhKUQNiFwqgdclWNRrAYDH8iMYH4etw/ZlGwiTVxBz2tDrGRrPlfZu6QjXwtd+C3Zib5pFqID97ZA==",
791 791
             "cpu": [
792 792
                 "x64"
793 793
             ],
@@ -799,9 +799,9 @@
799 799
             ]
800 800
         },
801 801
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
802
-            "version": "4.30.1",
803
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.30.1.tgz",
804
-            "integrity": "sha512-WabtHWiPaFF47W3PkHnjbmWawnX/aE57K47ZDT1BXTS5GgrBUEpvOzq0FI0V/UYzQJgdb8XlhVNH8/fwV8xDjw==",
802
+            "version": "4.31.0",
803
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.31.0.tgz",
804
+            "integrity": "sha512-JuhN2xdI/m8Hr+aVO3vspO7OQfUFO6bKLIRTAy0U15vmWjnZDLrEgCZ2s6+scAYaQVpYSh9tZtRijApw9IXyMw==",
805 805
             "cpu": [
806 806
                 "arm64"
807 807
             ],
@@ -813,9 +813,9 @@
813 813
             ]
814 814
         },
815 815
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
816
-            "version": "4.30.1",
817
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.30.1.tgz",
818
-            "integrity": "sha512-pxHAU+Zv39hLUTdQQHUVHf4P+0C47y/ZloorHpzs2SXMRqeAWmGghzAhfOlzFHHwjvgokdFAhC4V+6kC1lRRfw==",
816
+            "version": "4.31.0",
817
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.31.0.tgz",
818
+            "integrity": "sha512-U1xZZXYkvdf5MIWmftU8wrM5PPXzyaY1nGCI4KI4BFfoZxHamsIe+BtnPLIvvPykvQWlVbqUXdLa4aJUuilwLQ==",
819 819
             "cpu": [
820 820
                 "ia32"
821 821
             ],
@@ -827,9 +827,9 @@
827 827
             ]
828 828
         },
829 829
         "node_modules/@rollup/rollup-win32-x64-msvc": {
830
-            "version": "4.30.1",
831
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.30.1.tgz",
832
-            "integrity": "sha512-D6qjsXGcvhTjv0kI4fU8tUuBDF/Ueee4SVX79VfNDXZa64TfCW1Slkb6Z7O1p7vflqZjcmOVdZlqf8gvJxc6og==",
830
+            "version": "4.31.0",
831
+            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.31.0.tgz",
832
+            "integrity": "sha512-ul8rnCsUumNln5YWwz0ted2ZHFhzhRRnkpBZ+YRuHoRAlUji9KChpOUOndY7uykrPEPXVbHLlsdo6v5yXo/TXw==",
833 833
             "cpu": [
834 834
                 "x64"
835 835
             ],
@@ -1074,9 +1074,9 @@
1074 1074
             }
1075 1075
         },
1076 1076
         "node_modules/caniuse-lite": {
1077
-            "version": "1.0.30001692",
1078
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz",
1079
-            "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==",
1077
+            "version": "1.0.30001695",
1078
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001695.tgz",
1079
+            "integrity": "sha512-vHyLade6wTgI2u1ec3WQBxv+2BrTERV28UXQu9LO6lZ9pYeMk34vjXFLOxo1A4UBA8XTL4njRQZdno/yYaSmWw==",
1080 1080
             "dev": true,
1081 1081
             "funding": [
1082 1082
                 {
@@ -1235,9 +1235,9 @@
1235 1235
             "license": "MIT"
1236 1236
         },
1237 1237
         "node_modules/electron-to-chromium": {
1238
-            "version": "1.5.82",
1239
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.82.tgz",
1240
-            "integrity": "sha512-Zq16uk1hfQhyGx5GpwPAYDwddJuSGhtRhgOA2mCxANYaDT79nAeGnaXogMGng4KqLaJUVnOnuL0+TDop9nLOiA==",
1238
+            "version": "1.5.84",
1239
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.84.tgz",
1240
+            "integrity": "sha512-I+DQ8xgafao9Ha6y0qjHHvpZ9OfyA1qKlkHkjywxzniORU2awxyz7f/iVJcULmrF2yrM3nHQf+iDjJtbbexd/g==",
1241 1241
             "dev": true,
1242 1242
             "license": "ISC"
1243 1243
         },
@@ -1597,9 +1597,9 @@
1597 1597
             }
1598 1598
         },
1599 1599
         "node_modules/laravel-vite-plugin": {
1600
-            "version": "1.1.1",
1601
-            "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.1.1.tgz",
1602
-            "integrity": "sha512-HMZXpoSs1OR+7Lw1+g4Iy/s3HF3Ldl8KxxYT2Ot8pEB4XB/QRuZeWgDYJdu552UN03YRSRNK84CLC9NzYRtncA==",
1600
+            "version": "1.2.0",
1601
+            "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-1.2.0.tgz",
1602
+            "integrity": "sha512-R0pJ+IcTVeqEMoKz/B2Ij57QVq3sFTABiFmb06gAwFdivbOgsUtuhX6N2MGLEArajrS3U5JbberzwOe7uXHMHQ==",
1603 1603
             "dev": true,
1604 1604
             "license": "MIT",
1605 1605
             "dependencies": {
@@ -2242,9 +2242,9 @@
2242 2242
             }
2243 2243
         },
2244 2244
         "node_modules/rollup": {
2245
-            "version": "4.30.1",
2246
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz",
2247
-            "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==",
2245
+            "version": "4.31.0",
2246
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.31.0.tgz",
2247
+            "integrity": "sha512-9cCE8P4rZLx9+PjoyqHLs31V9a9Vpvfo4qNcs6JCiGWYhw2gijSetFbH6SSy1whnkgcefnUwr8sad7tgqsGvnw==",
2248 2248
             "dev": true,
2249 2249
             "license": "MIT",
2250 2250
             "dependencies": {
@@ -2258,25 +2258,25 @@
2258 2258
                 "npm": ">=8.0.0"
2259 2259
             },
2260 2260
             "optionalDependencies": {
2261
-                "@rollup/rollup-android-arm-eabi": "4.30.1",
2262
-                "@rollup/rollup-android-arm64": "4.30.1",
2263
-                "@rollup/rollup-darwin-arm64": "4.30.1",
2264
-                "@rollup/rollup-darwin-x64": "4.30.1",
2265
-                "@rollup/rollup-freebsd-arm64": "4.30.1",
2266
-                "@rollup/rollup-freebsd-x64": "4.30.1",
2267
-                "@rollup/rollup-linux-arm-gnueabihf": "4.30.1",
2268
-                "@rollup/rollup-linux-arm-musleabihf": "4.30.1",
2269
-                "@rollup/rollup-linux-arm64-gnu": "4.30.1",
2270
-                "@rollup/rollup-linux-arm64-musl": "4.30.1",
2271
-                "@rollup/rollup-linux-loongarch64-gnu": "4.30.1",
2272
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.30.1",
2273
-                "@rollup/rollup-linux-riscv64-gnu": "4.30.1",
2274
-                "@rollup/rollup-linux-s390x-gnu": "4.30.1",
2275
-                "@rollup/rollup-linux-x64-gnu": "4.30.1",
2276
-                "@rollup/rollup-linux-x64-musl": "4.30.1",
2277
-                "@rollup/rollup-win32-arm64-msvc": "4.30.1",
2278
-                "@rollup/rollup-win32-ia32-msvc": "4.30.1",
2279
-                "@rollup/rollup-win32-x64-msvc": "4.30.1",
2261
+                "@rollup/rollup-android-arm-eabi": "4.31.0",
2262
+                "@rollup/rollup-android-arm64": "4.31.0",
2263
+                "@rollup/rollup-darwin-arm64": "4.31.0",
2264
+                "@rollup/rollup-darwin-x64": "4.31.0",
2265
+                "@rollup/rollup-freebsd-arm64": "4.31.0",
2266
+                "@rollup/rollup-freebsd-x64": "4.31.0",
2267
+                "@rollup/rollup-linux-arm-gnueabihf": "4.31.0",
2268
+                "@rollup/rollup-linux-arm-musleabihf": "4.31.0",
2269
+                "@rollup/rollup-linux-arm64-gnu": "4.31.0",
2270
+                "@rollup/rollup-linux-arm64-musl": "4.31.0",
2271
+                "@rollup/rollup-linux-loongarch64-gnu": "4.31.0",
2272
+                "@rollup/rollup-linux-powerpc64le-gnu": "4.31.0",
2273
+                "@rollup/rollup-linux-riscv64-gnu": "4.31.0",
2274
+                "@rollup/rollup-linux-s390x-gnu": "4.31.0",
2275
+                "@rollup/rollup-linux-x64-gnu": "4.31.0",
2276
+                "@rollup/rollup-linux-x64-musl": "4.31.0",
2277
+                "@rollup/rollup-win32-arm64-msvc": "4.31.0",
2278
+                "@rollup/rollup-win32-ia32-msvc": "4.31.0",
2279
+                "@rollup/rollup-win32-x64-msvc": "4.31.0",
2280 2280
                 "fsevents": "~2.3.2"
2281 2281
             }
2282 2282
         },
@@ -2624,9 +2624,9 @@
2624 2624
             "license": "MIT"
2625 2625
         },
2626 2626
         "node_modules/vite": {
2627
-            "version": "6.0.7",
2628
-            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
2629
-            "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==",
2627
+            "version": "6.0.11",
2628
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.11.tgz",
2629
+            "integrity": "sha512-4VL9mQPKoHy4+FE0NnRE/kbY51TOfaknxAjt3fJbGJxhIpBZiqVzlZDEesWWsuREXHwNdAoOFZ9MkPEVXczHwg==",
2630 2630
             "dev": true,
2631 2631
             "license": "MIT",
2632 2632
             "dependencies": {

+ 9
- 0
resources/css/filament/company/theme.css 查看文件

@@ -18,6 +18,15 @@
18 18
     display: inline-flex;
19 19
 }
20 20
 
21
+/* End Alignment sortable column enhancement */
22
+.fi-ta-header-cell:has(button.justify-end > svg.fi-ta-header-cell-sort-icon) {
23
+    padding-right: 0.25rem;
24
+}
25
+
26
+.fi-ta-cell:has(.fi-ta-col-wrp > .justify-end) {
27
+    padding-right: 1rem;
28
+}
29
+
21 30
 :not(.dark) .fi-body {
22 31
     position: relative;
23 32
     background-color: #E8E9EB;

+ 39
- 1
resources/data/lang/es.json 查看文件

@@ -185,5 +185,43 @@
185 185
     "Currency List": "Lista de divisas",
186 186
     "Available": "Disponible",
187 187
     "Parent Department": "Departamento de padres",
188
-    "Live Rate": "Tarifa en vivo"
188
+    "Live Rate": "Tarifa en vivo",
189
+    "Category": "Categoría",
190
+    "Configuration": "Configuración",
191
+    "Dates": "Fechas",
192
+    "Adjustment Details": "Detalles del ajuste",
193
+    "Adjustments": "Ajustes",
194
+    "Sellable Configuration": "Configuración apta para venta",
195
+    "Purchasable Configuration": "Configuración que se puede comprar",
196
+    "Sale Information": "Información de venta",
197
+    "Purchase Information": "Información de compra",
198
+    "Billing": "Facturación",
199
+    "Shipping": "Envío",
200
+    "General Information": "Información general",
201
+    "Primary Contact": "Contacto principal",
202
+    "Billing Address": "Dirección de facturación",
203
+    "Shipping Address": "Dirección de envío",
204
+    "Invoice Header": "Encabezado de factura",
205
+    "Invoice Details": "Detalles de la factura",
206
+    "Secondary Contacts": "Contactos secundarios",
207
+    "Edit": "Editar",
208
+    "Bill Details": "Detalles de la factura",
209
+    "Frequency": "Frecuencia",
210
+    "Scheduling": "Programación",
211
+    "Notes": "Notas",
212
+    "Create": "Crear",
213
+    "Invoice Footer": "Pie de página de la factura",
214
+    "Time Zone": "Zona horaria",
215
+    "Footer": "Pie de página",
216
+    "Estimate Header": "Encabezado de estimación",
217
+    "Terms": "Términos",
218
+    "Estimate Footer": "Estimar pie de página",
219
+    "Scheduling Form": "Formulario de programación",
220
+    "Default :Type :Category": "Predeterminado :Type :Category",
221
+    "Approve": "Aprobar",
222
+    "Dates & Time": "Fechas y hora",
223
+    "Schedule Bounds": "Límites de programación",
224
+    "Ending Balance": "Saldo final",
225
+    "Address Information": "Información de dirección",
226
+    "Estimate Details": "Detalles de la estimación"
189 227
 }

+ 2
- 2
resources/views/components/report-summary-section.blade.php 查看文件

@@ -20,8 +20,8 @@
20 20
                         <strong
21 21
                             @class([
22 22
                                 'text-lg',
23
-                                'text-green-700 dark:text-green-500' => $isTargetLabel && $isPositive,
24
-                                'text-danger-700 dark:text-danger-500' => $isTargetLabel && ! $isPositive,
23
+                                'text-success-700 dark:text-success-400' => $isTargetLabel && $isPositive,
24
+                                'text-danger-700 dark:text-danger-400' => $isTargetLabel && ! $isPositive,
25 25
                             ])
26 26
                         >
27 27
                             {{ $summary['value'] }}

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

@@ -1,15 +1,13 @@
1 1
 @php
2 2
     $data = $this->form->getRawState();
3
-    $viewModel = new \App\View\Models\InvoiceViewModel($this->record, $data);
4
-    $viewSpecial = $viewModel->buildViewData();
5
-    extract($viewSpecial,\EXTR_SKIP);
3
+    $document = \App\DTO\DocumentPreviewDTO::fromSettings($this->record, $data);
6 4
 @endphp
7 5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9 7
 
10 8
 <style>
11 9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13 11
     }
14 12
 </style>
15 13
 
@@ -18,63 +16,65 @@
18 16
     <x-company.invoice.header class="default-template-header">
19 17
         <div class="w-2/3 text-left ml-6">
20 18
             <div class="text-xs">
21
-                <h2 class="text-base font-semibold">{{ $company_name }}</h2>
22
-                @if($company_address && $company_city && $company_state && $company_zip)
23
-                    <p>{{ $company_address }}</p>
24
-                    <p>{{ $company_city }}, {{ $company_state }} {{ $company_zip }}</p>
25
-                    <p>{{ $company_country }}</p>
19
+                <h2 class="text-base font-semibold">{{ $document->company->name }}</h2>
20
+                @if($formattedAddress = $document->company->getFormattedAddressHtml())
21
+                    {!! $formattedAddress !!}
26 22
                 @endif
27 23
             </div>
28 24
         </div>
29 25
 
30 26
         <div class="w-1/3 flex justify-end mr-6">
31
-            @if($logo && $show_logo)
32
-                <x-company.invoice.logo :src="$logo"/>
27
+            @if($document->logo && $document->showLogo)
28
+                <x-company.invoice.logo :src="$document->logo"/>
33 29
             @endif
34 30
         </div>
35 31
     </x-company.invoice.header>
36 32
 
37 33
     <x-company.invoice.metadata class="classic-template-metadata">
38 34
         <div class="items-center flex">
39
-            <hr class="grow-[2] py-0.5 border-solid border-y-2" style="border-color: {{ $accent_color }};">
35
+            <hr class="grow-[2] py-0.5 border-solid border-y-2" style="border-color: {{ $document->accentColor }};">
40 36
             <div class="items-center flex mx-5">
41
-                <x-icons.decor-border-left color="{{ $accent_color }}"/>
42
-                <div class="px-2.5 border-solid border-y-2 py-1 -mx-3" style="border-color: {{ $accent_color }};">
43
-                    <div class="px-2.5 border-solid border-y-2 py-3" style="border-color: {{ $accent_color }};">
37
+                <x-icons.decor-border-left color="{{ $document->accentColor }}"/>
38
+                <div class="px-2.5 border-solid border-y-2 py-1 -mx-3" style="border-color: {{ $document->accentColor }};">
39
+                    <div class="px-2.5 border-solid border-y-2 py-3" style="border-color: {{ $document->accentColor }};">
44 40
                         <div class="inline text-2xl font-semibold"
45
-                             style="color: {{ $accent_color }};">{{ $header }}</div>
41
+                             style="color: {{ $document->accentColor }};">{{ $document->header }}</div>
46 42
                     </div>
47 43
                 </div>
48
-                <x-icons.decor-border-right color="{{ $accent_color }}"/>
44
+                <x-icons.decor-border-right color="{{ $document->accentColor }}"/>
49 45
             </div>
50
-            <hr class="grow-[2] py-0.5 border-solid border-y-2" style="border-color: {{ $accent_color }};">
46
+            <hr class="grow-[2] py-0.5 border-solid border-y-2" style="border-color: {{ $document->accentColor }};">
51 47
         </div>
52
-        <div class="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">{{ $subheader }}</div>
48
+        <div class="mt-2 text-sm text-center text-gray-600 dark:text-gray-400">{{ $document->subheader }}</div>
53 49
 
54 50
         <div class="flex justify-between items-end">
55 51
             <!-- Billing Details -->
56 52
             <div class="text-xs">
57 53
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
58
-                <p class="text-base font-bold">John Doe</p>
59
-                <p>123 Main Street</p>
60
-                <p>New York, New York 10001</p>
61
-                <p>United States</p>
54
+                <p class="text-base font-bold">{{ $document->client->name }}</p>
55
+                @if($formattedAddress = $document->client->getFormattedAddressHtml())
56
+                    {!! $formattedAddress !!}
57
+                @endif
62 58
             </div>
63 59
 
64 60
             <div class="text-xs">
65 61
                 <table class="min-w-full">
66 62
                     <tbody>
67 63
                     <tr>
68
-                        <td class="font-semibold text-right pr-2">Invoice Number:</td>
69
-                        <td class="text-left pl-2">{{ $invoice_number }}</td>
64
+                        <td class="font-semibold text-right pr-2">{{ $document->label->number }}:</td>
65
+                        <td class="text-left pl-2">{{ $document->number }}</td>
66
+                    </tr>
67
+                    <tr>
68
+                        <td class="font-semibold text-right pr-2">{{ $document->label->referenceNumber }}:</td>
69
+                        <td class="text-left pl-2">{{ $document->referenceNumber }}</td>
70 70
                     </tr>
71 71
                     <tr>
72
-                        <td class="font-semibold text-right pr-2">Invoice Date:</td>
73
-                        <td class="text-left pl-2">{{ $invoice_date }}</td>
72
+                        <td class="font-semibold text-right pr-2">{{ $document->label->date }}:</td>
73
+                        <td class="text-left pl-2">{{ $document->date }}</td>
74 74
                     </tr>
75 75
                     <tr>
76
-                        <td class="font-semibold text-right pr-2">Payment Due:</td>
77
-                        <td class="text-left pl-2">{{ $invoice_due_date }}</td>
76
+                        <td class="font-semibold text-right pr-2">{{ $document->label->dueDate }}:</td>
77
+                        <td class="text-left pl-2">{{ $document->dueDate }}</td>
78 78
                     </tr>
79 79
                     </tbody>
80 80
                 </table>
@@ -87,31 +87,21 @@
87 87
         <table class="w-full text-left table-fixed">
88 88
             <thead class="text-sm leading-8">
89 89
             <tr>
90
-                <th class="text-left">{{ $item_name }}</th>
91
-                <th class="text-center">{{ $unit_name }}</th>
92
-                <th class="text-right">{{ $price_name }}</th>
93
-                <th class="text-right">{{ $amount_name }}</th>
90
+                <th class="text-left">{{ $document->columnLabel->items }}</th>
91
+                <th class="text-center">{{ $document->columnLabel->units }}</th>
92
+                <th class="text-right">{{ $document->columnLabel->price }}</th>
93
+                <th class="text-right">{{ $document->columnLabel->amount }}</th>
94 94
             </tr>
95 95
             </thead>
96 96
             <tbody class="text-xs border-t-2 border-b-2 border-dotted border-gray-300 leading-8">
97
-            <tr>
98
-                <td class="text-left font-semibold">Item 1</td>
99
-                <td class="text-center">2</td>
100
-                <td class="text-right">$150.00</td>
101
-                <td class="text-right">$300.00</td>
102
-            </tr>
103
-            <tr>
104
-                <td class="text-left font-semibold">Item 2</td>
105
-                <td class="text-center">3</td>
106
-                <td class="text-right">$200.00</td>
107
-                <td class="text-right">$600.00</td>
108
-            </tr>
109
-            <tr>
110
-                <td class="text-left font-semibold">Item 3</td>
111
-                <td class="text-center">1</td>
112
-                <td class="text-right">$180.00</td>
113
-                <td class="text-right">$180.00</td>
114
-            </tr>
97
+            @foreach($document->lineItems as $item)
98
+                <tr>
99
+                    <td class="text-left font-semibold">{{ $item->name }}</td>
100
+                    <td class="text-center">{{ $item->quantity }}</td>
101
+                    <td class="text-right">{{ $item->unitPrice }}</td>
102
+                    <td class="text-right">{{ $item->subtotal }}</td>
103
+                </tr>
104
+            @endforeach
115 105
             </tbody>
116 106
         </table>
117 107
 
@@ -120,7 +110,7 @@
120 110
             <!-- Notes Section -->
121 111
             <div class="w-1/2 border border-dashed border-gray-300 p-2 mt-4">
122 112
                 <h4 class="font-semibold mb-2">Notes</h4>
123
-                <p>{{ $footer }}</p>
113
+                <p>{{ $document->footer }}</p>
124 114
             </div>
125 115
 
126 116
             <!-- Financial Summary -->
@@ -129,23 +119,23 @@
129 119
                     <tbody class="text-xs leading-loose">
130 120
                     <tr>
131 121
                         <td class="text-right font-semibold">Subtotal:</td>
132
-                        <td class="text-right">$1080.00</td>
122
+                        <td class="text-right">{{ $document->subtotal }}</td>
133 123
                     </tr>
134 124
                     <tr class="text-success-800 dark:text-success-600">
135 125
                         <td class="text-right">Discount (5%):</td>
136
-                        <td class="text-right">($54.00)</td>
126
+                        <td class="text-right">({{ $document->discount }})</td>
137 127
                     </tr>
138 128
                     <tr>
139 129
                         <td class="text-right">Sales Tax (10%):</td>
140
-                        <td class="text-right">$102.60</td>
130
+                        <td class="text-right">{{ $document->tax }}</td>
141 131
                     </tr>
142 132
                     <tr>
143 133
                         <td class="text-right font-semibold">Total:</td>
144
-                        <td class="text-right">$1128.60</td>
134
+                        <td class="text-right">{{ $document->total }}</td>
145 135
                     </tr>
146 136
                     <tr>
147 137
                         <td class="text-right font-semibold">Amount Due (USD):</td>
148
-                        <td class="text-right">$1128.60</td>
138
+                        <td class="text-right">{{ $document->amountDue }}</td>
149 139
                     </tr>
150 140
                     </tbody>
151 141
                 </table>
@@ -156,6 +146,6 @@
156 146
     <!-- Footer -->
157 147
     <x-company.invoice.footer class="classic-template-footer">
158 148
         <h4 class="font-semibold px-6 mb-2">Terms & Conditions</h4>
159
-        <p class="px-6 break-words line-clamp-4">{{ $terms }}</p>
149
+        <p class="px-6 break-words line-clamp-4">{{ $document->terms }}</p>
160 150
     </x-company.invoice.footer>
161 151
 </x-company.invoice.container>

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

@@ -1,15 +1,13 @@
1 1
 @php
2 2
     $data = $this->form->getRawState();
3
-    $viewModel = new \App\View\Models\InvoiceViewModel($this->record, $data);
4
-    $viewSpecial = $viewModel->buildViewData();
5
-    extract($viewSpecial,\EXTR_SKIP);
3
+    $document = \App\DTO\DocumentPreviewDTO::fromSettings($this->record, $data);
6 4
 @endphp
7 5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9 7
 
10 8
 <style>
11 9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13 11
     }
14 12
 </style>
15 13
 
@@ -17,18 +15,16 @@
17 15
 
18 16
     <x-company.invoice.header class="default-template-header border-b-2 p-6 pb-4">
19 17
         <div class="w-2/3">
20
-            @if($logo && $show_logo)
21
-                <x-company.invoice.logo :src="$logo"/>
18
+            @if($document->logo && $document->showLogo)
19
+                <x-company.invoice.logo :src="$document->logo"/>
22 20
             @endif
23 21
         </div>
24 22
 
25 23
         <div class="w-1/3 text-right">
26 24
             <div class="text-xs">
27
-                <h2 class="font-semibold">{{ $company_name }}</h2>
28
-                @if($company_address && $company_city && $company_state && $company_zip)
29
-                    <p>{{ $company_address }}</p>
30
-                    <p>{{ $company_city }}, {{ $company_state }} {{ $company_zip }}</p>
31
-                    <p>{{ $company_country }}</p>
25
+                <h2 class="font-semibold">{{ $document->company->name }}</h2>
26
+                @if($formattedAddress = $document->company->getFormattedAddressHtml())
27
+                    {!! $formattedAddress !!}
32 28
                 @endif
33 29
             </div>
34 30
         </div>
@@ -36,9 +32,9 @@
36 32
 
37 33
     <x-company.invoice.metadata class="default-template-metadata space-y-6">
38 34
         <div>
39
-            <h1 class="text-3xl font-light uppercase">{{ $header }}</h1>
40
-            @if ($subheader)
41
-                <h2 class="text-sm text-gray-600 dark:text-gray-400">{{ $subheader }}</h2>
35
+            <h1 class="text-3xl font-light uppercase">{{ $document->header }}</h1>
36
+            @if ($document->subheader)
37
+                <h2 class="text-sm text-gray-600 dark:text-gray-400">{{ $document->subheader }}</h2>
42 38
             @endif
43 39
         </div>
44 40
 
@@ -46,26 +42,30 @@
46 42
             <!-- Billing Details -->
47 43
             <div class="text-xs">
48 44
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
49
-                <p class="text-base font-bold">John Doe</p>
50
-                <p>123 Main Street</p>
51
-                <p>New York, New York 10001</p>
52
-                <p>United States</p>
45
+                <p class="text-base font-bold">{{ $document->client->name }}</p>
46
+                @if($formattedAddress = $document->client->getFormattedAddressHtml())
47
+                    {!! $formattedAddress !!}
48
+                @endif
53 49
             </div>
54 50
 
55 51
             <div class="text-xs">
56 52
                 <table class="min-w-full">
57 53
                     <tbody>
58 54
                     <tr>
59
-                        <td class="font-semibold text-right pr-2">Invoice Number:</td>
60
-                        <td class="text-left pl-2">{{ $invoice_number }}</td>
55
+                        <td class="font-semibold text-right pr-2">{{ $document->label->number }}:</td>
56
+                        <td class="text-left pl-2">{{ $document->number }}</td>
57
+                    </tr>
58
+                    <tr>
59
+                        <td class="font-semibold text-right pr-2">{{ $document->label->referenceNumber }}:</td>
60
+                        <td class="text-left pl-2">{{ $document->referenceNumber }}</td>
61 61
                     </tr>
62 62
                     <tr>
63
-                        <td class="font-semibold text-right pr-2">Invoice Date:</td>
64
-                        <td class="text-left pl-2">{{ $invoice_date }}</td>
63
+                        <td class="font-semibold text-right pr-2">{{ $document->label->date }}:</td>
64
+                        <td class="text-left pl-2">{{ $document->date }}</td>
65 65
                     </tr>
66 66
                     <tr>
67
-                        <td class="font-semibold text-right pr-2">Payment Due:</td>
68
-                        <td class="text-left pl-2">{{ $invoice_due_date }}</td>
67
+                        <td class="font-semibold text-right pr-2">{{ $document->label->dueDate }}:</td>
68
+                        <td class="text-left pl-2">{{ $document->dueDate }}</td>
69 69
                     </tr>
70 70
                     </tbody>
71 71
                 </table>
@@ -76,59 +76,49 @@
76 76
     <!-- Line Items Table -->
77 77
     <x-company.invoice.line-items class="default-template-line-items">
78 78
         <table class="w-full text-left table-fixed">
79
-            <thead class="text-sm leading-8" style="background: {{ $accent_color }}">
79
+            <thead class="text-sm leading-8" style="background: {{ $document->accentColor }}">
80 80
             <tr class="text-white">
81
-                <th class="text-left pl-6">{{ $item_name }}</th>
82
-                <th class="text-center">{{ $unit_name }}</th>
83
-                <th class="text-right">{{ $price_name }}</th>
84
-                <th class="text-right pr-6">{{ $amount_name }}</th>
81
+                <th class="text-left pl-6">{{ $document->columnLabel->items }}</th>
82
+                <th class="text-center">{{ $document->columnLabel->units }}</th>
83
+                <th class="text-right">{{ $document->columnLabel->price }}</th>
84
+                <th class="text-right pr-6">{{ $document->columnLabel->amount }}</th>
85 85
             </tr>
86 86
             </thead>
87 87
             <tbody class="text-xs border-b-2 border-gray-300 leading-8">
88
-            <tr>
89
-                <td class="text-left pl-6 font-semibold">Item 1</td>
90
-                <td class="text-center">2</td>
91
-                <td class="text-right">$150.00</td>
92
-                <td class="text-right pr-6">$300.00</td>
93
-            </tr>
94
-            <tr>
95
-                <td class="text-left pl-6 font-semibold">Item 2</td>
96
-                <td class="text-center">3</td>
97
-                <td class="text-right">$200.00</td>
98
-                <td class="text-right pr-6">$600.00</td>
99
-            </tr>
100
-            <tr>
101
-                <td class="text-left pl-6 font-semibold">Item 3</td>
102
-                <td class="text-center">1</td>
103
-                <td class="text-right">$180.00</td>
104
-                <td class="text-right pr-6">$180.00</td>
105
-            </tr>
88
+            @foreach($document->lineItems as $item)
89
+                <tr>
90
+                    <td class="text-left pl-6 font-semibold">{{ $item->name }}</td>
91
+                    <td class="text-center">{{ $item->quantity }}</td>
92
+                    <td class="text-right">{{ $item->unitPrice }}</td>
93
+                    <td class="text-right pr-6">{{ $item->subtotal }}</td>
94
+                </tr>
95
+            @endforeach
106 96
             </tbody>
107 97
             <tfoot class="text-xs leading-loose">
108 98
             <tr>
109 99
                 <td class="pl-6" colspan="2"></td>
110 100
                 <td class="text-right font-semibold">Subtotal:</td>
111
-                <td class="text-right pr-6">$1080.00</td>
101
+                <td class="text-right pr-6">{{ $document->subtotal }}</td>
112 102
             </tr>
113 103
             <tr class="text-success-800 dark:text-success-600">
114 104
                 <td class="pl-6" colspan="2"></td>
115 105
                 <td class="text-right">Discount (5%):</td>
116
-                <td class="text-right pr-6">($54.00)</td>
106
+                <td class="text-right pr-6">({{ $document->discount }})</td>
117 107
             </tr>
118 108
             <tr>
119 109
                 <td class="pl-6" colspan="2"></td>
120
-                <td class="text-right">Sales Tax (10%):</td>
121
-                <td class="text-right pr-6">$102.60</td>
110
+                <td class="text-right">Tax:</td>
111
+                <td class="text-right pr-6">{{ $document->tax }}</td>
122 112
             </tr>
123 113
             <tr>
124 114
                 <td class="pl-6" colspan="2"></td>
125 115
                 <td class="text-right font-semibold border-t">Total:</td>
126
-                <td class="text-right border-t pr-6">$1128.60</td>
116
+                <td class="text-right border-t pr-6">{{ $document->total }}</td>
127 117
             </tr>
128 118
             <tr>
129 119
                 <td class="pl-6" colspan="2"></td>
130
-                <td class="text-right font-semibold border-t-4 border-double">Amount Due (USD):</td>
131
-                <td class="text-right border-t-4 border-double pr-6">$1128.60</td>
120
+                <td class="text-right font-semibold border-t-4 border-double">{{ $document->label->amountDue }} ({{ $document->currencyCode }}):</td>
121
+                <td class="text-right border-t-4 border-double pr-6">{{ $document->amountDue }}</td>
132 122
             </tr>
133 123
             </tfoot>
134 124
         </table>
@@ -136,9 +126,9 @@
136 126
 
137 127
     <!-- Footer Notes -->
138 128
     <x-company.invoice.footer class="default-template-footer">
139
-        <p class="px-6">{{ $footer }}</p>
129
+        <p class="px-6">{{ $document->footer }}</p>
140 130
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
141 131
         <h4 class="font-semibold px-6 mb-2">Terms & Conditions</h4>
142
-        <p class="px-6 break-words line-clamp-4">{{ $terms }}</p>
132
+        <p class="px-6 break-words line-clamp-4">{{ $document->terms }}</p>
143 133
     </x-company.invoice.footer>
144 134
 </x-company.invoice.container>

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

@@ -1,15 +1,13 @@
1 1
 @php
2 2
     $data = $this->form->getRawState();
3
-    $viewModel = new \App\View\Models\InvoiceViewModel($this->record, $data);
4
-    $viewSpecial = $viewModel->buildViewData();
5
-    extract($viewSpecial,\EXTR_SKIP);
3
+    $document = \App\DTO\DocumentPreviewDTO::fromSettings($this->record, $data);
6 4
 @endphp
7 5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9 7
 
10 8
 <style>
11 9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13 11
     }
14 12
 </style>
15 13
 
@@ -19,16 +17,16 @@
19 17
     <x-company.invoice.header class="bg-gray-800 h-20">
20 18
         <!-- Logo -->
21 19
         <div class="w-2/3">
22
-            @if($logo && $show_logo)
23
-                <x-company.invoice.logo class="ml-6" :src="$logo"/>
20
+            @if($document->logo && $document->showLogo)
21
+                <x-company.invoice.logo class="ml-6" :src="$document->logo"/>
24 22
             @endif
25 23
         </div>
26 24
 
27 25
         <!-- Ribbon Container -->
28 26
         <div class="w-1/3 absolute right-0 top-0 p-2 h-28 flex flex-col justify-end rounded-bl-sm"
29
-             style="background: {{ $accent_color }};">
30
-            @if($header)
31
-                <h1 class="text-3xl font-bold text-white text-center uppercase">{{ $header }}</h1>
27
+             style="background: {{ $document->accentColor }};">
28
+            @if($document->header)
29
+                <h1 class="text-3xl font-bold text-white text-center uppercase">{{ $document->header }}</h1>
32 30
             @endif
33 31
         </div>
34 32
     </x-company.invoice.header>
@@ -36,38 +34,44 @@
36 34
     <!-- Company Details -->
37 35
     <x-company.invoice.metadata class="modern-template-metadata space-y-6">
38 36
         <div class="text-xs">
39
-            <h2 class="text-base font-semibold">{{ $company_name }}</h2>
40
-            @if($company_address && $company_city && $company_state && $company_zip)
41
-                <p>{{ $company_address }}</p>
42
-                <p>{{ $company_city }}, {{ $company_state }} {{ $company_zip }}</p>
43
-                <p>{{ $company_country }}</p>
37
+            <h2 class="text-base font-semibold">{{ $document->company->name }}</h2>
38
+            @if($formattedAddress = $document->company->getFormattedAddressHtml())
39
+                {!! $formattedAddress !!}
44 40
             @endif
45 41
         </div>
46 42
 
47 43
         <div class="flex justify-between items-end">
48 44
             <!-- Billing Details -->
49
-            <div class="text-xs">
45
+            <div class="text-xs tracking-tight">
50 46
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
51
-                <p class="text-base font-bold" style="color: {{ $accent_color }}">John Doe</p>
52
-                <p>123 Main Street</p>
53
-                <p>New York, New York 10001</p>
54
-                <p>United States</p>
47
+                <p class="text-base font-bold"
48
+                   style="color: {{ $document->accentColor }}">{{ $document->client->name }}</p>
49
+
50
+                @if($formattedAddress = $document->client->getFormattedAddressHtml())
51
+                    {!! $formattedAddress !!}
52
+                @endif
55 53
             </div>
56 54
 
57
-            <div class="text-xs">
55
+            <div class="text-xs tracking-tight">
58 56
                 <table class="min-w-full">
59 57
                     <tbody>
60 58
                     <tr>
61
-                        <td class="font-semibold text-right pr-2">Invoice Number:</td>
62
-                        <td class="text-left pl-2">{{ $invoice_number }}</td>
59
+                        <td class="font-semibold text-right pr-2">{{ $document->label->number }}:</td>
60
+                        <td class="text-left pl-2">{{ $document->number }}</td>
63 61
                     </tr>
62
+                    @if($document->referenceNumber)
63
+                        <tr>
64
+                            <td class="font-semibold text-right pr-2">{{ $document->label->referenceNumber }}:</td>
65
+                            <td class="text-left pl-2">{{ $document->referenceNumber }}</td>
66
+                        </tr>
67
+                    @endif
64 68
                     <tr>
65
-                        <td class="font-semibold text-right pr-2">Invoice Date:</td>
66
-                        <td class="text-left pl-2">{{ $invoice_date }}</td>
69
+                        <td class="font-semibold text-right pr-2">{{ $document->label->date }}:</td>
70
+                        <td class="text-left pl-2">{{ $document->date }}</td>
67 71
                     </tr>
68 72
                     <tr>
69
-                        <td class="font-semibold text-right pr-2">Payment Due:</td>
70
-                        <td class="text-left pl-2">{{ $invoice_due_date }}</td>
73
+                        <td class="font-semibold text-right pr-2">{{ $document->label->dueDate }}:</td>
74
+                        <td class="text-left pl-2">{{ $document->dueDate }}</td>
71 75
                     </tr>
72 76
                     </tbody>
73 77
                 </table>
@@ -80,69 +84,74 @@
80 84
         <table class="w-full text-left table-fixed">
81 85
             <thead class="text-sm leading-8">
82 86
             <tr class="text-gray-600 dark:text-gray-400">
83
-                <th class="text-left pl-6">{{ $item_name }}</th>
84
-                <th class="text-center">{{ $unit_name }}</th>
85
-                <th class="text-right">{{ $price_name }}</th>
86
-                <th class="text-right pr-6">{{ $amount_name }}</th>
87
+                <th class="text-left pl-6 w-[50%]">{{ $document->columnLabel->items }}</th>
88
+                <th class="text-center w-[10%]">{{ $document->columnLabel->units }}</th>
89
+                <th class="text-right w-[20%]">{{ $document->columnLabel->price }}</th>
90
+                <th class="text-right pr-6 w-[20%]">{{ $document->columnLabel->amount }}</th>
87 91
             </tr>
88 92
             </thead>
89
-            <tbody class="text-xs border-t-2 border-b-2 leading-8">
90
-            <tr class="bg-gray-100 dark:bg-gray-800">
91
-                <td class="text-left pl-6 font-semibold">Item 1</td>
92
-                <td class="text-center">2</td>
93
-                <td class="text-right">$150.00</td>
94
-                <td class="text-right pr-6">$300.00</td>
95
-            </tr>
96
-            <tr>
97
-                <td class="text-left pl-6 font-semibold">Item 2</td>
98
-                <td class="text-center">3</td>
99
-                <td class="text-right">$200.00</td>
100
-                <td class="text-right pr-6">$600.00</td>
101
-            </tr>
102
-            <tr class="bg-gray-100 dark:bg-gray-800">
103
-                <td class="text-left pl-6 font-semibold">Item 3</td>
104
-                <td class="text-center">1</td>
105
-                <td class="text-right">$180.00</td>
106
-                <td class="text-right pr-6">$180.00</td>
107
-            </tr>
93
+            <tbody class="text-xs tracking-tight border-y-2">
94
+            @foreach($document->lineItems as $index => $item)
95
+                <tr @class(['bg-gray-100 dark:bg-gray-800' => $index % 2 === 0])>
96
+                    <td class="text-left pl-6 font-semibold py-2">
97
+                        {{ $item->name }}
98
+                        @if($item->description)
99
+                            <div class="text-gray-600 font-normal line-clamp-2">{{ $item->description }}</div>
100
+                        @endif
101
+                    </td>
102
+                    <td class="text-center">{{ $item->quantity }}</td>
103
+                    <td class="text-right">{{ $item->unitPrice }}</td>
104
+                    <td class="text-right pr-6">{{ $item->subtotal }}</td>
105
+                </tr>
106
+            @endforeach
108 107
             </tbody>
109
-            <tfoot class="text-xs leading-loose">
108
+            <tfoot class="text-xs tracking-tight">
110 109
             <tr>
111
-                <td class="pl-6" colspan="2"></td>
112
-                <td class="text-right font-semibold">Subtotal:</td>
113
-                <td class="text-right pr-6">$1080.00</td>
114
-            </tr>
115
-            <tr class="text-success-800 dark:text-success-600">
116
-                <td class="pl-6" colspan="2"></td>
117
-                <td class="text-right">Discount (5%):</td>
118
-                <td class="text-right pr-6">($54.00)</td>
119
-            </tr>
120
-            <tr>
121
-                <td class="pl-6" colspan="2"></td>
122
-                <td class="text-right">Sales Tax (10%):</td>
123
-                <td class="text-right pr-6">$102.60</td>
124
-            </tr>
125
-            <tr>
126
-                <td class="pl-6" colspan="2"></td>
127
-                <td class="text-right font-semibold border-t">Total:</td>
128
-                <td class="text-right border-t pr-6">$1128.60</td>
110
+                <td class="pl-6 py-1" colspan="2"></td>
111
+                <td class="text-right font-semibold py-1">Subtotal:</td>
112
+                <td class="text-right pr-6 py-1">{{ $document->subtotal }}</td>
129 113
             </tr>
114
+            @if($document->discount)
115
+                <tr class="text-success-800 dark:text-success-600">
116
+                    <td class="pl-6 py-1" colspan="2"></td>
117
+                    <td class="text-right py-1">Discount:</td>
118
+                    <td class="text-right pr-6 py-1">
119
+                        ({{ $document->discount }})
120
+                    </td>
121
+                </tr>
122
+            @endif
123
+            @if($document->tax)
124
+                <tr>
125
+                    <td class="pl-6 py-1" colspan="2"></td>
126
+                    <td class="text-right py-1">Tax:</td>
127
+                    <td class="text-right pr-6 py-1">{{ $document->tax }}</td>
128
+                </tr>
129
+            @endif
130 130
             <tr>
131
-                <td class="pl-6" colspan="2"></td>
132
-                <td class="text-right font-semibold border-t-4 border-double">Amount Due (USD):</td>
133
-                <td class="text-right border-t-4 border-double pr-6">$1128.60</td>
131
+                <td class="pl-6 py-1" colspan="2"></td>
132
+                <td class="text-right font-semibold border-t py-1">Total:</td>
133
+                <td class="text-right border-t pr-6 py-1">{{ $document->total }}</td>
134 134
             </tr>
135
+            @if($document->amountDue)
136
+                <tr>
137
+                    <td class="pl-6 py-1" colspan="2"></td>
138
+                    <td class="text-right font-semibold border-t-4 border-double py-1">{{ $document->label->amountDue }}
139
+                        ({{ $document->currencyCode }}):
140
+                    </td>
141
+                    <td class="text-right border-t-4 border-double pr-6 py-1">{{ $document->amountDue }}</td>
142
+                </tr>
143
+            @endif
135 144
             </tfoot>
136 145
         </table>
137 146
     </x-company.invoice.line-items>
138 147
 
139 148
     <!-- Footer Notes -->
140
-    <x-company.invoice.footer class="modern-template-footer">
141
-        <h4 class="font-semibold px-6" style="color: {{ $accent_color }}">Terms & Conditions</h4>
149
+    <x-company.invoice.footer class="modern-template-footer tracking-tight">
150
+        <h4 class="font-semibold px-6" style="color: {{ $document->accentColor }}">Terms & Conditions</h4>
142 151
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
143 152
         <div class="flex justify-between space-x-4 px-6">
144
-            <p class="w-1/2 break-words line-clamp-4">{{ $terms }}</p>
145
-            <p class="w-1/2 break-words line-clamp-4">{{ $footer }}</p>
153
+            <p class="w-1/2 break-words line-clamp-4">{{ $document->terms }}</p>
154
+            <p class="w-1/2 break-words line-clamp-4">{{ $document->footer }}</p>
146 155
         </div>
147 156
     </x-company.invoice.footer>
148 157
 </x-company.invoice.container>

+ 0
- 35
resources/views/filament/forms/components/labeled-field.blade.php 查看文件

@@ -1,35 +0,0 @@
1
-@php
2
-    $prefixLabel = $getPrefixLabel();
3
-    $suffixLabel = $getSuffixLabel();
4
-
5
-    $childComponentContainer = $getChildComponentContainer();
6
-    $childComponents = $childComponentContainer->getComponents();
7
-@endphp
8
-
9
-<div
10
-    {{
11
-        $attributes->class([
12
-            'flex items-center gap-x-4',
13
-        ])
14
-    }}
15
->
16
-    @if($prefixLabel)
17
-        <span class="whitespace-nowrap text-sm font-medium leading-6 text-gray-950 dark:text-white">{{ $prefixLabel }}</span>
18
-    @endif
19
-
20
-    @foreach($childComponents as $component)
21
-        @if(count($component->getChildComponents()) > 1)
22
-            <div>
23
-                {{ $component }}
24
-            </div>
25
-        @else
26
-            <div class="min-w-28 [&_.fi-fo-field-wrp]:m-0 [&_.grid]:!grid-cols-1 [&_.sm\:grid-cols-3]:!grid-cols-1 [&_.sm\:col-span-2]:!col-span-1">
27
-                {{ $component }}
28
-            </div>
29
-        @endif
30
-    @endforeach
31
-
32
-    @if($suffixLabel)
33
-        <span class="whitespace-nowrap text-sm font-medium leading-6 text-gray-950 dark:text-white">{{ $suffixLabel }}</span>
34
-    @endif
35
-</div>

+ 0
- 251
resources/views/filament/forms/components/line-item-repeater.blade.php 查看文件

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

+ 16
- 21
resources/views/filament/infolists/components/document-preview.blade.php 查看文件

@@ -2,6 +2,14 @@
2 2
     $document = \App\DTO\DocumentDTO::fromModel($getRecord());
3 3
 @endphp
4 4
 
5
+{!! $document->getFontHtml() !!}
6
+
7
+<style>
8
+    .inv-paper {
9
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
10
+    }
11
+</style>
12
+
5 13
 <div {{ $attributes }}>
6 14
     <x-company.invoice.container class="modern-template-container">
7 15
         <!-- Colored Header with Logo -->
@@ -26,10 +34,8 @@
26 34
         <x-company.invoice.metadata class="modern-template-metadata space-y-8">
27 35
             <div class="text-sm">
28 36
                 <h2 class="text-lg font-semibold">{{ $document->company->name }}</h2>
29
-                @if($document->company->address && $document->company->city && $document->company->state && $document->company->zipCode)
30
-                    <p>{{ $document->company->address }}</p>
31
-                    <p>{{ $document->company->city }}, {{ $document->company->state }} {{ $document->company->zipCode }}</p>
32
-                    <p>{{ $document->company->country }}</p>
37
+                @if($formattedAddress = $document->company->getFormattedAddressHtml())
38
+                    {!! $formattedAddress !!}
33 39
                 @endif
34 40
             </div>
35 41
 
@@ -40,19 +46,8 @@
40 46
                     <p class="text-base font-bold"
41 47
                        style="color: {{ $document->accentColor }}">{{ $document->client->name }}</p>
42 48
 
43
-                    @if($document->client->addressLine1)
44
-                        <p>{{ $document->client->addressLine1 }}</p>
45
-
46
-                        @if($document->client->addressLine2)
47
-                            <p>{{ $document->client->addressLine2 }}</p>
48
-                        @endif
49
-                        <p>
50
-                            {{ $document->client->city }}{{ $document->client->state ? ', ' . $document->client->state: '' }}
51
-                            {{ $document->client->postalCode }}
52
-                        </p>
53
-                        @if($document->client->country)
54
-                            <p>{{ $document->client->country }}</p>
55
-                        @endif
49
+                    @if($formattedAddress = $document->client->getFormattedAddressHtml())
50
+                        {!! $formattedAddress !!}
56 51
                     @endif
57 52
                 </div>
58 53
 
@@ -88,10 +83,10 @@
88 83
             <table class="w-full text-left table-fixed">
89 84
                 <thead class="text-sm leading-relaxed">
90 85
                 <tr class="text-gray-600 dark:text-gray-400">
91
-                    <th class="text-left pl-6 w-[45%] py-4">Items</th>
92
-                    <th class="text-center w-[15%] py-4">Quantity</th>
93
-                    <th class="text-right w-[20%] py-4">Price</th>
94
-                    <th class="text-right pr-6 w-[20%] py-4">Amount</th>
86
+                    <th class="text-left pl-6 w-[50%] py-4">{{ $document->columnLabel->items }}</th>
87
+                    <th class="text-center w-[10%] py-4">{{ $document->columnLabel->units }}</th>
88
+                    <th class="text-right w-[20%] py-4">{{ $document->columnLabel->price }}</th>
89
+                    <th class="text-right pr-6 w-[20%] py-4">{{ $document->columnLabel->amount }}</th>
95 90
                 </tr>
96 91
                 </thead>
97 92
                 <tbody class="text-sm tracking-tight border-y-2">

+ 187
- 13
tests/Feature/Accounting/RecurringInvoiceTest.php 查看文件

@@ -1,29 +1,203 @@
1 1
 <?php
2 2
 
3
+use App\Enums\Accounting\EndType;
4
+use App\Enums\Accounting\Frequency;
3 5
 use App\Enums\Accounting\IntervalType;
4 6
 use App\Models\Accounting\RecurringInvoice;
7
+use Illuminate\Support\Carbon;
5 8
 
6
-test('example', function () {
9
+beforeEach(function () {
10
+    $this->withOfferings();
11
+});
12
+
13
+test('recurring invoice properly handles months with fewer days for monthly frequency', function () {
14
+    // Start from January 31st
15
+    Carbon::setTestNow('2024-01-31');
16
+
17
+    RecurringInvoice::unsetEventDispatcher();
18
+
19
+    // Create a recurring invoice set for the 31st of each month
7 20
     $recurringInvoice = RecurringInvoice::factory()
8
-        ->custom(IntervalType::Week, 2)
9
-        ->create([
10
-            'start_date' => today(),
11
-            'day_of_week' => today()->dayOfWeek,
12
-        ]);
21
+        ->withLineItems()
22
+        ->withSchedule(
23
+            frequency: Frequency::Monthly,
24
+            startDate: Carbon::now(),
25
+        )
26
+        ->approved()
27
+        ->create();
28
+
29
+    // First invoice should be the start date
30
+    expect($recurringInvoice->calculateNextDate())
31
+        ->toBeInstanceOf(Carbon::class)
32
+        ->toDateString()->toBe('2024-01-31');
33
+
34
+    // Now set last_date to simulate first invoice being generated
35
+    $recurringInvoice->update(['last_date' => '2024-01-31']);
36
+    $recurringInvoice->refresh();
37
+    expect($recurringInvoice->calculateNextDate())
38
+        ->toBeInstanceOf(Carbon::class)
39
+        ->toDateString()->toBe('2024-02-29');
40
+
41
+    // Update last_date to Feb 29 and check next date (should be March 31)
42
+    $recurringInvoice->update(['last_date' => '2024-02-29']);
43
+    $recurringInvoice->refresh();
44
+    expect($recurringInvoice->calculateNextDate())
45
+        ->toBeInstanceOf(Carbon::class)
46
+        ->toDateString()->toBe('2024-03-31');
47
+
48
+    // Update last_date to March 31 and check next date (should be April 30)
49
+    $recurringInvoice->update(['last_date' => '2024-03-31']);
50
+    $recurringInvoice->refresh();
51
+    expect($recurringInvoice->calculateNextDate())
52
+        ->toBeInstanceOf(Carbon::class)
53
+        ->toDateString()->toBe('2024-04-30');
54
+
55
+    // Update last_date to April 30 and check next date (should be May 31)
56
+    $recurringInvoice->update(['last_date' => '2024-04-30']);
57
+    $recurringInvoice->refresh();
58
+    expect($recurringInvoice->calculateNextDate())
59
+        ->toBeInstanceOf(Carbon::class)
60
+        ->toDateString()->toBe('2024-05-31');
61
+});
62
+
63
+test('recurring invoice properly handles months with fewer days for yearly frequency', function () {
64
+    // Start from January 31st
65
+    Carbon::setTestNow('2024-02-29');
66
+
67
+    RecurringInvoice::unsetEventDispatcher();
68
+
69
+    // Create a recurring invoice set for the 31st of each month
70
+    $recurringInvoice = RecurringInvoice::factory()
71
+        ->withLineItems()
72
+        ->withSchedule(
73
+            frequency: Frequency::Yearly,
74
+            startDate: Carbon::now(),
75
+        )
76
+        ->approved()
77
+        ->create();
78
+
79
+    // First invoice should be the start date
80
+    expect($recurringInvoice->calculateNextDate())
81
+        ->toBeInstanceOf(Carbon::class)
82
+        ->toDateString()->toBe('2024-02-29');
83
+
84
+    // Next date should be Feb 28, 2025 (non-leap year)
85
+    $recurringInvoice->update(['last_date' => '2024-02-29']);
86
+    $recurringInvoice->refresh();
87
+    expect($recurringInvoice->calculateNextDate())
88
+        ->toBeInstanceOf(Carbon::class)
89
+        ->toDateString()->toBe('2025-02-28');
13 90
 
91
+    // Next date should be Feb 29, 2026 (leap year)
92
+    $recurringInvoice->update(['last_date' => '2025-02-28']);
14 93
     $recurringInvoice->refresh();
94
+    expect($recurringInvoice->calculateNextDate())
95
+        ->toBeInstanceOf(Carbon::class)
96
+        ->toDateString()->toBe('2026-02-28');
97
+});
98
+
99
+test('recurring invoice properly handles weekly frequency and custom weekly intervals', function () {
100
+    Carbon::setTestNow('2024-01-31'); // Wednesday
15 101
 
16
-    $nextInvoiceDate = $recurringInvoice->calculateNextDate();
102
+    RecurringInvoice::unsetEventDispatcher();
17 103
 
18
-    expect($nextInvoiceDate)->toEqual(today());
104
+    // Test regular weekly frequency
105
+    $recurringInvoice = RecurringInvoice::factory()
106
+        ->withLineItems()
107
+        ->withSchedule(
108
+            frequency: Frequency::Weekly,
109
+            startDate: Carbon::now(),
110
+        )
111
+        ->approved()
112
+        ->create();
19 113
 
20
-    $recurringInvoice->update([
21
-        'last_date' => $nextInvoiceDate,
22
-    ]);
114
+    // First invoice should be the start date
115
+    expect($recurringInvoice->calculateNextDate())
116
+        ->toBeInstanceOf(Carbon::class)
117
+        ->toDateString()->toBe('2024-01-31');
23 118
 
119
+    // Next date should be that Friday
120
+    $recurringInvoice->update(['last_date' => '2024-01-31']);
24 121
     $recurringInvoice->refresh();
122
+    expect($recurringInvoice->calculateNextDate())
123
+        ->toBeInstanceOf(Carbon::class)
124
+        ->toDateString()->toBe('2024-02-07');
125
+
126
+    // Test custom weekly frequency (every 2 weeks)
127
+    $recurringInvoice = RecurringInvoice::factory()
128
+        ->withLineItems()
129
+        ->withCustomSchedule(
130
+            startDate: Carbon::now(), // Wednesday
131
+            endType: EndType::Never,
132
+            intervalType: IntervalType::Week,
133
+            intervalValue: 2,
134
+        )
135
+        ->approved()
136
+        ->create();
137
+
138
+    // First invoice should be the start date
139
+    expect($recurringInvoice->calculateNextDate())
140
+        ->toBeInstanceOf(Carbon::class)
141
+        ->toDateString()->toBe('2024-01-31');
142
+
143
+    // Next date should be two weeks from start, on Friday
144
+    $recurringInvoice->update(['last_date' => '2024-01-31']);
145
+    $recurringInvoice->refresh();
146
+    expect($recurringInvoice->calculateNextDate())
147
+        ->toBeInstanceOf(Carbon::class)
148
+        ->toDateString()->toBe('2024-02-14');
149
+});
150
+
151
+test('recurring invoice generates correct sequence of invoices across different month lengths', function () {
152
+    Carbon::setTestNow('2024-01-31');
153
+
154
+    $recurringInvoice = RecurringInvoice::factory()
155
+        ->withLineItems()
156
+        ->withSchedule(
157
+            frequency: Frequency::Monthly,
158
+            startDate: Carbon::now(),
159
+        )
160
+        ->approved()
161
+        ->create();
162
+
163
+    // Generate first invoice
164
+    $recurringInvoice->generateDueInvoices();
165
+
166
+    $invoices = $recurringInvoice->invoices()
167
+        ->orderBy('date')
168
+        ->get();
169
+
170
+    expect($invoices)->toHaveCount(1)
171
+        ->and($invoices->pluck('date')->map->toDateString()->toArray())->toBe([
172
+            '2024-01-31',
173
+        ]);
174
+
175
+    // Move time forward to February (leap year)
176
+    Carbon::setTestNow('2024-02-29');
177
+    $recurringInvoice->generateDueInvoices();
25 178
 
26
-    $nextInvoiceDate = $recurringInvoice->calculateNextDate();
179
+    $invoices = $recurringInvoice->invoices()
180
+        ->orderBy('date')
181
+        ->get();
27 182
 
28
-    expect($nextInvoiceDate)->toEqual(today()->addWeeks(2));
183
+    expect($invoices)->toHaveCount(2)
184
+        ->and($invoices->pluck('date')->map->toDateString()->toArray())->toBe([
185
+            '2024-01-31',
186
+            '2024-02-29',
187
+        ]);
188
+
189
+    // Move time forward to March
190
+    Carbon::setTestNow('2024-03-31');
191
+    $recurringInvoice->generateDueInvoices();
192
+
193
+    $invoices = $recurringInvoice->invoices()
194
+        ->orderBy('date')
195
+        ->get();
196
+
197
+    expect($invoices)->toHaveCount(3)
198
+        ->and($invoices->pluck('date')->map->toDateString()->toArray())->toBe([
199
+            '2024-01-31',
200
+            '2024-02-29',
201
+            '2024-03-31',
202
+        ]);
29 203
 });

+ 1
- 1
tests/Feature/CompanySetupAndBehaviorTest.php 查看文件

@@ -56,7 +56,7 @@ it('returns data for the current company based on the CurrentCompanyScope', func
56 56
 it('validates that company default settings are non-null', function () {
57 57
     $testCompany = $this->testCompany;
58 58
 
59
-    expect($testCompany->profile->country)->not->toBeNull()
59
+    expect($testCompany->profile->address->country_code)->not->toBeNull()
60 60
         ->and($testCompany->profile->email)->not->toBeNull()
61 61
         ->and($testCompany->default->currency_code)->toBe('USD')
62 62
         ->and($testCompany->locale->language)->toBe('en')

+ 14
- 0
tests/TestCase.php 查看文件

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace Tests;
4 4
 
5
+use App\Models\Common\Offering;
5 6
 use App\Models\Company;
6 7
 use App\Models\User;
7 8
 use App\Testing\TestsReport;
@@ -45,4 +46,17 @@ abstract class TestCase extends BaseTestCase
45 46
 
46 47
         Filament::setTenant($this->testCompany);
47 48
     }
49
+
50
+    public function withOfferings(): static
51
+    {
52
+        Offering::factory()
53
+            ->for($this->testCompany)
54
+            ->sellable()
55
+            ->withSalesAdjustments()
56
+            ->purchasable()
57
+            ->withPurchaseAdjustments()
58
+            ->create();
59
+
60
+        return $this;
61
+    }
48 62
 }

正在加载...
取消
保存