Browse Source

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

Development 3.x
3.x
Andrew Wallo 8 months ago
parent
commit
85b6422972
No account linked to committer's email address
85 changed files with 2226 additions and 1494 deletions
  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 View File

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 View File

81
 
81
 
82
     protected function updateDocumentTotals(Model $record, array $data): array
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
         $subtotalCents = $record->lineItems()->sum('subtotal');
85
         $subtotalCents = $record->lineItems()->sum('subtotal');
86
         $taxTotalCents = $record->lineItems()->sum('tax_total');
86
         $taxTotalCents = $record->lineItems()->sum('tax_total');
87
         $discountTotalCents = $this->calculateDiscountTotal(
87
         $discountTotalCents = $this->calculateDiscountTotal(

+ 24
- 2
app/DTO/ClientDTO.php View File

25
             addressLine1: $address?->address_line_1 ?? '',
25
             addressLine1: $address?->address_line_1 ?? '',
26
             addressLine2: $address?->address_line_2 ?? '',
26
             addressLine2: $address?->address_line_2 ?? '',
27
             city: $address?->city ?? '',
27
             city: $address?->city ?? '',
28
-            state: $address?->state ?? '',
28
+            state: $address?->state?->name ?? '',
29
             postalCode: $address?->postal_code ?? '',
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 View File

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 View File

8
 {
8
 {
9
     public function __construct(
9
     public function __construct(
10
         public string $name,
10
         public string $name,
11
-        public string $address,
11
+        public string $addressLine1,
12
+        public string $addressLine2,
12
         public string $city,
13
         public string $city,
13
         public string $state,
14
         public string $state,
14
-        public string $zipCode,
15
+        public string $postalCode,
15
         public string $country,
16
         public string $country,
16
     ) {}
17
     ) {}
17
 
18
 
18
     public static function fromModel(Company $company): self
19
     public static function fromModel(Company $company): self
19
     {
20
     {
20
         $profile = $company->profile;
21
         $profile = $company->profile;
22
+        $address = $profile->address ?? null;
21
 
23
 
22
         return new self(
24
         return new self(
23
             name: $company->name,
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 View File

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 View File

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

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 View File

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
         return CurrencyConverter::formatToMoney($value, $currencyCode);
31
         return CurrencyConverter::formatToMoney($value, $currencyCode);
32
     }
32
     }

+ 33
- 0
app/DTO/LineItemPreviewDTO.php View File

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 View File

100
 
100
 
101
         return $date->day(min($this->value, $date->daysInMonth));
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 View File

4
 
4
 
5
 trait ParsesEnum
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
             return null;
10
             return null;
11
         }
11
         }
12
 
12
 
13
-        if ($value instanceof self) {
13
+        if ($value instanceof static) {
14
             return $value;
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 View File

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

+ 25
- 53
app/Filament/Company/Clusters/Settings/Pages/CompanyProfile.php View File

4
 
4
 
5
 use App\Enums\Setting\EntityType;
5
 use App\Enums\Setting\EntityType;
6
 use App\Filament\Company\Clusters\Settings;
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
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
9
 use App\Models\Setting\CompanyProfile as CompanyProfileModel;
11
 use App\Utilities\Localization\Timezone;
10
 use App\Utilities\Localization\Timezone;
12
 use Filament\Actions\Action;
11
 use Filament\Actions\Action;
14
 use Filament\Forms\Components\Component;
13
 use Filament\Forms\Components\Component;
15
 use Filament\Forms\Components\FileUpload;
14
 use Filament\Forms\Components\FileUpload;
16
 use Filament\Forms\Components\Group;
15
 use Filament\Forms\Components\Group;
16
+use Filament\Forms\Components\Hidden;
17
 use Filament\Forms\Components\Section;
17
 use Filament\Forms\Components\Section;
18
 use Filament\Forms\Components\Select;
18
 use Filament\Forms\Components\Select;
19
 use Filament\Forms\Components\TextInput;
19
 use Filament\Forms\Components\TextInput;
20
 use Filament\Forms\Form;
20
 use Filament\Forms\Form;
21
-use Filament\Forms\Get;
22
-use Filament\Forms\Set;
23
 use Filament\Notifications\Notification;
21
 use Filament\Notifications\Notification;
24
 use Filament\Pages\Concerns\InteractsWithFormActions;
22
 use Filament\Pages\Concerns\InteractsWithFormActions;
25
 use Filament\Pages\Page;
23
 use Filament\Pages\Page;
95
             return;
93
             return;
96
         }
94
         }
97
 
95
 
98
-        $countryChanged = $this->record->wasChanged('country');
99
-        $stateChanged = $this->record->wasChanged('state_id');
100
-
101
         $this->getSavedNotification()->send();
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
     protected function updateTimezone(string $countryCode): void
99
     protected function updateTimezone(string $countryCode): void
149
         return $form
136
         return $form
150
             ->schema([
137
             ->schema([
151
                 $this->getIdentificationSection(),
138
                 $this->getIdentificationSection(),
139
+                $this->getNeedsAddressCompletionAlert(),
152
                 $this->getLocationDetailsSection(),
140
                 $this->getLocationDetailsSection(),
153
                 $this->getLegalAndComplianceSection(),
141
                 $this->getLegalAndComplianceSection(),
154
             ])
142
             ])
167
                             ->email()
155
                             ->email()
168
                             ->localizeLabel()
156
                             ->localizeLabel()
169
                             ->maxLength(255)
157
                             ->maxLength(255)
170
-                            ->required(),
158
+                            ->softRequired(),
171
                         TextInput::make('phone_number')
159
                         TextInput::make('phone_number')
172
                             ->tel()
160
                             ->tel()
173
-                            ->nullable()
174
                             ->localizeLabel(),
161
                             ->localizeLabel(),
175
                     ])->columns(1),
162
                     ])->columns(1),
176
                 FileUpload::make('logo')
163
                 FileUpload::make('logo')
196
             ])->columns();
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
     protected function getLocationDetailsSection(): Component
196
     protected function getLocationDetailsSection(): Component
200
     {
197
     {
201
-        return Section::make('Location Details')
198
+        return Section::make('Address Information')
199
+            ->relationship('address')
202
             ->schema([
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
     protected function getLegalAndComplianceSection(): Component
209
     protected function getLegalAndComplianceSection(): Component
240
                 Select::make('entity_type')
213
                 Select::make('entity_type')
241
                     ->localizeLabel()
214
                     ->localizeLabel()
242
                     ->options(EntityType::class)
215
                     ->options(EntityType::class)
243
-                    ->required(),
216
+                    ->softRequired(),
244
                 TextInput::make('tax_id')
217
                 TextInput::make('tax_id')
245
                     ->localizeLabel('Tax ID')
218
                     ->localizeLabel('Tax ID')
246
-                    ->maxLength(50)
247
-                    ->nullable(),
219
+                    ->maxLength(50),
248
             ])->columns();
220
             ])->columns();
249
     }
221
     }
250
 
222
 

+ 1
- 1
app/Filament/Company/Clusters/Settings/Pages/Localization.php View File

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

+ 10
- 2
app/Filament/Company/Pages/CreateCompany.php View File

2
 
2
 
3
 namespace App\Filament\Company\Pages;
3
 namespace App\Filament\Company\Pages;
4
 
4
 
5
+use App\Enums\Common\AddressType;
5
 use App\Enums\Setting\EntityType;
6
 use App\Enums\Setting\EntityType;
6
 use App\Models\Company;
7
 use App\Models\Company;
7
 use App\Models\Locale\Country;
8
 use App\Models\Locale\Country;
66
                     ->live()
67
                     ->live()
67
                     ->searchable()
68
                     ->searchable()
68
                     ->options(Country::getAvailableCountryOptions())
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
                     ->softRequired(),
72
                     ->softRequired(),
70
                 Select::make('locale.language')
73
                 Select::make('locale.language')
71
                     ->label('Language')
74
                     ->label('Language')
101
                 'personal_company' => $personalCompany,
104
                 'personal_company' => $personalCompany,
102
             ]);
105
             ]);
103
 
106
 
104
-            $company->profile()->create([
107
+            $profile = $company->profile()->create([
105
                 'email' => $data['profile']['email'],
108
                 'email' => $data['profile']['email'],
106
                 'entity_type' => $data['profile']['entity_type'],
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
             $user?->switchCompany($company);
118
             $user?->switchCompany($company);

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

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

+ 18
- 0
app/Filament/Company/Resources/Purchases/BillResource/Pages/CreateBill.php View File

6
 use App\Concerns\RedirectToListPage;
6
 use App\Concerns\RedirectToListPage;
7
 use App\Filament\Company\Resources\Purchases\BillResource;
7
 use App\Filament\Company\Resources\Purchases\BillResource;
8
 use App\Models\Accounting\Bill;
8
 use App\Models\Accounting\Bill;
9
+use App\Models\Common\Vendor;
9
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Support\Enums\MaxWidth;
11
 use Filament\Support\Enums\MaxWidth;
11
 use Illuminate\Database\Eloquent\Model;
12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12
 
14
 
13
 class CreateBill extends CreateRecord
15
 class CreateBill extends CreateRecord
14
 {
16
 {
17
 
19
 
18
     protected static string $resource = BillResource::class;
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
     public function getMaxContentWidth(): MaxWidth | string | null
38
     public function getMaxContentWidth(): MaxWidth | string | null
21
     {
39
     {
22
         return MaxWidth::Full;
40
         return MaxWidth::Full;

+ 57
- 32
app/Filament/Company/Resources/Purchases/VendorResource.php View File

2
 
2
 
3
 namespace App\Filament\Company\Resources\Purchases;
3
 namespace App\Filament\Company\Resources\Purchases;
4
 
4
 
5
+use App\Enums\Accounting\BillStatus;
5
 use App\Enums\Common\ContractorType;
6
 use App\Enums\Common\ContractorType;
6
 use App\Enums\Common\VendorType;
7
 use App\Enums\Common\VendorType;
7
 use App\Filament\Company\Resources\Purchases\VendorResource\Pages;
8
 use App\Filament\Company\Resources\Purchases\VendorResource\Pages;
9
+use App\Filament\Forms\Components\AddressFields;
8
 use App\Filament\Forms\Components\CreateCurrencySelect;
10
 use App\Filament\Forms\Components\CreateCurrencySelect;
9
 use App\Filament\Forms\Components\CustomSection;
11
 use App\Filament\Forms\Components\CustomSection;
10
 use App\Filament\Forms\Components\PhoneBuilder;
12
 use App\Filament\Forms\Components\PhoneBuilder;
13
+use App\Filament\Tables\Columns;
11
 use App\Models\Common\Vendor;
14
 use App\Models\Common\Vendor;
15
+use App\Utilities\Currency\CurrencyConverter;
12
 use Filament\Forms;
16
 use Filament\Forms;
13
 use Filament\Forms\Form;
17
 use Filament\Forms\Form;
14
 use Filament\Resources\Resource;
18
 use Filament\Resources\Resource;
15
 use Filament\Tables;
19
 use Filament\Tables;
16
 use Filament\Tables\Table;
20
 use Filament\Tables\Table;
21
+use Illuminate\Database\Eloquent\Builder;
17
 
22
 
18
 class VendorResource extends Resource
23
 class VendorResource extends Resource
19
 {
24
 {
41
                                     ->columnSpanFull(),
46
                                     ->columnSpanFull(),
42
                                 CreateCurrencySelect::make('currency_code')
47
                                 CreateCurrencySelect::make('currency_code')
43
                                     ->nullable()
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
                                 Forms\Components\Select::make('contractor_type')
50
                                 Forms\Components\Select::make('contractor_type')
46
                                     ->label('Contractor Type')
51
                                     ->label('Contractor Type')
47
                                     ->required()
52
                                     ->required()
48
                                     ->live()
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
                                     ->options(ContractorType::class),
55
                                     ->options(ContractorType::class),
51
                                 Forms\Components\TextInput::make('ssn')
56
                                 Forms\Components\TextInput::make('ssn')
52
                                     ->label('Social Security Number')
57
                                     ->label('Social Security Number')
55
                                     ->mask('999-99-9999')
60
                                     ->mask('999-99-9999')
56
                                     ->stripCharacters('-')
61
                                     ->stripCharacters('-')
57
                                     ->maxLength(11)
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
                                     ->maxLength(255),
64
                                     ->maxLength(255),
60
                                 Forms\Components\TextInput::make('ein')
65
                                 Forms\Components\TextInput::make('ein')
61
                                     ->label('Employer Identification Number')
66
                                     ->label('Employer Identification Number')
64
                                     ->mask('99-9999999')
69
                                     ->mask('99-9999999')
65
                                     ->stripCharacters('-')
70
                                     ->stripCharacters('-')
66
                                     ->maxLength(10)
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
                                     ->maxLength(255),
73
                                     ->maxLength(255),
69
                                 Forms\Components\TextInput::make('account_number')
74
                                 Forms\Components\TextInput::make('account_number')
70
                                     ->maxLength(255),
75
                                     ->maxLength(255),
141
                     ->schema([
146
                     ->schema([
142
                         Forms\Components\Hidden::make('type')
147
                         Forms\Components\Hidden::make('type')
143
                             ->default('general'),
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
                     ->columns(2),
151
                     ->columns(2),
169
             ]);
152
             ]);
173
     {
156
     {
174
         return $table
157
         return $table
175
             ->columns([
158
             ->columns([
159
+                Columns::id(),
176
                 Tables\Columns\TextColumn::make('type')
160
                 Tables\Columns\TextColumn::make('type')
177
                     ->badge()
161
                     ->badge()
178
-                    ->searchable(),
162
+                    ->searchable()
163
+                    ->sortable(),
179
                 Tables\Columns\TextColumn::make('name')
164
                 Tables\Columns\TextColumn::make('name')
180
                     ->searchable()
165
                     ->searchable()
181
-                    ->description(fn (Vendor $vendor) => $vendor->contact?->full_name),
166
+                    ->sortable()
167
+                    ->description(static fn (Vendor $vendor) => $vendor->contact?->full_name),
182
                 Tables\Columns\TextColumn::make('contact.email')
168
                 Tables\Columns\TextColumn::make('contact.email')
183
                     ->label('Email')
169
                     ->label('Email')
184
                     ->searchable(),
170
                     ->searchable(),
185
-                Tables\Columns\TextColumn::make('primaryContact.phones')
171
+                Tables\Columns\TextColumn::make('contact.first_available_phone')
186
                     ->label('Phone')
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
             ->filters([
210
             ->filters([
190
                 //
211
                 //
191
             ])
212
             ])
192
             ->actions([
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
             ->bulkActions([
219
             ->bulkActions([
196
                 Tables\Actions\BulkActionGroup::make([
220
                 Tables\Actions\BulkActionGroup::make([
211
         return [
235
         return [
212
             'index' => Pages\ListVendors::route('/'),
236
             'index' => Pages\ListVendors::route('/'),
213
             'create' => Pages\CreateVendor::route('/create'),
237
             'create' => Pages\CreateVendor::route('/create'),
238
+            'view' => Pages\ViewVendor::route('/{record}'),
214
             'edit' => Pages\EditVendor::route('/{record}/edit'),
239
             'edit' => Pages\EditVendor::route('/{record}/edit'),
215
         ];
240
         ];
216
     }
241
     }

+ 62
- 0
app/Filament/Company/Resources/Purchases/VendorResource/Pages/ViewVendor.php View File

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 View File

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 View File

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 View File

3
 namespace App\Filament\Company\Resources\Sales;
3
 namespace App\Filament\Company\Resources\Sales;
4
 
4
 
5
 use App\Filament\Company\Resources\Sales\ClientResource\Pages;
5
 use App\Filament\Company\Resources\Sales\ClientResource\Pages;
6
+use App\Filament\Forms\Components\AddressFields;
6
 use App\Filament\Forms\Components\CreateCurrencySelect;
7
 use App\Filament\Forms\Components\CreateCurrencySelect;
7
 use App\Filament\Forms\Components\CustomSection;
8
 use App\Filament\Forms\Components\CustomSection;
8
 use App\Filament\Forms\Components\PhoneBuilder;
9
 use App\Filament\Forms\Components\PhoneBuilder;
10
+use App\Filament\Tables\Columns;
11
+use App\Models\Common\Address;
9
 use App\Models\Common\Client;
12
 use App\Models\Common\Client;
13
+use App\Utilities\Currency\CurrencyConverter;
10
 use Filament\Forms;
14
 use Filament\Forms;
11
 use Filament\Forms\Form;
15
 use Filament\Forms\Form;
16
+use Filament\Forms\Get;
17
+use Filament\Forms\Set;
12
 use Filament\Resources\Resource;
18
 use Filament\Resources\Resource;
13
 use Filament\Tables;
19
 use Filament\Tables;
14
 use Filament\Tables\Table;
20
 use Filament\Tables\Table;
21
+use Illuminate\Database\Eloquent\Builder;
15
 
22
 
16
 class ClientResource extends Resource
23
 class ClientResource extends Resource
17
 {
24
 {
170
                         CreateCurrencySelect::make('currency_code'),
177
                         CreateCurrencySelect::make('currency_code'),
171
                         CustomSection::make('Billing Address')
178
                         CustomSection::make('Billing Address')
172
                             ->relationship('billingAddress')
179
                             ->relationship('billingAddress')
180
+                            ->saveRelationshipsUsing(null)
181
+                            ->dehydrated(true)
173
                             ->contained(false)
182
                             ->contained(false)
174
                             ->schema([
183
                             ->schema([
175
                                 Forms\Components\Hidden::make('type')
184
                                 Forms\Components\Hidden::make('type')
176
                                     ->default('billing'),
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
                             ])->columns(),
187
                             ])->columns(),
201
                     ])
188
                     ])
202
                     ->columns(1),
189
                     ->columns(1),
203
                 Forms\Components\Section::make('Shipping')
190
                 Forms\Components\Section::make('Shipping')
204
                     ->relationship('shippingAddress')
191
                     ->relationship('shippingAddress')
192
+                    ->saveRelationshipsUsing(null)
193
+                    ->dehydrated(true)
205
                     ->schema([
194
                     ->schema([
195
+                        Forms\Components\Hidden::make('type')
196
+                            ->default('shipping'),
206
                         Forms\Components\TextInput::make('recipient')
197
                         Forms\Components\TextInput::make('recipient')
207
                             ->label('Recipient')
198
                             ->label('Recipient')
208
                             ->required()
199
                             ->required()
209
                             ->maxLength(255),
200
                             ->maxLength(255),
210
-                        Forms\Components\Hidden::make('type')
211
-                            ->default('shipping'),
212
                         Forms\Components\TextInput::make('phone')
201
                         Forms\Components\TextInput::make('phone')
213
                             ->label('Phone')
202
                             ->label('Phone')
214
                             ->required()
203
                             ->required()
216
                         CustomSection::make('Shipping Address')
205
                         CustomSection::make('Shipping Address')
217
                             ->contained(false)
206
                             ->contained(false)
218
                             ->schema([
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
                                 Forms\Components\Textarea::make('notes')
241
                                 Forms\Components\Textarea::make('notes')
243
                                     ->label('Delivery Instructions')
242
                                     ->label('Delivery Instructions')
244
                                     ->maxLength(255)
243
                                     ->maxLength(255)
252
     {
251
     {
253
         return $table
252
         return $table
254
             ->columns([
253
             ->columns([
254
+                Columns::id(),
255
                 Tables\Columns\TextColumn::make('name')
255
                 Tables\Columns\TextColumn::make('name')
256
                     ->searchable()
256
                     ->searchable()
257
-                    ->description(fn (Client $client) => $client->primaryContact->full_name),
257
+                    ->sortable()
258
+                    ->description(static fn (Client $client) => $client->primaryContact->full_name),
258
                 Tables\Columns\TextColumn::make('primaryContact.email')
259
                 Tables\Columns\TextColumn::make('primaryContact.email')
259
                     ->label('Email')
260
                     ->label('Email')
260
-                    ->searchable(),
261
+                    ->searchable()
262
+                    ->toggleable(),
261
                 Tables\Columns\TextColumn::make('primaryContact.phones')
263
                 Tables\Columns\TextColumn::make('primaryContact.phones')
262
                     ->label('Phone')
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
             ->filters([
302
             ->filters([
266
                 //
303
                 //
267
             ])
304
             ])
268
             ->actions([
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
             ->bulkActions([
311
             ->bulkActions([
272
                 Tables\Actions\BulkActionGroup::make([
312
                 Tables\Actions\BulkActionGroup::make([
287
         return [
327
         return [
288
             'index' => Pages\ListClients::route('/'),
328
             'index' => Pages\ListClients::route('/'),
289
             'create' => Pages\CreateClient::route('/create'),
329
             'create' => Pages\CreateClient::route('/create'),
330
+            'view' => Pages\ViewClient::route('/{record}'),
290
             'edit' => Pages\EditClient::route('/{record}/edit'),
331
             'edit' => Pages\EditClient::route('/{record}/edit'),
291
         ];
332
         ];
292
     }
333
     }

+ 52
- 0
app/Filament/Company/Resources/Sales/ClientResource/Pages/CreateClient.php View File

3
 namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
3
 namespace App\Filament\Company\Resources\Sales\ClientResource\Pages;
4
 
4
 
5
 use App\Concerns\RedirectToListPage;
5
 use App\Concerns\RedirectToListPage;
6
+use App\Enums\Common\AddressType;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
7
 use App\Filament\Company\Resources\Sales\ClientResource;
8
+use App\Models\Common\Client;
7
 use Filament\Resources\Pages\CreateRecord;
9
 use Filament\Resources\Pages\CreateRecord;
8
 use Filament\Support\Enums\MaxWidth;
10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
9
 
12
 
10
 class CreateClient extends CreateRecord
13
 class CreateClient extends CreateRecord
11
 {
14
 {
17
     {
20
     {
18
         return MaxWidth::FiveExtraLarge;
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 View File

4
 
4
 
5
 use App\Concerns\RedirectToListPage;
5
 use App\Concerns\RedirectToListPage;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
7
+use App\Models\Common\Client;
7
 use Filament\Actions;
8
 use Filament\Actions;
8
 use Filament\Resources\Pages\EditRecord;
9
 use Filament\Resources\Pages\EditRecord;
9
 use Filament\Support\Enums\MaxWidth;
10
 use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
10
 
12
 
11
 class EditClient extends EditRecord
13
 class EditClient extends EditRecord
12
 {
14
 {
25
     {
27
     {
26
         return MaxWidth::FiveExtraLarge;
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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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

+ 18
- 0
app/Filament/Company/Resources/Sales/EstimateResource/Pages/CreateEstimate.php View File

6
 use App\Concerns\RedirectToListPage;
6
 use App\Concerns\RedirectToListPage;
7
 use App\Filament\Company\Resources\Sales\EstimateResource;
7
 use App\Filament\Company\Resources\Sales\EstimateResource;
8
 use App\Models\Accounting\Estimate;
8
 use App\Models\Accounting\Estimate;
9
+use App\Models\Common\Client;
9
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Support\Enums\MaxWidth;
11
 use Filament\Support\Enums\MaxWidth;
11
 use Illuminate\Database\Eloquent\Model;
12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12
 
14
 
13
 class CreateEstimate extends CreateRecord
15
 class CreateEstimate extends CreateRecord
14
 {
16
 {
17
 
19
 
18
     protected static string $resource = EstimateResource::class;
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
     public function getMaxContentWidth(): MaxWidth | string | null
38
     public function getMaxContentWidth(): MaxWidth | string | null
21
     {
39
     {
22
         return MaxWidth::Full;
40
         return MaxWidth::Full;

+ 27
- 16
app/Filament/Company/Resources/Sales/InvoiceResource.php View File

7
 use App\Enums\Accounting\DocumentType;
7
 use App\Enums\Accounting\DocumentType;
8
 use App\Enums\Accounting\InvoiceStatus;
8
 use App\Enums\Accounting\InvoiceStatus;
9
 use App\Enums\Accounting\PaymentMethod;
9
 use App\Enums\Accounting\PaymentMethod;
10
+use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\InvoicesRelationManager;
10
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
11
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
11
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
12
 use App\Filament\Company\Resources\Sales\InvoiceResource\RelationManagers;
12
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
13
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
13
 use App\Filament\Forms\Components\CreateCurrencySelect;
14
 use App\Filament\Forms\Components\CreateCurrencySelect;
14
 use App\Filament\Forms\Components\DocumentTotals;
15
 use App\Filament\Forms\Components\DocumentTotals;
15
 use App\Filament\Tables\Actions\ReplicateBulkAction;
16
 use App\Filament\Tables\Actions\ReplicateBulkAction;
17
+use App\Filament\Tables\Columns;
16
 use App\Filament\Tables\Filters\DateRangeFilter;
18
 use App\Filament\Tables\Filters\DateRangeFilter;
17
 use App\Models\Accounting\Adjustment;
19
 use App\Models\Accounting\Adjustment;
18
 use App\Models\Accounting\Invoice;
20
 use App\Models\Accounting\Invoice;
114
                                             $set('currency_code', $currencyCode);
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
                             Forms\Components\Group::make([
124
                             Forms\Components\Group::make([
120
                                 Forms\Components\TextInput::make('invoice_number')
125
                                 Forms\Components\TextInput::make('invoice_number')
297
         return $table
302
         return $table
298
             ->defaultSort('due_date')
303
             ->defaultSort('due_date')
299
             ->modifyQueryUsing(function (Builder $query, Tables\Contracts\HasTable $livewire) {
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
                 return $query;
313
                 return $query;
307
             })
314
             })
308
             ->columns([
315
             ->columns([
309
-                Tables\Columns\TextColumn::make('id')
310
-                    ->label('ID')
311
-                    ->sortable()
312
-                    ->toggleable(isToggledHiddenByDefault: true)
313
-                    ->searchable(),
316
+                Columns::id(),
314
                 Tables\Columns\TextColumn::make('status')
317
                 Tables\Columns\TextColumn::make('status')
315
                     ->badge()
318
                     ->badge()
316
                     ->searchable(),
319
                     ->searchable(),
317
                 Tables\Columns\TextColumn::make('due_date')
320
                 Tables\Columns\TextColumn::make('due_date')
318
                     ->label('Due')
321
                     ->label('Due')
319
                     ->asRelativeDay()
322
                     ->asRelativeDay()
320
-                    ->sortable(),
323
+                    ->sortable()
324
+                    ->hideOnTabs(['draft']),
321
                 Tables\Columns\TextColumn::make('date')
325
                 Tables\Columns\TextColumn::make('date')
322
                     ->date()
326
                     ->date()
323
                     ->sortable(),
327
                     ->sortable(),
330
                     ->sortable(),
334
                     ->sortable(),
331
                 Tables\Columns\TextColumn::make('client.name')
335
                 Tables\Columns\TextColumn::make('client.name')
332
                     ->sortable()
336
                     ->sortable()
333
-                    ->searchable(),
337
+                    ->searchable()
338
+                    ->hiddenOn(InvoicesRelationManager::class),
334
                 Tables\Columns\TextColumn::make('total')
339
                 Tables\Columns\TextColumn::make('total')
335
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
340
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
336
                     ->sortable()
341
                     ->sortable()
337
-                    ->toggleable(),
342
+                    ->toggleable()
343
+                    ->alignEnd(),
338
                 Tables\Columns\TextColumn::make('amount_paid')
344
                 Tables\Columns\TextColumn::make('amount_paid')
339
                     ->label('Amount Paid')
345
                     ->label('Amount Paid')
340
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
346
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
341
                     ->sortable()
347
                     ->sortable()
342
-                    ->toggleable(),
348
+                    ->alignEnd()
349
+                    ->showOnTabs(['unpaid']),
343
                 Tables\Columns\TextColumn::make('amount_due')
350
                 Tables\Columns\TextColumn::make('amount_due')
344
                     ->label('Amount Due')
351
                     ->label('Amount Due')
345
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
352
                     ->currencyWithConversion(static fn (Invoice $record) => $record->currency_code)
346
-                    ->sortable(),
353
+                    ->sortable()
354
+                    ->alignEnd()
355
+                    ->hideOnTabs(['draft']),
347
             ])
356
             ])
348
             ->filters([
357
             ->filters([
349
                 Tables\Filters\SelectFilter::make('client')
358
                 Tables\Filters\SelectFilter::make('client')
386
             ])
395
             ])
387
             ->actions([
396
             ->actions([
388
                 Tables\Actions\ActionGroup::make([
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
                     Tables\Actions\DeleteAction::make(),
402
                     Tables\Actions\DeleteAction::make(),
392
                     Invoice::getReplicateAction(Tables\Actions\ReplicateAction::class),
403
                     Invoice::getReplicateAction(Tables\Actions\ReplicateAction::class),
393
                     Invoice::getApproveDraftAction(Tables\Actions\Action::class),
404
                     Invoice::getApproveDraftAction(Tables\Actions\Action::class),

+ 18
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/CreateInvoice.php View File

6
 use App\Concerns\RedirectToListPage;
6
 use App\Concerns\RedirectToListPage;
7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
7
 use App\Filament\Company\Resources\Sales\InvoiceResource;
8
 use App\Models\Accounting\Invoice;
8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Common\Client;
9
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Resources\Pages\CreateRecord;
10
 use Filament\Support\Enums\MaxWidth;
11
 use Filament\Support\Enums\MaxWidth;
11
 use Illuminate\Database\Eloquent\Model;
12
 use Illuminate\Database\Eloquent\Model;
13
+use Livewire\Attributes\Url;
12
 
14
 
13
 class CreateInvoice extends CreateRecord
15
 class CreateInvoice extends CreateRecord
14
 {
16
 {
17
 
19
 
18
     protected static string $resource = InvoiceResource::class;
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
     public function getMaxContentWidth(): MaxWidth | string | null
38
     public function getMaxContentWidth(): MaxWidth | string | null
21
     {
39
     {
22
         return MaxWidth::Full;
40
         return MaxWidth::Full;

+ 2
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php View File

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

+ 16
- 8
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php View File

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

+ 18
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php View File

5
 use App\Concerns\ManagesLineItems;
5
 use App\Concerns\ManagesLineItems;
6
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
6
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7
 use App\Models\Accounting\RecurringInvoice;
7
 use App\Models\Accounting\RecurringInvoice;
8
+use App\Models\Common\Client;
8
 use Filament\Resources\Pages\CreateRecord;
9
 use Filament\Resources\Pages\CreateRecord;
9
 use Filament\Support\Enums\MaxWidth;
10
 use Filament\Support\Enums\MaxWidth;
10
 use Illuminate\Database\Eloquent\Model;
11
 use Illuminate\Database\Eloquent\Model;
12
+use Livewire\Attributes\Url;
11
 
13
 
12
 class CreateRecurringInvoice extends CreateRecord
14
 class CreateRecurringInvoice extends CreateRecord
13
 {
15
 {
15
 
17
 
16
     protected static string $resource = RecurringInvoiceResource::class;
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
     public function getMaxContentWidth(): MaxWidth | string | null
36
     public function getMaxContentWidth(): MaxWidth | string | null
19
     {
37
     {
20
         return MaxWidth::Full;
38
         return MaxWidth::Full;

+ 3
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php View File

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

+ 3
- 3
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php View File

6
 use App\Filament\Company\Resources\Sales\ClientResource;
6
 use App\Filament\Company\Resources\Sales\ClientResource;
7
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
7
 use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
8
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
8
 use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
9
+use App\Filament\Infolists\Components\BannerEntry;
9
 use App\Filament\Infolists\Components\DocumentPreview;
10
 use App\Filament\Infolists\Components\DocumentPreview;
10
 use App\Models\Accounting\RecurringInvoice;
11
 use App\Models\Accounting\RecurringInvoice;
11
-use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
12
 use Filament\Actions;
12
 use Filament\Actions;
13
 use Filament\Infolists\Components\Actions\Action;
13
 use Filament\Infolists\Components\Actions\Action;
14
 use Filament\Infolists\Components\Grid;
14
 use Filament\Infolists\Components\Grid;
54
     {
54
     {
55
         return $infolist
55
         return $infolist
56
             ->schema([
56
             ->schema([
57
-                SimpleAlert::make('scheduleIsNotSet')
57
+                BannerEntry::make('scheduleIsNotSet')
58
                     ->info()
58
                     ->info()
59
                     ->title('Schedule Not Set')
59
                     ->title('Schedule Not Set')
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.')
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
                         RecurringInvoice::getUpdateScheduleAction(Action::class)
64
                         RecurringInvoice::getUpdateScheduleAction(Action::class)
65
                             ->outlined(),
65
                             ->outlined(),
66
                     ]),
66
                     ]),
67
-                SimpleAlert::make('readyToApprove')
67
+                BannerEntry::make('readyToApprove')
68
                     ->info()
68
                     ->info()
69
                     ->title('Ready to Approve')
69
                     ->title('Ready to Approve')
70
                     ->description('This recurring invoice is ready for approval. Review the details, and approve it when you’re ready to start generating invoices.')
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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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 View File

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
     protected function isCurrentlyOverdue(): Attribute
190
     protected function isCurrentlyOverdue(): Attribute
184
     {
191
     {
185
         return Attribute::get(function () {
192
         return Attribute::get(function () {

+ 36
- 23
app/Models/Accounting/RecurringInvoice.php View File

17
 use App\Enums\Accounting\Month;
17
 use App\Enums\Accounting\Month;
18
 use App\Enums\Accounting\RecurringInvoiceStatus;
18
 use App\Enums\Accounting\RecurringInvoiceStatus;
19
 use App\Enums\Setting\PaymentTerms;
19
 use App\Enums\Setting\PaymentTerms;
20
+use App\Filament\Forms\Components\Banner;
20
 use App\Filament\Forms\Components\CustomSection;
21
 use App\Filament\Forms\Components\CustomSection;
21
 use App\Models\Common\Client;
22
 use App\Models\Common\Client;
22
 use App\Models\Setting\CompanyProfile;
23
 use App\Models\Setting\CompanyProfile;
23
 use App\Observers\RecurringInvoiceObserver;
24
 use App\Observers\RecurringInvoiceObserver;
24
 use App\Support\ScheduleHandler;
25
 use App\Support\ScheduleHandler;
25
 use App\Utilities\Localization\Timezone;
26
 use App\Utilities\Localization\Timezone;
26
-use CodeWithDennis\SimpleAlert\Components\Forms\SimpleAlert;
27
 use Filament\Actions\Action;
27
 use Filament\Actions\Action;
28
 use Filament\Actions\MountableAction;
28
 use Filament\Actions\MountableAction;
29
 use Filament\Forms;
29
 use Filament\Forms;
200
         return $this->start_date?->gte(today()) ?? false;
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
         $frequency = $this->frequency;
209
         $frequency = $this->frequency;
206
 
210
 
207
         return match (true) {
211
         return match (true) {
215
 
219
 
216
             $frequency->isCustom() => $this->getCustomScheduleDescription(),
220
             $frequency->isCustom() => $this->getCustomScheduleDescription(),
217
 
221
 
218
-            default => 'Not Configured',
222
+            default => null,
219
         };
223
         };
220
     }
224
     }
221
 
225
 
238
         return "Repeat every {$interval}{$dayDescription}";
242
         return "Repeat every {$interval}{$dayDescription}";
239
     }
243
     }
240
 
244
 
241
-    public function getEndDescription(): string
245
+    public function getEndDescription(): ?string
242
     {
246
     {
243
         if (! $this->end_type) {
247
         if (! $this->end_type) {
244
-            return 'Not configured';
248
+            return null;
245
         }
249
         }
246
 
250
 
247
         return match (true) {
251
         return match (true) {
251
 
255
 
252
             $this->end_type->isOn() && $this->end_date => 'On ' . $this->end_date->toDefaultDateFormat(),
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
         $parts = [];
268
         $parts = [];
261
 
269
 
262
         if ($this->start_date) {
270
         if ($this->start_date) {
303
         return $nextDate;
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
     protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon
335
     protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon
325
         return match ($this->interval_type) {
339
         return match ($this->interval_type) {
326
             IntervalType::Day => $lastDate->copy()->addDays($interval),
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
             default => null
348
             default => null
335
         };
349
         };
400
                                     $handler->handleFrequencyChange($state);
414
                                     $handler->handleFrequencyChange($state);
401
                                 }),
415
                                 }),
402
 
416
 
403
-                            // Custom frequency fields in a nested grid
404
                             Cluster::make([
417
                             Cluster::make([
405
                                 Forms\Components\TextInput::make('interval_value')
418
                                 Forms\Components\TextInput::make('interval_value')
406
                                     ->softRequired()
419
                                     ->softRequired()
422
                                 ->markAsRequired(false)
435
                                 ->markAsRequired(false)
423
                                 ->visible($frequency->isCustom()),
436
                                 ->visible($frequency->isCustom()),
424
 
437
 
425
-                            // Specific schedule details
426
                             Forms\Components\Select::make('month')
438
                             Forms\Components\Select::make('month')
427
                                 ->label('Month')
439
                                 ->label('Month')
428
                                 ->options(Month::class)
440
                                 ->options(Month::class)
455
                                     $handler->handleDateChange('day_of_month', $state);
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
                                 ->columnSpanFull()
475
                                 ->columnSpanFull()
463
-                                ->visible($dayOfMonth?->value > 28),
476
+                                ->visible($dayOfMonth?->mayExceedMonthLength() && ($frequency->isMonthly() || $intervalType?->isMonth())),
464
 
477
 
465
                             Forms\Components\Select::make('day_of_week')
478
                             Forms\Components\Select::make('day_of_week')
466
                                 ->label('Day of Week')
479
                                 ->label('Day of Week')
467
                                 ->options(DayOfWeek::class)
480
                                 ->options(DayOfWeek::class)
468
                                 ->softRequired()
481
                                 ->softRequired()
469
-                                ->visible($frequency->isWeekly() || $intervalType?->isWeek())
482
+                                ->visible(($frequency->isWeekly() || $intervalType?->isWeek()) ?? false)
470
                                 ->live()
483
                                 ->live()
471
                                 ->afterStateUpdated(function (Forms\Set $set, $state) {
484
                                 ->afterStateUpdated(function (Forms\Set $set, $state) {
472
                                     $handler = new ScheduleHandler($set);
485
                                     $handler = new ScheduleHandler($set);

+ 52
- 2
app/Models/Common/Address.php View File

5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Common\AddressType;
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
 use Illuminate\Database\Eloquent\Factories\HasFactory;
11
 use Illuminate\Database\Eloquent\Factories\HasFactory;
9
 use Illuminate\Database\Eloquent\Model;
12
 use Illuminate\Database\Eloquent\Model;
13
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\HasMany;
10
 use Illuminate\Database\Eloquent\Relations\MorphTo;
15
 use Illuminate\Database\Eloquent\Relations\MorphTo;
11
 
16
 
12
 class Address extends Model
17
 class Address extends Model
19
 
24
 
20
     protected $fillable = [
25
     protected $fillable = [
21
         'company_id',
26
         'company_id',
27
+        'parent_address_id',
22
         'type',
28
         'type',
23
         'recipient',
29
         'recipient',
24
         'phone',
30
         'phone',
25
         'address_line_1',
31
         'address_line_1',
26
         'address_line_2',
32
         'address_line_2',
27
         'city',
33
         'city',
28
-        'state',
34
+        'state_id',
29
         'postal_code',
35
         'postal_code',
30
-        'country',
36
+        'country_code',
31
         'notes',
37
         'notes',
32
         'created_by',
38
         'created_by',
33
         'updated_by',
39
         'updated_by',
41
     {
47
     {
42
         return $this->morphTo();
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 View File

5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Common\AddressType;
7
 use App\Enums\Common\AddressType;
8
+use App\Models\Accounting\Estimate;
8
 use App\Models\Accounting\Invoice;
9
 use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\RecurringInvoice;
9
 use App\Models\Setting\Currency;
11
 use App\Models\Setting\Currency;
10
 use Illuminate\Database\Eloquent\Factories\HasFactory;
12
 use Illuminate\Database\Eloquent\Factories\HasFactory;
11
 use Illuminate\Database\Eloquent\Model;
13
 use Illuminate\Database\Eloquent\Model;
72
             ->where('type', AddressType::Shipping);
74
             ->where('type', AddressType::Shipping);
73
     }
75
     }
74
 
76
 
77
+    public function estimates(): HasMany
78
+    {
79
+        return $this->hasMany(Estimate::class);
80
+    }
81
+
75
     public function invoices(): HasMany
82
     public function invoices(): HasMany
76
     {
83
     {
77
         return $this->hasMany(Invoice::class);
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 View File

2
 
2
 
3
 namespace App\Models\Locale;
3
 namespace App\Models\Locale;
4
 
4
 
5
-use App\Models\Setting\CompanyProfile;
5
+use App\Models\Common\Address;
6
 use Illuminate\Database\Eloquent\Casts\Attribute;
6
 use Illuminate\Database\Eloquent\Casts\Attribute;
7
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
7
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
8
 use Illuminate\Database\Eloquent\Relations\HasMany;
8
 use Illuminate\Database\Eloquent\Relations\HasMany;
49
         return $this->belongsTo(Currency::class, 'currency_code', 'code');
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
     public function states(): HasMany
57
     public function states(): HasMany
80
 
80
 
81
     public static function getAllCountryCodes(): Collection
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
     public static function getAvailableCountryOptions(): array
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
     public static function getLanguagesByCountryCode(?string $code = null): array
124
     public static function getLanguagesByCountryCode(?string $code = null): array
94
     {
125
     {
95
-        if ($code === null) {
126
+        if (! $code) {
96
             return Locales::getNames();
127
             return Locales::getNames();
97
         }
128
         }
98
 
129
 

+ 35
- 5
app/Models/Locale/State.php View File

4
 
4
 
5
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
5
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
6
 use Illuminate\Database\Eloquent\Relations\HasMany;
6
 use Illuminate\Database\Eloquent\Relations\HasMany;
7
-use Illuminate\Support\Collection;
8
 use Squire\Model;
7
 use Squire\Model;
9
 
8
 
10
 /**
9
 /**
28
         'longitude' => 'float',
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
     public function country(): BelongsTo
70
     public function country(): BelongsTo

+ 4
- 26
app/Models/Setting/CompanyProfile.php View File

5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Setting\EntityType;
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
 use Database\Factories\Setting\CompanyProfileFactory;
9
 use Database\Factories\Setting\CompanyProfileFactory;
12
 use Illuminate\Database\Eloquent\Casts\Attribute;
10
 use Illuminate\Database\Eloquent\Casts\Attribute;
13
 use Illuminate\Database\Eloquent\Factories\Factory;
11
 use Illuminate\Database\Eloquent\Factories\Factory;
14
 use Illuminate\Database\Eloquent\Factories\HasFactory;
12
 use Illuminate\Database\Eloquent\Factories\HasFactory;
15
 use Illuminate\Database\Eloquent\Model;
13
 use Illuminate\Database\Eloquent\Model;
16
-use Illuminate\Database\Eloquent\Relations\BelongsTo;
14
+use Illuminate\Database\Eloquent\Relations\MorphOne;
17
 use Illuminate\Support\Facades\Storage;
15
 use Illuminate\Support\Facades\Storage;
18
 
16
 
19
 class CompanyProfile extends Model
17
 class CompanyProfile extends Model
27
     protected $fillable = [
25
     protected $fillable = [
28
         'company_id',
26
         'company_id',
29
         'logo',
27
         'logo',
30
-        'address',
31
-        'city_id',
32
-        'zip_code',
33
-        'state_id',
34
-        'country',
35
         'phone_number',
28
         'phone_number',
36
         'email',
29
         'email',
37
         'tax_id',
30
         'tax_id',
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
     protected static function newFactory(): Factory
60
     protected static function newFactory(): Factory

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

187
         return $options[$optionValue] ?? null;
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
     protected static function newFactory(): Factory
207
     protected static function newFactory(): Factory
191
     {
208
     {
192
         return DocumentDefaultFactory::new();
209
         return DocumentDefaultFactory::new();

+ 4
- 0
app/Providers/FilamentCompaniesServiceProvider.php View File

276
                 ->filtersFormWidth(MaxWidth::Small)
276
                 ->filtersFormWidth(MaxWidth::Small)
277
                 ->filtersTriggerAction(fn (Tables\Actions\Action $action) => $action->slideOver());
277
                 ->filtersTriggerAction(fn (Tables\Actions\Action $action) => $action->slideOver());
278
         }, isImportant: true);
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 View File

19
 use Filament\Forms\Components\TextInput;
19
 use Filament\Forms\Components\TextInput;
20
 use Filament\Infolists\Components\TextEntry;
20
 use Filament\Infolists\Components\TextEntry;
21
 use Filament\Tables\Columns\TextColumn;
21
 use Filament\Tables\Columns\TextColumn;
22
+use Filament\Tables\Contracts\HasTable;
23
+use Illuminate\Contracts\Support\Htmlable;
22
 use Illuminate\Support\Carbon;
24
 use Illuminate\Support\Carbon;
25
+use Illuminate\Support\HtmlString;
23
 use Illuminate\Support\ServiceProvider;
26
 use Illuminate\Support\ServiceProvider;
24
 
27
 
25
 class MacroServiceProvider extends ServiceProvider
28
 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
         TextColumn::macro('defaultDateFormat', function (): static {
145
         TextColumn::macro('defaultDateFormat', function (): static {
117
             $localization = Localization::firstOrFail();
146
             $localization = Localization::firstOrFail();
118
 
147
 
172
             return $this;
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
             $currency ??= CurrencyAccessor::getDefaultCurrency();
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
                 if (blank($state)) {
209
                 if (blank($state)) {
180
                     return null;
210
                     return null;
181
                 }
211
                 }
182
 
212
 
183
                 $currency = $column->evaluate($currency);
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
                 if (blank($state)) {
230
                 if (blank($state)) {
190
                     return null;
231
                     return null;
191
                 }
232
                 }
197
                     return null;
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
                 $convertedBalanceInCents = CurrencyConverter::convertBalance($balanceInCents, $oldCurrency, $newCurrency);
247
                 $convertedBalanceInCents = CurrencyConverter::convertBalance($balanceInCents, $oldCurrency, $newCurrency);
203
 
248
 
249
+                if ($convertedBalanceInCents < 0) {
250
+                    return '(' . CurrencyConverter::formatCentsToMoney(abs($convertedBalanceInCents), $newCurrency, true) . ')';
251
+                }
252
+
204
                 return CurrencyConverter::formatCentsToMoney($convertedBalanceInCents, $newCurrency, true);
253
                 return CurrencyConverter::formatCentsToMoney($convertedBalanceInCents, $newCurrency, true);
205
             });
254
             });
206
 
255
 

+ 6
- 1
app/Services/ReportService.php View File

11
 use App\DTO\ReportDTO;
11
 use App\DTO\ReportDTO;
12
 use App\Enums\Accounting\AccountCategory;
12
 use App\Enums\Accounting\AccountCategory;
13
 use App\Enums\Accounting\AccountType;
13
 use App\Enums\Accounting\AccountType;
14
+use App\Enums\Accounting\TransactionType;
14
 use App\Models\Accounting\Account;
15
 use App\Models\Accounting\Account;
15
 use App\Models\Accounting\Transaction;
16
 use App\Models\Accounting\Transaction;
16
 use App\Support\Column;
17
 use App\Support\Column;
261
         if ($transaction->transactionable_type === null || $transaction->is_payment) {
262
         if ($transaction->transactionable_type === null || $transaction->is_payment) {
262
             return [
263
             return [
263
                 'type' => 'transaction',
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
                 'id' => $transaction->id,
270
                 'id' => $transaction->id,
266
             ];
271
             ];
267
         }
272
         }

+ 8
- 2
app/Utilities/Currency/CurrencyConverter.php View File

62
         return $money->format();
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
         $currency ??= CurrencyAccessor::getDefaultCurrency();
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
     public static function convertCentsToFloat(int $amount, ?string $currency = null): float
78
     public static function convertCentsToFloat(int $amount, ?string $currency = null): float

+ 0
- 209
app/View/Models/InvoiceViewModel.php View File

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 View File

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

+ 62
- 133
composer.lock View File

4
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
4
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
5
         "This file is @generated automatically"
5
         "This file is @generated automatically"
6
     ],
6
     ],
7
-    "content-hash": "decc627f2a7bd0c6546114ebb1f500f9",
7
+    "content-hash": "095bb4040f9910ddd128bd53c0670a55",
8
     "packages": [
8
     "packages": [
9
         {
9
         {
10
             "name": "akaunting/laravel-money",
10
             "name": "akaunting/laravel-money",
497
         },
497
         },
498
         {
498
         {
499
             "name": "aws/aws-sdk-php",
499
             "name": "aws/aws-sdk-php",
500
-            "version": "3.336.15",
500
+            "version": "3.337.3",
501
             "source": {
501
             "source": {
502
                 "type": "git",
502
                 "type": "git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
503
                 "url": "https://github.com/aws/aws-sdk-php.git",
504
-                "reference": "f8028ef4b8dcb0acfe86c33e207fd3cb0b9cbf3b"
504
+                "reference": "06dfc8f76423b49aaa181debd25bbdc724c346d6"
505
             },
505
             },
506
             "dist": {
506
             "dist": {
507
                 "type": "zip",
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
                 "shasum": ""
510
                 "shasum": ""
511
             },
511
             },
512
             "require": {
512
             "require": {
589
             "support": {
589
             "support": {
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
590
                 "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80",
591
                 "issues": "https://github.com/aws/aws-sdk-php/issues",
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
             "name": "aws/aws-sdk-php-laravel",
597
             "name": "aws/aws-sdk-php-laravel",
1029
         },
1029
         },
1030
         {
1030
         {
1031
             "name": "codewithdennis/filament-simple-alert",
1031
             "name": "codewithdennis/filament-simple-alert",
1032
-            "version": "v3.0.15",
1032
+            "version": "v3.0.16",
1033
             "source": {
1033
             "source": {
1034
                 "type": "git",
1034
                 "type": "git",
1035
                 "url": "https://github.com/CodeWithDennis/filament-simple-alert.git",
1035
                 "url": "https://github.com/CodeWithDennis/filament-simple-alert.git",
1036
-                "reference": "8fd7dfa48bb98061bcc3f5fbaacb82dce7a09c7b"
1036
+                "reference": "f29677d3a0d2b6fd9b1c3627152cd0107d2db337"
1037
             },
1037
             },
1038
             "dist": {
1038
             "dist": {
1039
                 "type": "zip",
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
                 "shasum": ""
1042
                 "shasum": ""
1043
             },
1043
             },
1044
             "require": {
1044
             "require": {
1098
                     "type": "github"
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
             "name": "danharrin/date-format-converter",
1104
             "name": "danharrin/date-format-converter",
1282
         },
1282
         },
1283
         {
1283
         {
1284
             "name": "doctrine/dbal",
1284
             "name": "doctrine/dbal",
1285
-            "version": "4.2.1",
1285
+            "version": "4.2.2",
1286
             "source": {
1286
             "source": {
1287
                 "type": "git",
1287
                 "type": "git",
1288
                 "url": "https://github.com/doctrine/dbal.git",
1288
                 "url": "https://github.com/doctrine/dbal.git",
1289
-                "reference": "dadd35300837a3a2184bd47d403333b15d0a9bd0"
1289
+                "reference": "19a2b7deb5fe8c2df0ff817ecea305e50acb62ec"
1290
             },
1290
             },
1291
             "dist": {
1291
             "dist": {
1292
                 "type": "zip",
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
                 "shasum": ""
1295
                 "shasum": ""
1296
             },
1296
             },
1297
             "require": {
1297
             "require": {
1304
                 "doctrine/coding-standard": "12.0.0",
1304
                 "doctrine/coding-standard": "12.0.0",
1305
                 "fig/log-test": "^1",
1305
                 "fig/log-test": "^1",
1306
                 "jetbrains/phpstorm-stubs": "2023.2",
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
                 "slevomat/coding-standard": "8.13.1",
1311
                 "slevomat/coding-standard": "8.13.1",
1313
                 "squizlabs/php_codesniffer": "3.10.2",
1312
                 "squizlabs/php_codesniffer": "3.10.2",
1314
                 "symfony/cache": "^6.3.8|^7.0",
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
             "suggest": {
1316
             "suggest": {
1319
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
1317
                 "symfony/console": "For helpful console commands such as SQL execution and import of files."
1370
             ],
1368
             ],
1371
             "support": {
1369
             "support": {
1372
                 "issues": "https://github.com/doctrine/dbal/issues",
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
             "funding": [
1373
             "funding": [
1376
                 {
1374
                 {
1386
                     "type": "tidelift"
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
             "name": "doctrine/deprecations",
1390
             "name": "doctrine/deprecations",
3053
         },
3051
         },
3054
         {
3052
         {
3055
             "name": "laravel/framework",
3053
             "name": "laravel/framework",
3056
-            "version": "v11.38.2",
3054
+            "version": "v11.39.0",
3057
             "source": {
3055
             "source": {
3058
                 "type": "git",
3056
                 "type": "git",
3059
                 "url": "https://github.com/laravel/framework.git",
3057
                 "url": "https://github.com/laravel/framework.git",
3060
-                "reference": "9d290aa90fcad44048bedca5219d2b872e98772a"
3058
+                "reference": "996c96955f78e8a2b26a24c490a1721cfb14574f"
3061
             },
3059
             },
3062
             "dist": {
3060
             "dist": {
3063
                 "type": "zip",
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
                 "shasum": ""
3064
                 "shasum": ""
3067
             },
3065
             },
3068
             "require": {
3066
             "require": {
3263
                 "issues": "https://github.com/laravel/framework/issues",
3261
                 "issues": "https://github.com/laravel/framework/issues",
3264
                 "source": "https://github.com/laravel/framework"
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
             "name": "laravel/prompts",
3267
             "name": "laravel/prompts",
3451
         },
3449
         },
3452
         {
3450
         {
3453
             "name": "laravel/socialite",
3451
             "name": "laravel/socialite",
3454
-            "version": "v5.16.1",
3452
+            "version": "v5.17.0",
3455
             "source": {
3453
             "source": {
3456
                 "type": "git",
3454
                 "type": "git",
3457
                 "url": "https://github.com/laravel/socialite.git",
3455
                 "url": "https://github.com/laravel/socialite.git",
3458
-                "reference": "4e5be83c0b3ecf81b2ffa47092e917d1f79dce71"
3456
+                "reference": "77be8be7ee5099aed8ca7cfddc1bf6f9ab3fc159"
3459
             },
3457
             },
3460
             "dist": {
3458
             "dist": {
3461
                 "type": "zip",
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
                 "shasum": ""
3462
                 "shasum": ""
3465
             },
3463
             },
3466
             "require": {
3464
             "require": {
3519
                 "issues": "https://github.com/laravel/socialite/issues",
3517
                 "issues": "https://github.com/laravel/socialite/issues",
3520
                 "source": "https://github.com/laravel/socialite"
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
             "name": "laravel/tinker",
3523
             "name": "laravel/tinker",
4446
         },
4444
         },
4447
         {
4445
         {
4448
             "name": "matomo/device-detector",
4446
             "name": "matomo/device-detector",
4449
-            "version": "6.4.2",
4447
+            "version": "6.4.3",
4450
             "source": {
4448
             "source": {
4451
                 "type": "git",
4449
                 "type": "git",
4452
                 "url": "https://github.com/matomo-org/device-detector.git",
4450
                 "url": "https://github.com/matomo-org/device-detector.git",
4453
-                "reference": "806e52d214b05ddead1a1d4304c7592f61f95976"
4451
+                "reference": "aa4586d495a7f59029d46d976f160b13eb769bb0"
4454
             },
4452
             },
4455
             "dist": {
4453
             "dist": {
4456
                 "type": "zip",
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
                 "shasum": ""
4457
                 "shasum": ""
4460
             },
4458
             },
4461
             "require": {
4459
             "require": {
4511
                 "source": "https://github.com/matomo-org/matomo",
4509
                 "source": "https://github.com/matomo-org/matomo",
4512
                 "wiki": "https://dev.matomo.org/"
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
             "name": "monolog/monolog",
4515
             "name": "monolog/monolog",
6493
         },
6491
         },
6494
         {
6492
         {
6495
             "name": "spatie/laravel-package-tools",
6493
             "name": "spatie/laravel-package-tools",
6496
-            "version": "1.18.0",
6494
+            "version": "1.18.2",
6497
             "source": {
6495
             "source": {
6498
                 "type": "git",
6496
                 "type": "git",
6499
                 "url": "https://github.com/spatie/laravel-package-tools.git",
6497
                 "url": "https://github.com/spatie/laravel-package-tools.git",
6500
-                "reference": "8332205b90d17164913244f4a8e13ab7e6761d29"
6498
+                "reference": "d41c44a7eab604c3eb0cad93210612d4c1429c20"
6501
             },
6499
             },
6502
             "dist": {
6500
             "dist": {
6503
                 "type": "zip",
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
                 "shasum": ""
6504
                 "shasum": ""
6507
             },
6505
             },
6508
             "require": {
6506
             "require": {
6541
             ],
6539
             ],
6542
             "support": {
6540
             "support": {
6543
                 "issues": "https://github.com/spatie/laravel-package-tools/issues",
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
             "funding": [
6544
             "funding": [
6547
                 {
6545
                 {
6549
                     "type": "github"
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
             "name": "squirephp/model",
6553
             "name": "squirephp/model",
10151
         },
10080
         },
10152
         {
10081
         {
10153
             "name": "pestphp/pest",
10082
             "name": "pestphp/pest",
10154
-            "version": "v3.7.1",
10083
+            "version": "v3.7.2",
10155
             "source": {
10084
             "source": {
10156
                 "type": "git",
10085
                 "type": "git",
10157
                 "url": "https://github.com/pestphp/pest.git",
10086
                 "url": "https://github.com/pestphp/pest.git",
10158
-                "reference": "bf3178473dcaa53b0458f21dfdb271306ea62512"
10087
+                "reference": "709ecb1ba2641fc0c4653ebe1fd8a402bbf4d18b"
10159
             },
10088
             },
10160
             "dist": {
10089
             "dist": {
10161
                 "type": "zip",
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
                 "shasum": ""
10093
                 "shasum": ""
10165
             },
10094
             },
10166
             "require": {
10095
             "require": {
10171
                 "pestphp/pest-plugin-arch": "^3.0.0",
10100
                 "pestphp/pest-plugin-arch": "^3.0.0",
10172
                 "pestphp/pest-plugin-mutate": "^3.0.5",
10101
                 "pestphp/pest-plugin-mutate": "^3.0.5",
10173
                 "php": "^8.2.0",
10102
                 "php": "^8.2.0",
10174
-                "phpunit/phpunit": "^11.5.1"
10103
+                "phpunit/phpunit": "^11.5.3"
10175
             },
10104
             },
10176
             "conflict": {
10105
             "conflict": {
10177
                 "filp/whoops": "<2.16.0",
10106
                 "filp/whoops": "<2.16.0",
10178
-                "phpunit/phpunit": ">11.5.1",
10107
+                "phpunit/phpunit": ">11.5.3",
10179
                 "sebastian/exporter": "<6.0.0",
10108
                 "sebastian/exporter": "<6.0.0",
10180
                 "webmozart/assert": "<1.11.0"
10109
                 "webmozart/assert": "<1.11.0"
10181
             },
10110
             },
10182
             "require-dev": {
10111
             "require-dev": {
10183
                 "pestphp/pest-dev-tools": "^3.3.0",
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
                 "symfony/process": "^7.2.0"
10114
                 "symfony/process": "^7.2.0"
10186
             },
10115
             },
10187
             "bin": [
10116
             "bin": [
10247
             ],
10176
             ],
10248
             "support": {
10177
             "support": {
10249
                 "issues": "https://github.com/pestphp/pest/issues",
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
             "funding": [
10181
             "funding": [
10253
                 {
10182
                 {
10259
                     "type": "github"
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
             "name": "pestphp/pest-plugin",
10194
             "name": "pestphp/pest-plugin",
11204
         },
11133
         },
11205
         {
11134
         {
11206
             "name": "phpunit/phpunit",
11135
             "name": "phpunit/phpunit",
11207
-            "version": "11.5.1",
11136
+            "version": "11.5.3",
11208
             "source": {
11137
             "source": {
11209
                 "type": "git",
11138
                 "type": "git",
11210
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
11139
                 "url": "https://github.com/sebastianbergmann/phpunit.git",
11211
-                "reference": "2b94d4f2450b9869fa64a46fd8a6a41997aef56a"
11140
+                "reference": "30e319e578a7b5da3543073e30002bf82042f701"
11212
             },
11141
             },
11213
             "dist": {
11142
             "dist": {
11214
                 "type": "zip",
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
                 "shasum": ""
11146
                 "shasum": ""
11218
             },
11147
             },
11219
             "require": {
11148
             "require": {
11227
                 "phar-io/manifest": "^2.0.4",
11156
                 "phar-io/manifest": "^2.0.4",
11228
                 "phar-io/version": "^3.2.1",
11157
                 "phar-io/version": "^3.2.1",
11229
                 "php": ">=8.2",
11158
                 "php": ">=8.2",
11230
-                "phpunit/php-code-coverage": "^11.0.7",
11159
+                "phpunit/php-code-coverage": "^11.0.8",
11231
                 "phpunit/php-file-iterator": "^5.1.0",
11160
                 "phpunit/php-file-iterator": "^5.1.0",
11232
                 "phpunit/php-invoker": "^5.0.1",
11161
                 "phpunit/php-invoker": "^5.0.1",
11233
                 "phpunit/php-text-template": "^4.0.1",
11162
                 "phpunit/php-text-template": "^4.0.1",
11234
                 "phpunit/php-timer": "^7.0.1",
11163
                 "phpunit/php-timer": "^7.0.1",
11235
                 "sebastian/cli-parser": "^3.0.2",
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
                 "sebastian/diff": "^6.0.2",
11167
                 "sebastian/diff": "^6.0.2",
11239
                 "sebastian/environment": "^7.2.0",
11168
                 "sebastian/environment": "^7.2.0",
11240
                 "sebastian/exporter": "^6.3.0",
11169
                 "sebastian/exporter": "^6.3.0",
11285
             "support": {
11214
             "support": {
11286
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
11215
                 "issues": "https://github.com/sebastianbergmann/phpunit/issues",
11287
                 "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
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
             "funding": [
11219
             "funding": [
11291
                 {
11220
                 {
11301
                     "type": "tidelift"
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
             "name": "pimple/pimple",
11236
             "name": "pimple/pimple",

+ 1
- 4
config/aws.php View File

16
     | http://docs.aws.amazon.com/aws-sdk-php/v3/guide/guide/configuration.html
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
     'region' => env('AWS_REGION', 'us-east-1'),
20
     'region' => env('AWS_REGION', 'us-east-1'),
24
     'version' => 'latest',
21
     'version' => 'latest',
25
     'ua_append' => [
22
     'ua_append' => [

+ 5
- 5
database/factories/Accounting/RecurringInvoiceFactory.php View File

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
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
95
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
96
             $this->ensureLineItems($recurringInvoice);
96
             $this->ensureLineItems($recurringInvoice);
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
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
108
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
109
             $this->ensureLineItems($recurringInvoice);
109
             $this->ensureLineItems($recurringInvoice);
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
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
122
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
123
             $this->ensureLineItems($recurringInvoice);
123
             $this->ensureLineItems($recurringInvoice);
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
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
136
         return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($startDate, $endType) {
137
             $this->ensureLineItems($recurringInvoice);
137
             $this->ensureLineItems($recurringInvoice);
146
         });
146
         });
147
     }
147
     }
148
 
148
 
149
-    protected function withCustomSchedule(
149
+    public function withCustomSchedule(
150
         Carbon $startDate,
150
         Carbon $startDate,
151
         EndType $endType,
151
         EndType $endType,
152
         ?IntervalType $intervalType = null,
152
         ?IntervalType $intervalType = null,

+ 2
- 2
database/factories/Common/AddressFactory.php View File

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

+ 6
- 10
database/factories/Common/ContactFactory.php View File

71
 
71
 
72
     public function primary(): self
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
     public function secondary(): self
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 View File

42
     public function withCompanyProfile(): self
42
     public function withCompanyProfile(): self
43
     {
43
     {
44
         return $this->afterCreating(function (Company $company) {
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
     public function withCompanyDefaults(): self
52
     public function withCompanyDefaults(): self
53
     {
53
     {
54
         return $this->afterCreating(function (Company $company) {
54
         return $this->afterCreating(function (Company $company) {
55
-            $countryCode = $company->profile->country;
55
+            $countryCode = $company->profile->address->country_code;
56
             $companyDefaultService = app(CompanyDefaultService::class);
56
             $companyDefaultService = app(CompanyDefaultService::class);
57
             $companyDefaultService->createCompanyDefaults($company, $company->owner, 'USD', $countryCode, 'en');
57
             $companyDefaultService->createCompanyDefaults($company, $company->owner, 'USD', $countryCode, 'en');
58
         });
58
         });

+ 7
- 16
database/factories/Setting/CompanyProfileFactory.php View File

4
 
4
 
5
 use App\Enums\Setting\EntityType;
5
 use App\Enums\Setting\EntityType;
6
 use App\Faker\State;
6
 use App\Faker\State;
7
+use App\Models\Common\Address;
7
 use App\Models\Company;
8
 use App\Models\Company;
8
 use App\Models\Setting\CompanyProfile;
9
 use App\Models\Setting\CompanyProfile;
9
 use Illuminate\Database\Eloquent\Factories\Factory;
10
 use Illuminate\Database\Eloquent\Factories\Factory;
25
      */
26
      */
26
     public function definition(): array
27
     public function definition(): array
27
     {
28
     {
28
-        $countryCode = $this->faker->countryCode;
29
-
30
         return [
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
             'email' => $this->faker->email,
31
             'email' => $this->faker->email,
37
             'entity_type' => $this->faker->randomElement(EntityType::class),
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
     public function forCompany(Company $company): self
36
     public function forCompany(Company $company): self
51
     {
37
     {
52
         return $this->state([
38
         return $this->state([
55
             'updated_by' => $company->owner->id,
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 View File

51
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
51
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
52
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
52
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
53
             $table->timestamps();
53
             $table->timestamps();
54
-
55
-            $table->unique(['company_id', 'code']);
56
         });
54
         });
57
 
55
 
58
         Schema::create('bank_accounts', function (Blueprint $table) {
56
         Schema::create('bank_accounts', function (Blueprint $table) {
66
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
64
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
67
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
65
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
68
             $table->timestamps();
66
             $table->timestamps();
69
-
70
-            $table->unique(['company_id', 'account_id']);
71
         });
67
         });
72
 
68
 
73
         Schema::create('connected_bank_accounts', function (Blueprint $table) {
69
         Schema::create('connected_bank_accounts', function (Blueprint $table) {
98
      */
94
      */
99
     public function down(): void
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
         Schema::dropIfExists('connected_bank_accounts');
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 View File

15
             $table->id();
15
             $table->id();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
             $table->string('logo')->nullable();
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
             $table->string('phone_number', 30)->nullable();
18
             $table->string('phone_number', 30)->nullable();
24
             $table->string('email', 255)->nullable();
19
             $table->string('email', 255)->nullable();
25
             $table->string('tax_id', 50)->nullable();
20
             $table->string('tax_id', 50)->nullable();

+ 0
- 2
database/migrations/2024_11_14_230753_create_adjustments_table.php View File

28
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
28
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
29
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
30
             $table->timestamps();
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 View File

14
         Schema::create('addresses', function (Blueprint $table) {
14
         Schema::create('addresses', function (Blueprint $table) {
15
             $table->id();
15
             $table->id();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('parent_address_id')->nullable()->constrained('addresses')->nullOnDelete();
17
             $table->morphs('addressable');
18
             $table->morphs('addressable');
18
             $table->string('type'); // billing, shipping, etc.
19
             $table->string('type'); // billing, shipping, etc.
19
             $table->string('recipient')->nullable();
20
             $table->string('recipient')->nullable();
20
             $table->string('phone')->nullable();
21
             $table->string('phone')->nullable();
21
-            $table->string('address_line_1');
22
+            $table->string('address_line_1')->nullable();
22
             $table->string('address_line_2')->nullable();
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
             $table->string('postal_code')->nullable();
26
             $table->string('postal_code')->nullable();
26
-            $table->string('country')->nullable();
27
+            $table->string('country_code');
27
             $table->text('notes')->nullable();
28
             $table->text('notes')->nullable();
28
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
29
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
30
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();

+ 91
- 91
package-lock.json View File

575
             }
575
             }
576
         },
576
         },
577
         "node_modules/@rollup/rollup-android-arm-eabi": {
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
             "cpu": [
581
             "cpu": [
582
                 "arm"
582
                 "arm"
583
             ],
583
             ],
589
             ]
589
             ]
590
         },
590
         },
591
         "node_modules/@rollup/rollup-android-arm64": {
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
             "cpu": [
595
             "cpu": [
596
                 "arm64"
596
                 "arm64"
597
             ],
597
             ],
603
             ]
603
             ]
604
         },
604
         },
605
         "node_modules/@rollup/rollup-darwin-arm64": {
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
             "cpu": [
609
             "cpu": [
610
                 "arm64"
610
                 "arm64"
611
             ],
611
             ],
617
             ]
617
             ]
618
         },
618
         },
619
         "node_modules/@rollup/rollup-darwin-x64": {
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
             "cpu": [
623
             "cpu": [
624
                 "x64"
624
                 "x64"
625
             ],
625
             ],
631
             ]
631
             ]
632
         },
632
         },
633
         "node_modules/@rollup/rollup-freebsd-arm64": {
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
             "cpu": [
637
             "cpu": [
638
                 "arm64"
638
                 "arm64"
639
             ],
639
             ],
645
             ]
645
             ]
646
         },
646
         },
647
         "node_modules/@rollup/rollup-freebsd-x64": {
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
             "cpu": [
651
             "cpu": [
652
                 "x64"
652
                 "x64"
653
             ],
653
             ],
659
             ]
659
             ]
660
         },
660
         },
661
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
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
             "cpu": [
665
             "cpu": [
666
                 "arm"
666
                 "arm"
667
             ],
667
             ],
673
             ]
673
             ]
674
         },
674
         },
675
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
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
             "cpu": [
679
             "cpu": [
680
                 "arm"
680
                 "arm"
681
             ],
681
             ],
687
             ]
687
             ]
688
         },
688
         },
689
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
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
             "cpu": [
693
             "cpu": [
694
                 "arm64"
694
                 "arm64"
695
             ],
695
             ],
701
             ]
701
             ]
702
         },
702
         },
703
         "node_modules/@rollup/rollup-linux-arm64-musl": {
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
             "cpu": [
707
             "cpu": [
708
                 "arm64"
708
                 "arm64"
709
             ],
709
             ],
715
             ]
715
             ]
716
         },
716
         },
717
         "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
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
             "cpu": [
721
             "cpu": [
722
                 "loong64"
722
                 "loong64"
723
             ],
723
             ],
729
             ]
729
             ]
730
         },
730
         },
731
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
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
             "cpu": [
735
             "cpu": [
736
                 "ppc64"
736
                 "ppc64"
737
             ],
737
             ],
743
             ]
743
             ]
744
         },
744
         },
745
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
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
             "cpu": [
749
             "cpu": [
750
                 "riscv64"
750
                 "riscv64"
751
             ],
751
             ],
757
             ]
757
             ]
758
         },
758
         },
759
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
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
             "cpu": [
763
             "cpu": [
764
                 "s390x"
764
                 "s390x"
765
             ],
765
             ],
771
             ]
771
             ]
772
         },
772
         },
773
         "node_modules/@rollup/rollup-linux-x64-gnu": {
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
             "cpu": [
777
             "cpu": [
778
                 "x64"
778
                 "x64"
779
             ],
779
             ],
785
             ]
785
             ]
786
         },
786
         },
787
         "node_modules/@rollup/rollup-linux-x64-musl": {
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
             "cpu": [
791
             "cpu": [
792
                 "x64"
792
                 "x64"
793
             ],
793
             ],
799
             ]
799
             ]
800
         },
800
         },
801
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
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
             "cpu": [
805
             "cpu": [
806
                 "arm64"
806
                 "arm64"
807
             ],
807
             ],
813
             ]
813
             ]
814
         },
814
         },
815
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
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
             "cpu": [
819
             "cpu": [
820
                 "ia32"
820
                 "ia32"
821
             ],
821
             ],
827
             ]
827
             ]
828
         },
828
         },
829
         "node_modules/@rollup/rollup-win32-x64-msvc": {
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
             "cpu": [
833
             "cpu": [
834
                 "x64"
834
                 "x64"
835
             ],
835
             ],
1074
             }
1074
             }
1075
         },
1075
         },
1076
         "node_modules/caniuse-lite": {
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
             "dev": true,
1080
             "dev": true,
1081
             "funding": [
1081
             "funding": [
1082
                 {
1082
                 {
1235
             "license": "MIT"
1235
             "license": "MIT"
1236
         },
1236
         },
1237
         "node_modules/electron-to-chromium": {
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
             "dev": true,
1241
             "dev": true,
1242
             "license": "ISC"
1242
             "license": "ISC"
1243
         },
1243
         },
1597
             }
1597
             }
1598
         },
1598
         },
1599
         "node_modules/laravel-vite-plugin": {
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
             "dev": true,
1603
             "dev": true,
1604
             "license": "MIT",
1604
             "license": "MIT",
1605
             "dependencies": {
1605
             "dependencies": {
2242
             }
2242
             }
2243
         },
2243
         },
2244
         "node_modules/rollup": {
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
             "dev": true,
2248
             "dev": true,
2249
             "license": "MIT",
2249
             "license": "MIT",
2250
             "dependencies": {
2250
             "dependencies": {
2258
                 "npm": ">=8.0.0"
2258
                 "npm": ">=8.0.0"
2259
             },
2259
             },
2260
             "optionalDependencies": {
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
                 "fsevents": "~2.3.2"
2280
                 "fsevents": "~2.3.2"
2281
             }
2281
             }
2282
         },
2282
         },
2624
             "license": "MIT"
2624
             "license": "MIT"
2625
         },
2625
         },
2626
         "node_modules/vite": {
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
             "dev": true,
2630
             "dev": true,
2631
             "license": "MIT",
2631
             "license": "MIT",
2632
             "dependencies": {
2632
             "dependencies": {

+ 9
- 0
resources/css/filament/company/theme.css View File

18
     display: inline-flex;
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
 :not(.dark) .fi-body {
30
 :not(.dark) .fi-body {
22
     position: relative;
31
     position: relative;
23
     background-color: #E8E9EB;
32
     background-color: #E8E9EB;

+ 39
- 1
resources/data/lang/es.json View File

185
     "Currency List": "Lista de divisas",
185
     "Currency List": "Lista de divisas",
186
     "Available": "Disponible",
186
     "Available": "Disponible",
187
     "Parent Department": "Departamento de padres",
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 View File

20
                         <strong
20
                         <strong
21
                             @class([
21
                             @class([
22
                                 'text-lg',
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
                             {{ $summary['value'] }}
27
                             {{ $summary['value'] }}

+ 49
- 59
resources/views/filament/company/components/invoice-layouts/classic.blade.php View File

1
 @php
1
 @php
2
     $data = $this->form->getRawState();
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
 @endphp
4
 @endphp
7
 
5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9
 
7
 
10
 <style>
8
 <style>
11
     .inv-paper {
9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13
     }
11
     }
14
 </style>
12
 </style>
15
 
13
 
18
     <x-company.invoice.header class="default-template-header">
16
     <x-company.invoice.header class="default-template-header">
19
         <div class="w-2/3 text-left ml-6">
17
         <div class="w-2/3 text-left ml-6">
20
             <div class="text-xs">
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
                 @endif
22
                 @endif
27
             </div>
23
             </div>
28
         </div>
24
         </div>
29
 
25
 
30
         <div class="w-1/3 flex justify-end mr-6">
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
             @endif
29
             @endif
34
         </div>
30
         </div>
35
     </x-company.invoice.header>
31
     </x-company.invoice.header>
36
 
32
 
37
     <x-company.invoice.metadata class="classic-template-metadata">
33
     <x-company.invoice.metadata class="classic-template-metadata">
38
         <div class="items-center flex">
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
             <div class="items-center flex mx-5">
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
                         <div class="inline text-2xl font-semibold"
40
                         <div class="inline text-2xl font-semibold"
45
-                             style="color: {{ $accent_color }};">{{ $header }}</div>
41
+                             style="color: {{ $document->accentColor }};">{{ $document->header }}</div>
46
                     </div>
42
                     </div>
47
                 </div>
43
                 </div>
48
-                <x-icons.decor-border-right color="{{ $accent_color }}"/>
44
+                <x-icons.decor-border-right color="{{ $document->accentColor }}"/>
49
             </div>
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
         </div>
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
         <div class="flex justify-between items-end">
50
         <div class="flex justify-between items-end">
55
             <!-- Billing Details -->
51
             <!-- Billing Details -->
56
             <div class="text-xs">
52
             <div class="text-xs">
57
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
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
             </div>
58
             </div>
63
 
59
 
64
             <div class="text-xs">
60
             <div class="text-xs">
65
                 <table class="min-w-full">
61
                 <table class="min-w-full">
66
                     <tbody>
62
                     <tbody>
67
                     <tr>
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
                     </tr>
70
                     </tr>
71
                     <tr>
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
                     </tr>
74
                     </tr>
75
                     <tr>
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
                     </tr>
78
                     </tr>
79
                     </tbody>
79
                     </tbody>
80
                 </table>
80
                 </table>
87
         <table class="w-full text-left table-fixed">
87
         <table class="w-full text-left table-fixed">
88
             <thead class="text-sm leading-8">
88
             <thead class="text-sm leading-8">
89
             <tr>
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
             </tr>
94
             </tr>
95
             </thead>
95
             </thead>
96
             <tbody class="text-xs border-t-2 border-b-2 border-dotted border-gray-300 leading-8">
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
             </tbody>
105
             </tbody>
116
         </table>
106
         </table>
117
 
107
 
120
             <!-- Notes Section -->
110
             <!-- Notes Section -->
121
             <div class="w-1/2 border border-dashed border-gray-300 p-2 mt-4">
111
             <div class="w-1/2 border border-dashed border-gray-300 p-2 mt-4">
122
                 <h4 class="font-semibold mb-2">Notes</h4>
112
                 <h4 class="font-semibold mb-2">Notes</h4>
123
-                <p>{{ $footer }}</p>
113
+                <p>{{ $document->footer }}</p>
124
             </div>
114
             </div>
125
 
115
 
126
             <!-- Financial Summary -->
116
             <!-- Financial Summary -->
129
                     <tbody class="text-xs leading-loose">
119
                     <tbody class="text-xs leading-loose">
130
                     <tr>
120
                     <tr>
131
                         <td class="text-right font-semibold">Subtotal:</td>
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
                     </tr>
123
                     </tr>
134
                     <tr class="text-success-800 dark:text-success-600">
124
                     <tr class="text-success-800 dark:text-success-600">
135
                         <td class="text-right">Discount (5%):</td>
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
                     </tr>
127
                     </tr>
138
                     <tr>
128
                     <tr>
139
                         <td class="text-right">Sales Tax (10%):</td>
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
                     </tr>
131
                     </tr>
142
                     <tr>
132
                     <tr>
143
                         <td class="text-right font-semibold">Total:</td>
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
                     </tr>
135
                     </tr>
146
                     <tr>
136
                     <tr>
147
                         <td class="text-right font-semibold">Amount Due (USD):</td>
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
                     </tr>
139
                     </tr>
150
                     </tbody>
140
                     </tbody>
151
                 </table>
141
                 </table>
156
     <!-- Footer -->
146
     <!-- Footer -->
157
     <x-company.invoice.footer class="classic-template-footer">
147
     <x-company.invoice.footer class="classic-template-footer">
158
         <h4 class="font-semibold px-6 mb-2">Terms & Conditions</h4>
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
     </x-company.invoice.footer>
150
     </x-company.invoice.footer>
161
 </x-company.invoice.container>
151
 </x-company.invoice.container>

+ 47
- 57
resources/views/filament/company/components/invoice-layouts/default.blade.php View File

1
 @php
1
 @php
2
     $data = $this->form->getRawState();
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
 @endphp
4
 @endphp
7
 
5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9
 
7
 
10
 <style>
8
 <style>
11
     .inv-paper {
9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13
     }
11
     }
14
 </style>
12
 </style>
15
 
13
 
17
 
15
 
18
     <x-company.invoice.header class="default-template-header border-b-2 p-6 pb-4">
16
     <x-company.invoice.header class="default-template-header border-b-2 p-6 pb-4">
19
         <div class="w-2/3">
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
             @endif
20
             @endif
23
         </div>
21
         </div>
24
 
22
 
25
         <div class="w-1/3 text-right">
23
         <div class="w-1/3 text-right">
26
             <div class="text-xs">
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
                 @endif
28
                 @endif
33
             </div>
29
             </div>
34
         </div>
30
         </div>
36
 
32
 
37
     <x-company.invoice.metadata class="default-template-metadata space-y-6">
33
     <x-company.invoice.metadata class="default-template-metadata space-y-6">
38
         <div>
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
             @endif
38
             @endif
43
         </div>
39
         </div>
44
 
40
 
46
             <!-- Billing Details -->
42
             <!-- Billing Details -->
47
             <div class="text-xs">
43
             <div class="text-xs">
48
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
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
             </div>
49
             </div>
54
 
50
 
55
             <div class="text-xs">
51
             <div class="text-xs">
56
                 <table class="min-w-full">
52
                 <table class="min-w-full">
57
                     <tbody>
53
                     <tbody>
58
                     <tr>
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
                     </tr>
61
                     </tr>
62
                     <tr>
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
                     </tr>
65
                     </tr>
66
                     <tr>
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
                     </tr>
69
                     </tr>
70
                     </tbody>
70
                     </tbody>
71
                 </table>
71
                 </table>
76
     <!-- Line Items Table -->
76
     <!-- Line Items Table -->
77
     <x-company.invoice.line-items class="default-template-line-items">
77
     <x-company.invoice.line-items class="default-template-line-items">
78
         <table class="w-full text-left table-fixed">
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
             <tr class="text-white">
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
             </tr>
85
             </tr>
86
             </thead>
86
             </thead>
87
             <tbody class="text-xs border-b-2 border-gray-300 leading-8">
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
             </tbody>
96
             </tbody>
107
             <tfoot class="text-xs leading-loose">
97
             <tfoot class="text-xs leading-loose">
108
             <tr>
98
             <tr>
109
                 <td class="pl-6" colspan="2"></td>
99
                 <td class="pl-6" colspan="2"></td>
110
                 <td class="text-right font-semibold">Subtotal:</td>
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
             </tr>
102
             </tr>
113
             <tr class="text-success-800 dark:text-success-600">
103
             <tr class="text-success-800 dark:text-success-600">
114
                 <td class="pl-6" colspan="2"></td>
104
                 <td class="pl-6" colspan="2"></td>
115
                 <td class="text-right">Discount (5%):</td>
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
             </tr>
107
             </tr>
118
             <tr>
108
             <tr>
119
                 <td class="pl-6" colspan="2"></td>
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
             </tr>
112
             </tr>
123
             <tr>
113
             <tr>
124
                 <td class="pl-6" colspan="2"></td>
114
                 <td class="pl-6" colspan="2"></td>
125
                 <td class="text-right font-semibold border-t">Total:</td>
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
             </tr>
117
             </tr>
128
             <tr>
118
             <tr>
129
                 <td class="pl-6" colspan="2"></td>
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
             </tr>
122
             </tr>
133
             </tfoot>
123
             </tfoot>
134
         </table>
124
         </table>
136
 
126
 
137
     <!-- Footer Notes -->
127
     <!-- Footer Notes -->
138
     <x-company.invoice.footer class="default-template-footer">
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
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
130
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
141
         <h4 class="font-semibold px-6 mb-2">Terms & Conditions</h4>
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
     </x-company.invoice.footer>
133
     </x-company.invoice.footer>
144
 </x-company.invoice.container>
134
 </x-company.invoice.container>

+ 85
- 76
resources/views/filament/company/components/invoice-layouts/modern.blade.php View File

1
 @php
1
 @php
2
     $data = $this->form->getRawState();
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
 @endphp
4
 @endphp
7
 
5
 
8
-{!! $font_html !!}
6
+{!! $document->getFontHtml() !!}
9
 
7
 
10
 <style>
8
 <style>
11
     .inv-paper {
9
     .inv-paper {
12
-        font-family: '{{ $font_family }}', sans-serif;
10
+        font-family: '{{ $document->font->getLabel() }}', sans-serif;
13
     }
11
     }
14
 </style>
12
 </style>
15
 
13
 
19
     <x-company.invoice.header class="bg-gray-800 h-20">
17
     <x-company.invoice.header class="bg-gray-800 h-20">
20
         <!-- Logo -->
18
         <!-- Logo -->
21
         <div class="w-2/3">
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
             @endif
22
             @endif
25
         </div>
23
         </div>
26
 
24
 
27
         <!-- Ribbon Container -->
25
         <!-- Ribbon Container -->
28
         <div class="w-1/3 absolute right-0 top-0 p-2 h-28 flex flex-col justify-end rounded-bl-sm"
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
             @endif
30
             @endif
33
         </div>
31
         </div>
34
     </x-company.invoice.header>
32
     </x-company.invoice.header>
36
     <!-- Company Details -->
34
     <!-- Company Details -->
37
     <x-company.invoice.metadata class="modern-template-metadata space-y-6">
35
     <x-company.invoice.metadata class="modern-template-metadata space-y-6">
38
         <div class="text-xs">
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
             @endif
40
             @endif
45
         </div>
41
         </div>
46
 
42
 
47
         <div class="flex justify-between items-end">
43
         <div class="flex justify-between items-end">
48
             <!-- Billing Details -->
44
             <!-- Billing Details -->
49
-            <div class="text-xs">
45
+            <div class="text-xs tracking-tight">
50
                 <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
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
             </div>
53
             </div>
56
 
54
 
57
-            <div class="text-xs">
55
+            <div class="text-xs tracking-tight">
58
                 <table class="min-w-full">
56
                 <table class="min-w-full">
59
                     <tbody>
57
                     <tbody>
60
                     <tr>
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
                     </tr>
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
                     <tr>
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
                     </tr>
71
                     </tr>
68
                     <tr>
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
                     </tr>
75
                     </tr>
72
                     </tbody>
76
                     </tbody>
73
                 </table>
77
                 </table>
80
         <table class="w-full text-left table-fixed">
84
         <table class="w-full text-left table-fixed">
81
             <thead class="text-sm leading-8">
85
             <thead class="text-sm leading-8">
82
             <tr class="text-gray-600 dark:text-gray-400">
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
             </tr>
91
             </tr>
88
             </thead>
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
             </tbody>
107
             </tbody>
109
-            <tfoot class="text-xs leading-loose">
108
+            <tfoot class="text-xs tracking-tight">
110
             <tr>
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
             </tr>
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
             <tr>
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
             </tr>
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
             </tfoot>
144
             </tfoot>
136
         </table>
145
         </table>
137
     </x-company.invoice.line-items>
146
     </x-company.invoice.line-items>
138
 
147
 
139
     <!-- Footer Notes -->
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
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
151
         <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
143
         <div class="flex justify-between space-x-4 px-6">
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
         </div>
155
         </div>
147
     </x-company.invoice.footer>
156
     </x-company.invoice.footer>
148
 </x-company.invoice.container>
157
 </x-company.invoice.container>

+ 0
- 35
resources/views/filament/forms/components/labeled-field.blade.php View File

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 View File

1
-@php
2
-    use Filament\Forms\Components\Actions\Action;
3
-    use Filament\Support\Enums\Alignment;
4
-    use Filament\Support\Enums\MaxWidth;
5
-
6
-    $containers = $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 View File

2
     $document = \App\DTO\DocumentDTO::fromModel($getRecord());
2
     $document = \App\DTO\DocumentDTO::fromModel($getRecord());
3
 @endphp
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
 <div {{ $attributes }}>
13
 <div {{ $attributes }}>
6
     <x-company.invoice.container class="modern-template-container">
14
     <x-company.invoice.container class="modern-template-container">
7
         <!-- Colored Header with Logo -->
15
         <!-- Colored Header with Logo -->
26
         <x-company.invoice.metadata class="modern-template-metadata space-y-8">
34
         <x-company.invoice.metadata class="modern-template-metadata space-y-8">
27
             <div class="text-sm">
35
             <div class="text-sm">
28
                 <h2 class="text-lg font-semibold">{{ $document->company->name }}</h2>
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
                 @endif
39
                 @endif
34
             </div>
40
             </div>
35
 
41
 
40
                     <p class="text-base font-bold"
46
                     <p class="text-base font-bold"
41
                        style="color: {{ $document->accentColor }}">{{ $document->client->name }}</p>
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
                     @endif
51
                     @endif
57
                 </div>
52
                 </div>
58
 
53
 
88
             <table class="w-full text-left table-fixed">
83
             <table class="w-full text-left table-fixed">
89
                 <thead class="text-sm leading-relaxed">
84
                 <thead class="text-sm leading-relaxed">
90
                 <tr class="text-gray-600 dark:text-gray-400">
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
                 </tr>
90
                 </tr>
96
                 </thead>
91
                 </thead>
97
                 <tbody class="text-sm tracking-tight border-y-2">
92
                 <tbody class="text-sm tracking-tight border-y-2">

+ 187
- 13
tests/Feature/Accounting/RecurringInvoiceTest.php View File

1
 <?php
1
 <?php
2
 
2
 
3
+use App\Enums\Accounting\EndType;
4
+use App\Enums\Accounting\Frequency;
3
 use App\Enums\Accounting\IntervalType;
5
 use App\Enums\Accounting\IntervalType;
4
 use App\Models\Accounting\RecurringInvoice;
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
     $recurringInvoice = RecurringInvoice::factory()
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
     $recurringInvoice->refresh();
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
     $recurringInvoice->refresh();
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 View File

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

+ 14
- 0
tests/TestCase.php View File

2
 
2
 
3
 namespace Tests;
3
 namespace Tests;
4
 
4
 
5
+use App\Models\Common\Offering;
5
 use App\Models\Company;
6
 use App\Models\Company;
6
 use App\Models\User;
7
 use App\Models\User;
7
 use App\Testing\TestsReport;
8
 use App\Testing\TestsReport;
45
 
46
 
46
         Filament::setTenant($this->testCompany);
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
 }

Loading…
Cancel
Save