Bläddra i källkod

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

Development 3.x
3.x
Andrew Wallo 9 månader sedan
förälder
incheckning
d2eec180d1
Inget konto är kopplat till bidragsgivarens mejladress
61 ändrade filer med 3944 tillägg och 774 borttagningar
  1. 39
    0
      app/Console/Commands/TriggerRecurringInvoiceGeneration.php
  2. 33
    0
      app/DTO/ClientDTO.php
  3. 31
    0
      app/DTO/CompanyDTO.php
  4. 71
    0
      app/DTO/DocumentDTO.php
  5. 27
    0
      app/DTO/DocumentLabelDTO.php
  6. 33
    0
      app/DTO/LineItemDTO.php
  7. 103
    0
      app/Enums/Accounting/DayOfMonth.php
  8. 24
    0
      app/Enums/Accounting/DayOfWeek.php
  9. 42
    26
      app/Enums/Accounting/DocumentType.php
  10. 35
    0
      app/Enums/Accounting/EndType.php
  11. 97
    0
      app/Enums/Accounting/Frequency.php
  12. 19
    0
      app/Enums/Accounting/IntervalModifier.php
  13. 66
    0
      app/Enums/Accounting/IntervalType.php
  14. 29
    0
      app/Enums/Accounting/Month.php
  15. 29
    0
      app/Enums/Accounting/RecurringInvoiceStatus.php
  16. 0
    2
      app/Filament/Company/Resources/Purchases/BillResource.php
  17. 0
    2
      app/Filament/Company/Resources/Purchases/VendorResource.php
  18. 0
    2
      app/Filament/Company/Resources/Sales/EstimateResource.php
  19. 28
    2
      app/Filament/Company/Resources/Sales/InvoiceResource.php
  20. 62
    0
      app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php
  21. 349
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php
  22. 36
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php
  23. 46
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php
  24. 51
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php
  25. 135
    0
      app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php
  26. 45
    0
      app/Filament/Forms/Components/LabeledField.php
  27. 33
    0
      app/Jobs/GenerateRecurringInvoices.php
  28. 32
    23
      app/Models/Accounting/Bill.php
  29. 46
    0
      app/Models/Accounting/Document.php
  30. 28
    21
      app/Models/Accounting/Estimate.php
  31. 46
    21
      app/Models/Accounting/Invoice.php
  32. 672
    0
      app/Models/Accounting/RecurringInvoice.php
  33. 5
    0
      app/Models/Company.php
  34. 57
    0
      app/Observers/RecurringInvoiceObserver.php
  35. 2
    0
      app/Providers/FilamentCompaniesServiceProvider.php
  36. 273
    0
      app/Support/ScheduleHandler.php
  37. 0
    118
      app/View/Models/DocumentPreviewViewModel.php
  38. 2
    0
      composer.json
  39. 304
    160
      composer.lock
  40. 2
    1
      database/factories/Accounting/DocumentLineItemFactory.php
  41. 288
    0
      database/factories/Accounting/RecurringInvoiceFactory.php
  42. 81
    0
      database/factories/CompanyFactory.php
  43. 65
    0
      database/migrations/2024_11_27_223001_create_recurring_invoices_table.php
  44. 1
    0
      database/migrations/2024_11_27_223015_create_invoices_table.php
  45. 1
    0
      database/seeders/DatabaseSeeder.php
  46. 110
    110
      package-lock.json
  47. 27
    0
      resources/css/filament/company/custom-data-table.css
  48. 125
    0
      resources/css/filament/company/custom-section.css
  49. 92
    0
      resources/css/filament/company/form-fields.css
  50. 18
    0
      resources/css/filament/company/modal.css
  51. 15
    0
      resources/css/filament/company/report-card.css
  52. 1
    0
      resources/css/filament/company/tailwind.config.js
  53. 11
    176
      resources/css/filament/company/theme.css
  54. 20
    0
      resources/css/filament/company/top-navigation.css
  55. 8
    1
      resources/data/lang/en.json
  56. 16
    46
      resources/views/components/custom-section.blade.php
  57. 18
    0
      resources/views/filament/company/resources/sales/invoice-resource/pages/list-invoices.blade.php
  58. 35
    0
      resources/views/filament/forms/components/labeled-field.blade.php
  59. 49
    63
      resources/views/filament/infolists/components/document-preview.blade.php
  60. 2
    0
      routes/console.php
  61. 29
    0
      tests/Feature/Accounting/RecurringInvoiceTest.php

+ 39
- 0
app/Console/Commands/TriggerRecurringInvoiceGeneration.php Visa fil

@@ -0,0 +1,39 @@
1
+<?php
2
+
3
+namespace App\Console\Commands;
4
+
5
+use App\Jobs\GenerateRecurringInvoices;
6
+use Illuminate\Console\Command;
7
+
8
+class TriggerRecurringInvoiceGeneration extends Command
9
+{
10
+    /**
11
+     * The name and signature of the console command.
12
+     *
13
+     * @var string
14
+     */
15
+    protected $signature = 'invoices:generate-recurring {--queue : Whether the job should be queued}';
16
+
17
+    /**
18
+     * The console command description.
19
+     *
20
+     * @var string
21
+     */
22
+    protected $description = 'Generate invoices for active recurring schedules';
23
+
24
+    /**
25
+     * Execute the console command.
26
+     */
27
+    public function handle(): void
28
+    {
29
+        if ($this->option('queue')) {
30
+            GenerateRecurringInvoices::dispatch();
31
+
32
+            $this->info('Recurring invoice generation has been queued.');
33
+        } else {
34
+            GenerateRecurringInvoices::dispatchSync();
35
+
36
+            $this->info('Recurring invoices have been generated.');
37
+        }
38
+    }
39
+}

+ 33
- 0
app/DTO/ClientDTO.php Visa fil

@@ -0,0 +1,33 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Models\Common\Client;
6
+
7
+readonly class ClientDTO
8
+{
9
+    public function __construct(
10
+        public string $name,
11
+        public string $addressLine1,
12
+        public string $addressLine2,
13
+        public string $city,
14
+        public string $state,
15
+        public string $postalCode,
16
+        public string $country,
17
+    ) {}
18
+
19
+    public static function fromModel(Client $client): self
20
+    {
21
+        $address = $client->billingAddress ?? null;
22
+
23
+        return new self(
24
+            name: $client->name,
25
+            addressLine1: $address?->address_line_1 ?? '',
26
+            addressLine2: $address?->address_line_2 ?? '',
27
+            city: $address?->city ?? '',
28
+            state: $address?->state ?? '',
29
+            postalCode: $address?->postal_code ?? '',
30
+            country: $address?->country ?? '',
31
+        );
32
+    }
33
+}

+ 31
- 0
app/DTO/CompanyDTO.php Visa fil

@@ -0,0 +1,31 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Models\Company;
6
+
7
+readonly class CompanyDTO
8
+{
9
+    public function __construct(
10
+        public string $name,
11
+        public string $address,
12
+        public string $city,
13
+        public string $state,
14
+        public string $zipCode,
15
+        public string $country,
16
+    ) {}
17
+
18
+    public static function fromModel(Company $company): self
19
+    {
20
+        $profile = $company->profile;
21
+
22
+        return new self(
23
+            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 ?? '',
29
+        );
30
+    }
31
+}

+ 71
- 0
app/DTO/DocumentDTO.php Visa fil

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

+ 27
- 0
app/DTO/DocumentLabelDTO.php Visa fil

@@ -0,0 +1,27 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+readonly class DocumentLabelDTO
6
+{
7
+    public function __construct(
8
+        public string $title,
9
+        public string $number,
10
+        public string $referenceNumber,
11
+        public string $date,
12
+        public string $dueDate,
13
+        public string $amountDue,
14
+    ) {}
15
+
16
+    public function toArray(): array
17
+    {
18
+        return [
19
+            'title' => $this->title,
20
+            'number' => $this->number,
21
+            'reference_number' => $this->referenceNumber,
22
+            'date' => $this->date,
23
+            'due_date' => $this->dueDate,
24
+            'amount_due' => $this->amountDue,
25
+        ];
26
+    }
27
+}

+ 33
- 0
app/DTO/LineItemDTO.php Visa fil

@@ -0,0 +1,33 @@
1
+<?php
2
+
3
+namespace App\DTO;
4
+
5
+use App\Models\Accounting\DocumentLineItem;
6
+use App\Utilities\Currency\CurrencyConverter;
7
+
8
+readonly class LineItemDTO
9
+{
10
+    public function __construct(
11
+        public string $name,
12
+        public string $description,
13
+        public int $quantity,
14
+        public string $unitPrice,
15
+        public string $subtotal,
16
+    ) {}
17
+
18
+    public static function fromModel(DocumentLineItem $lineItem): self
19
+    {
20
+        return new self(
21
+            name: $lineItem->offering->name ?? '',
22
+            description: $lineItem->description ?? '',
23
+            quantity: $lineItem->quantity,
24
+            unitPrice: self::formatToMoney($lineItem->unit_price, $lineItem->documentable->currency_code),
25
+            subtotal: self::formatToMoney($lineItem->subtotal, $lineItem->documentable->currency_code),
26
+        );
27
+    }
28
+
29
+    private static function formatToMoney(float | string $value, ?string $currencyCode): string
30
+    {
31
+        return CurrencyConverter::formatToMoney($value, $currencyCode);
32
+    }
33
+}

+ 103
- 0
app/Enums/Accounting/DayOfMonth.php Visa fil

@@ -0,0 +1,103 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Carbon\CarbonImmutable;
7
+use Filament\Support\Contracts\HasLabel;
8
+use Illuminate\Support\Carbon;
9
+
10
+enum DayOfMonth: int implements HasLabel
11
+{
12
+    use ParsesEnum;
13
+
14
+    case First = 1;
15
+    case Last = -1;
16
+    case Second = 2;
17
+    case Third = 3;
18
+    case Fourth = 4;
19
+    case Fifth = 5;
20
+    case Sixth = 6;
21
+    case Seventh = 7;
22
+    case Eighth = 8;
23
+    case Ninth = 9;
24
+    case Tenth = 10;
25
+    case Eleventh = 11;
26
+    case Twelfth = 12;
27
+    case Thirteenth = 13;
28
+    case Fourteenth = 14;
29
+    case Fifteenth = 15;
30
+    case Sixteenth = 16;
31
+    case Seventeenth = 17;
32
+    case Eighteenth = 18;
33
+    case Nineteenth = 19;
34
+    case Twentieth = 20;
35
+    case TwentyFirst = 21;
36
+    case TwentySecond = 22;
37
+    case TwentyThird = 23;
38
+    case TwentyFourth = 24;
39
+    case TwentyFifth = 25;
40
+    case TwentySixth = 26;
41
+    case TwentySeventh = 27;
42
+    case TwentyEighth = 28;
43
+    case TwentyNinth = 29;
44
+    case Thirtieth = 30;
45
+    case ThirtyFirst = 31;
46
+
47
+    public function getLabel(): ?string
48
+    {
49
+        return match ($this) {
50
+            self::First => 'First',
51
+            self::Last => 'Last',
52
+            self::Second => '2nd',
53
+            self::Third => '3rd',
54
+            self::Fourth => '4th',
55
+            self::Fifth => '5th',
56
+            self::Sixth => '6th',
57
+            self::Seventh => '7th',
58
+            self::Eighth => '8th',
59
+            self::Ninth => '9th',
60
+            self::Tenth => '10th',
61
+            self::Eleventh => '11th',
62
+            self::Twelfth => '12th',
63
+            self::Thirteenth => '13th',
64
+            self::Fourteenth => '14th',
65
+            self::Fifteenth => '15th',
66
+            self::Sixteenth => '16th',
67
+            self::Seventeenth => '17th',
68
+            self::Eighteenth => '18th',
69
+            self::Nineteenth => '19th',
70
+            self::Twentieth => '20th',
71
+            self::TwentyFirst => '21st',
72
+            self::TwentySecond => '22nd',
73
+            self::TwentyThird => '23rd',
74
+            self::TwentyFourth => '24th',
75
+            self::TwentyFifth => '25th',
76
+            self::TwentySixth => '26th',
77
+            self::TwentySeventh => '27th',
78
+            self::TwentyEighth => '28th',
79
+            self::TwentyNinth => '29th',
80
+            self::Thirtieth => '30th',
81
+            self::ThirtyFirst => '31st',
82
+        };
83
+    }
84
+
85
+    public function isFirst(): bool
86
+    {
87
+        return $this === self::First;
88
+    }
89
+
90
+    public function isLast(): bool
91
+    {
92
+        return $this === self::Last;
93
+    }
94
+
95
+    public function resolveDate(Carbon | CarbonImmutable $date): Carbon | CarbonImmutable
96
+    {
97
+        if ($this->isLast()) {
98
+            return $date->endOfMonth();
99
+        }
100
+
101
+        return $date->day(min($this->value, $date->daysInMonth));
102
+    }
103
+}

+ 24
- 0
app/Enums/Accounting/DayOfWeek.php Visa fil

@@ -0,0 +1,24 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum DayOfWeek: int implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Sunday = 0;
13
+    case Monday = 1;
14
+    case Tuesday = 2;
15
+    case Wednesday = 3;
16
+    case Thursday = 4;
17
+    case Friday = 5;
18
+    case Saturday = 6;
19
+
20
+    public function getLabel(): ?string
21
+    {
22
+        return $this->name;
23
+    }
24
+}

+ 42
- 26
app/Enums/Accounting/DocumentType.php Visa fil

@@ -2,6 +2,7 @@
2 2
 
3 3
 namespace App\Enums\Accounting;
4 4
 
5
+use App\DTO\DocumentLabelDTO;
5 6
 use Filament\Support\Contracts\HasIcon;
6 7
 use Filament\Support\Contracts\HasLabel;
7 8
 
@@ -10,18 +11,22 @@ enum DocumentType: string implements HasIcon, HasLabel
10 11
     case Invoice = 'invoice';
11 12
     case Bill = 'bill';
12 13
     case Estimate = 'estimate';
14
+    case RecurringInvoice = 'recurring_invoice';
13 15
 
14 16
     public const DEFAULT = self::Invoice->value;
15 17
 
16 18
     public function getLabel(): ?string
17 19
     {
18
-        return $this->name;
20
+        return match ($this) {
21
+            self::Invoice, self::Bill, self::Estimate => $this->name,
22
+            self::RecurringInvoice => 'Recurring Invoice',
23
+        };
19 24
     }
20 25
 
21 26
     public function getIcon(): ?string
22 27
     {
23 28
         return match ($this->value) {
24
-            self::Invoice->value => 'heroicon-o-document-duplicate',
29
+            self::Invoice->value, self::RecurringInvoice->value => 'heroicon-o-document-duplicate',
25 30
             self::Bill->value => 'heroicon-o-clipboard-document-list',
26 31
             self::Estimate->value => 'heroicon-o-document-text',
27 32
         };
@@ -30,7 +35,7 @@ enum DocumentType: string implements HasIcon, HasLabel
30 35
     public function getTaxKey(): string
31 36
     {
32 37
         return match ($this) {
33
-            self::Invoice, self::Estimate => 'salesTaxes',
38
+            self::Invoice, self::RecurringInvoice, self::Estimate => 'salesTaxes',
34 39
             self::Bill => 'purchaseTaxes',
35 40
         };
36 41
     }
@@ -38,35 +43,46 @@ enum DocumentType: string implements HasIcon, HasLabel
38 43
     public function getDiscountKey(): string
39 44
     {
40 45
         return match ($this) {
41
-            self::Invoice, self::Estimate => 'salesDiscounts',
46
+            self::Invoice, self::RecurringInvoice, self::Estimate => 'salesDiscounts',
42 47
             self::Bill => 'purchaseDiscounts',
43 48
         };
44 49
     }
45 50
 
46
-    public function getLabels(): array
51
+    public function getLabels(): DocumentLabelDTO
47 52
     {
48 53
         return match ($this) {
49
-            self::Invoice => [
50
-                'title' => 'Invoice',
51
-                'number' => 'Invoice Number',
52
-                'reference_number' => 'P.O/S.O Number',
53
-                'date' => 'Invoice Date',
54
-                'due_date' => 'Payment Due',
55
-            ],
56
-            self::Estimate => [
57
-                'title' => 'Estimate',
58
-                'number' => 'Estimate Number',
59
-                'reference_number' => 'Reference Number',
60
-                'date' => 'Estimate Date',
61
-                'due_date' => 'Expiration Date',
62
-            ],
63
-            self::Bill => [
64
-                'title' => 'Bill',
65
-                'number' => 'Bill Number',
66
-                'reference_number' => 'P.O/S.O Number',
67
-                'date' => 'Bill Date',
68
-                'due_date' => 'Payment Due',
69
-            ],
54
+            self::Invoice => new DocumentLabelDTO(
55
+                title: 'Invoice',
56
+                number: 'Invoice Number',
57
+                referenceNumber: 'P.O/S.O Number',
58
+                date: 'Invoice Date',
59
+                dueDate: 'Payment Due',
60
+                amountDue: 'Amount Due',
61
+            ),
62
+            self::RecurringInvoice => new DocumentLabelDTO(
63
+                title: 'Recurring Invoice',
64
+                number: 'Invoice Number',
65
+                referenceNumber: 'P.O/S.O Number',
66
+                date: 'Invoice Date',
67
+                dueDate: 'Payment Due',
68
+                amountDue: 'Amount Due',
69
+            ),
70
+            self::Estimate => new DocumentLabelDTO(
71
+                title: 'Estimate',
72
+                number: 'Estimate Number',
73
+                referenceNumber: 'Reference Number',
74
+                date: 'Estimate Date',
75
+                dueDate: 'Expiration Date',
76
+                amountDue: 'Grand Total',
77
+            ),
78
+            self::Bill => new DocumentLabelDTO(
79
+                title: 'Bill',
80
+                number: 'Bill Number',
81
+                referenceNumber: 'P.O/S.O Number',
82
+                date: 'Bill Date',
83
+                dueDate: 'Payment Due',
84
+                amountDue: 'Amount Due',
85
+            ),
70 86
         };
71 87
     }
72 88
 }

+ 35
- 0
app/Enums/Accounting/EndType.php Visa fil

@@ -0,0 +1,35 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum EndType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Never = 'never';
13
+    case After = 'after';
14
+    case On = 'on';
15
+
16
+    public function getLabel(): ?string
17
+    {
18
+        return $this->name;
19
+    }
20
+
21
+    public function isNever(): bool
22
+    {
23
+        return $this === self::Never;
24
+    }
25
+
26
+    public function isAfter(): bool
27
+    {
28
+        return $this === self::After;
29
+    }
30
+
31
+    public function isOn(): bool
32
+    {
33
+        return $this === self::On;
34
+    }
35
+}

+ 97
- 0
app/Enums/Accounting/Frequency.php Visa fil

@@ -0,0 +1,97 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum Frequency: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Daily = 'daily';
13
+    case Weekly = 'weekly';
14
+    case Monthly = 'monthly';
15
+    case Yearly = 'yearly';
16
+    case Custom = 'custom';
17
+
18
+    public function getLabel(): ?string
19
+    {
20
+        return $this->name;
21
+    }
22
+
23
+    public function getOptions(): array
24
+    {
25
+        return match ($this) {
26
+            self::Weekly => [
27
+                1 => 'Monday',
28
+                2 => 'Tuesday',
29
+                3 => 'Wednesday',
30
+                4 => 'Thursday',
31
+                5 => 'Friday',
32
+                6 => 'Saturday',
33
+                7 => 'Sunday',
34
+            ],
35
+            self::Monthly, self::Yearly => [
36
+                1 => 'First',
37
+                -1 => 'Last',
38
+                2 => '2nd',
39
+                3 => '3rd',
40
+                4 => '4th',
41
+                5 => '5th',
42
+                6 => '6th',
43
+                7 => '7th',
44
+                8 => '8th',
45
+                9 => '9th',
46
+                10 => '10th',
47
+                11 => '11th',
48
+                12 => '12th',
49
+                13 => '13th',
50
+                14 => '14th',
51
+                15 => '15th',
52
+                16 => '16th',
53
+                17 => '17th',
54
+                18 => '18th',
55
+                19 => '19th',
56
+                20 => '20th',
57
+                21 => '21st',
58
+                22 => '22nd',
59
+                23 => '23rd',
60
+                24 => '24th',
61
+                25 => '25th',
62
+                26 => '26th',
63
+                27 => '27th',
64
+                28 => '28th',
65
+                29 => '29th',
66
+                30 => '30th',
67
+                31 => '31st',
68
+            ],
69
+            default => [],
70
+        };
71
+    }
72
+
73
+    public function isDaily(): bool
74
+    {
75
+        return $this === self::Daily;
76
+    }
77
+
78
+    public function isWeekly(): bool
79
+    {
80
+        return $this === self::Weekly;
81
+    }
82
+
83
+    public function isMonthly(): bool
84
+    {
85
+        return $this === self::Monthly;
86
+    }
87
+
88
+    public function isYearly(): bool
89
+    {
90
+        return $this === self::Yearly;
91
+    }
92
+
93
+    public function isCustom(): bool
94
+    {
95
+        return $this === self::Custom;
96
+    }
97
+}

+ 19
- 0
app/Enums/Accounting/IntervalModifier.php Visa fil

@@ -0,0 +1,19 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasLabel;
6
+
7
+enum IntervalModifier: string implements HasLabel
8
+{
9
+    case First = 'first';
10
+    case Second = 'second';
11
+    case Third = 'third';
12
+    case Fourth = 'fourth';
13
+    case Last = 'last';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+}

+ 66
- 0
app/Enums/Accounting/IntervalType.php Visa fil

@@ -0,0 +1,66 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum IntervalType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Day = 'day';
13
+    case Week = 'week';
14
+    case Month = 'month';
15
+    case Year = 'year';
16
+
17
+    public function getLabel(): ?string
18
+    {
19
+        return match ($this) {
20
+            self::Day => 'Day(s)',
21
+            self::Week => 'Week(s)',
22
+            self::Month => 'Month(s)',
23
+            self::Year => 'Year(s)',
24
+        };
25
+    }
26
+
27
+    public function getSingularLabel(): ?string
28
+    {
29
+        return match ($this) {
30
+            self::Day => 'Day',
31
+            self::Week => 'Week',
32
+            self::Month => 'Month',
33
+            self::Year => 'Year',
34
+        };
35
+    }
36
+
37
+    public function getPluralLabel(): ?string
38
+    {
39
+        return match ($this) {
40
+            self::Day => 'Days',
41
+            self::Week => 'Weeks',
42
+            self::Month => 'Months',
43
+            self::Year => 'Years',
44
+        };
45
+    }
46
+
47
+    public function isDay(): bool
48
+    {
49
+        return $this === self::Day;
50
+    }
51
+
52
+    public function isWeek(): bool
53
+    {
54
+        return $this === self::Week;
55
+    }
56
+
57
+    public function isMonth(): bool
58
+    {
59
+        return $this === self::Month;
60
+    }
61
+
62
+    public function isYear(): bool
63
+    {
64
+        return $this === self::Year;
65
+    }
66
+}

+ 29
- 0
app/Enums/Accounting/Month.php Visa fil

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum Month: int implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case January = 1;
13
+    case February = 2;
14
+    case March = 3;
15
+    case April = 4;
16
+    case May = 5;
17
+    case June = 6;
18
+    case July = 7;
19
+    case August = 8;
20
+    case September = 9;
21
+    case October = 10;
22
+    case November = 11;
23
+    case December = 12;
24
+
25
+    public function getLabel(): ?string
26
+    {
27
+        return $this->name;
28
+    }
29
+}

+ 29
- 0
app/Enums/Accounting/RecurringInvoiceStatus.php Visa fil

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use Filament\Support\Contracts\HasColor;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum RecurringInvoiceStatus: string implements HasColor, HasLabel
9
+{
10
+    case Draft = 'draft';
11
+    case Active = 'active';
12
+    case Paused = 'paused';
13
+    case Ended = 'ended';
14
+
15
+    public function getLabel(): ?string
16
+    {
17
+        return $this->name;
18
+    }
19
+
20
+    public function getColor(): string | array | null
21
+    {
22
+        return match ($this) {
23
+            self::Draft => 'gray',
24
+            self::Active => 'primary',
25
+            self::Paused => 'warning',
26
+            self::Ended => 'success',
27
+        };
28
+    }
29
+}

+ 0
- 2
app/Filament/Company/Resources/Purchases/BillResource.php Visa fil

@@ -38,8 +38,6 @@ class BillResource extends Resource
38 38
 {
39 39
     protected static ?string $model = Bill::class;
40 40
 
41
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
42
-
43 41
     public static function form(Form $form): Form
44 42
     {
45 43
         $company = Auth::user()->currentCompany;

+ 0
- 2
app/Filament/Company/Resources/Purchases/VendorResource.php Visa fil

@@ -19,8 +19,6 @@ class VendorResource extends Resource
19 19
 {
20 20
     protected static ?string $model = Vendor::class;
21 21
 
22
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
23
-
24 22
     public static function form(Form $form): Form
25 23
     {
26 24
         return $form

+ 0
- 2
app/Filament/Company/Resources/Sales/EstimateResource.php Visa fil

@@ -36,8 +36,6 @@ class EstimateResource extends Resource
36 36
 {
37 37
     protected static ?string $model = Estimate::class;
38 38
 
39
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
40
-
41 39
     public static function form(Form $form): Form
42 40
     {
43 41
         $company = Auth::user()->currentCompany;

+ 28
- 2
app/Filament/Company/Resources/Sales/InvoiceResource.php Visa fil

@@ -43,8 +43,6 @@ class InvoiceResource extends Resource
43 43
 {
44 44
     protected static ?string $model = Invoice::class;
45 45
 
46
-    protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
47
-
48 46
     public static function form(Form $form): Form
49 47
     {
50 48
         $company = Auth::user()->currentCompany;
@@ -298,6 +296,15 @@ class InvoiceResource extends Resource
298 296
     {
299 297
         return $table
300 298
             ->defaultSort('due_date')
299
+            ->modifyQueryUsing(function (Builder $query, Tables\Contracts\HasTable $livewire) {
300
+                $recurringInvoiceId = $livewire->recurringInvoice;
301
+
302
+                if (! empty($recurringInvoiceId)) {
303
+                    $query->where('recurring_invoice_id', $recurringInvoiceId);
304
+                }
305
+
306
+                return $query;
307
+            })
301 308
             ->columns([
302 309
                 Tables\Columns\TextColumn::make('id')
303 310
                     ->label('ID')
@@ -317,6 +324,9 @@ class InvoiceResource extends Resource
317 324
                 Tables\Columns\TextColumn::make('invoice_number')
318 325
                     ->label('Number')
319 326
                     ->searchable()
327
+                    ->description(function (Invoice $record) {
328
+                        return $record->source_type?->getLabel();
329
+                    })
320 330
                     ->sortable(),
321 331
                 Tables\Columns\TextColumn::make('client.name')
322 332
                     ->sortable()
@@ -349,6 +359,22 @@ class InvoiceResource extends Resource
349 359
                         true: fn (Builder $query) => $query->whereHas('payments'),
350 360
                         false: fn (Builder $query) => $query->whereDoesntHave('payments'),
351 361
                     ),
362
+                Tables\Filters\SelectFilter::make('source_type')
363
+                    ->label('Source Type')
364
+                    ->options([
365
+                        DocumentType::Estimate->value => DocumentType::Estimate->getLabel(),
366
+                        DocumentType::RecurringInvoice->value => DocumentType::RecurringInvoice->getLabel(),
367
+                    ])
368
+                    ->native(false)
369
+                    ->query(function (Builder $query, array $data) {
370
+                        $sourceType = $data['value'] ?? null;
371
+
372
+                        return match ($sourceType) {
373
+                            DocumentType::Estimate->value => $query->whereNotNull('estimate_id'),
374
+                            DocumentType::RecurringInvoice->value => $query->whereNotNull('recurring_invoice_id'),
375
+                            default => $query,
376
+                        };
377
+                    }),
352 378
                 DateRangeFilter::make('date')
353 379
                     ->fromLabel('From Date')
354 380
                     ->untilLabel('To Date')

+ 62
- 0
app/Filament/Company/Resources/Sales/InvoiceResource/Pages/ListInvoices.php Visa fil

@@ -5,13 +5,21 @@ namespace App\Filament\Company\Resources\Sales\InvoiceResource\Pages;
5 5
 use App\Enums\Accounting\InvoiceStatus;
6 6
 use App\Filament\Company\Resources\Sales\InvoiceResource;
7 7
 use App\Filament\Company\Resources\Sales\InvoiceResource\Widgets;
8
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages\ViewRecurringInvoice;
8 9
 use App\Models\Accounting\Invoice;
10
+use App\Models\Accounting\RecurringInvoice;
11
+use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
9 12
 use Filament\Actions;
13
+use Filament\Infolists\Components\Actions\Action;
14
+use Filament\Infolists\Infolist;
10 15
 use Filament\Pages\Concerns\ExposesTableToWidgets;
11 16
 use Filament\Resources\Components\Tab;
12 17
 use Filament\Resources\Pages\ListRecords;
13 18
 use Filament\Support\Enums\MaxWidth;
14 19
 use Illuminate\Database\Eloquent\Builder;
20
+use Illuminate\Support\Facades\Blade;
21
+use Illuminate\Support\HtmlString;
22
+use Livewire\Attributes\Url;
15 23
 
16 24
 class ListInvoices extends ListRecords
17 25
 {
@@ -19,6 +27,54 @@ class ListInvoices extends ListRecords
19 27
 
20 28
     protected static string $resource = InvoiceResource::class;
21 29
 
30
+    #[Url(except: '')]
31
+    public string $recurringInvoice = '';
32
+
33
+    protected static string $view = 'filament.company.resources.sales.invoice-resource.pages.list-invoices';
34
+
35
+    public function infolist(Infolist $infolist): Infolist
36
+    {
37
+        return $infolist
38
+            ->schema([
39
+                SimpleAlert::make('recurringInvoiceFilter')
40
+                    ->info()
41
+                    ->title(function () {
42
+                        if (empty($this->recurringInvoice)) {
43
+                            return null;
44
+                        }
45
+
46
+                        $recurringInvoice = RecurringInvoice::find($this->recurringInvoice);
47
+
48
+                        $clientName = $recurringInvoice?->client?->name;
49
+
50
+                        if (! $clientName) {
51
+                            return 'You are currently viewing invoices created from a recurring invoice';
52
+                        }
53
+
54
+                        $recurringInvoiceUrl = ViewRecurringInvoice::getUrl([
55
+                            'record' => $recurringInvoice,
56
+                        ]);
57
+
58
+                        $link = Blade::render('filament::components.link', [
59
+                            'href' => $recurringInvoiceUrl,
60
+                            'slot' => 'a recurring invoice for ' . $clientName,
61
+                        ]);
62
+
63
+                        return new HtmlString(
64
+                            "You are currently viewing invoices created from {$link}"
65
+                        );
66
+                    })
67
+                    ->visible(fn () => ! empty($this->recurringInvoice))
68
+                    ->actions([
69
+                        Action::make('clearFilter')
70
+                            ->label('Clear Filter')
71
+                            ->button()
72
+                            ->outlined()
73
+                            ->action('clearFilter'),
74
+                    ]),
75
+            ]);
76
+    }
77
+
22 78
     protected function getHeaderActions(): array
23 79
     {
24 80
         return [
@@ -33,6 +89,12 @@ class ListInvoices extends ListRecords
33 89
         ];
34 90
     }
35 91
 
92
+    public function clearFilter(): void
93
+    {
94
+        $this->recurringInvoice = '';
95
+        $this->tableFilters = []; // Refresh widgets/table
96
+    }
97
+
36 98
     public function getMaxContentWidth(): MaxWidth | string | null
37 99
     {
38 100
         return 'max-w-8xl';

+ 349
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource.php Visa fil

@@ -0,0 +1,349 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales;
4
+
5
+use App\Enums\Accounting\DocumentDiscountMethod;
6
+use App\Enums\Accounting\DocumentType;
7
+use App\Enums\Accounting\RecurringInvoiceStatus;
8
+use App\Enums\Setting\PaymentTerms;
9
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
10
+use App\Filament\Forms\Components\CreateCurrencySelect;
11
+use App\Filament\Forms\Components\DocumentTotals;
12
+use App\Models\Accounting\Adjustment;
13
+use App\Models\Accounting\RecurringInvoice;
14
+use App\Models\Common\Client;
15
+use App\Models\Common\Offering;
16
+use App\Utilities\Currency\CurrencyAccessor;
17
+use App\Utilities\Currency\CurrencyConverter;
18
+use App\Utilities\RateCalculator;
19
+use Awcodes\TableRepeater\Components\TableRepeater;
20
+use Awcodes\TableRepeater\Header;
21
+use Filament\Forms;
22
+use Filament\Forms\Components\FileUpload;
23
+use Filament\Forms\Form;
24
+use Filament\Resources\Resource;
25
+use Filament\Support\Enums\MaxWidth;
26
+use Filament\Tables;
27
+use Filament\Tables\Table;
28
+use Illuminate\Support\Facades\Auth;
29
+use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
30
+
31
+class RecurringInvoiceResource extends Resource
32
+{
33
+    protected static ?string $model = RecurringInvoice::class;
34
+
35
+    public static function form(Form $form): Form
36
+    {
37
+        $company = Auth::user()->currentCompany;
38
+
39
+        return $form
40
+            ->schema([
41
+                Forms\Components\Section::make('Invoice Header')
42
+                    ->collapsible()
43
+                    ->collapsed()
44
+                    ->schema([
45
+                        Forms\Components\Split::make([
46
+                            Forms\Components\Group::make([
47
+                                FileUpload::make('logo')
48
+                                    ->openable()
49
+                                    ->maxSize(1024)
50
+                                    ->localizeLabel()
51
+                                    ->visibility('public')
52
+                                    ->disk('public')
53
+                                    ->directory('logos/document')
54
+                                    ->imageResizeMode('contain')
55
+                                    ->imageCropAspectRatio('3:2')
56
+                                    ->panelAspectRatio('3:2')
57
+                                    ->maxWidth(MaxWidth::ExtraSmall)
58
+                                    ->panelLayout('integrated')
59
+                                    ->removeUploadedFileButtonPosition('center bottom')
60
+                                    ->uploadButtonPosition('center bottom')
61
+                                    ->uploadProgressIndicatorPosition('center bottom')
62
+                                    ->getUploadedFileNameForStorageUsing(
63
+                                        static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
64
+                                            ->prepend(Auth::user()->currentCompany->id . '_'),
65
+                                    )
66
+                                    ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
67
+                            ]),
68
+                            Forms\Components\Group::make([
69
+                                Forms\Components\TextInput::make('header')
70
+                                    ->default(fn () => $company->defaultInvoice->header),
71
+                                Forms\Components\TextInput::make('subheader')
72
+                                    ->default(fn () => $company->defaultInvoice->subheader),
73
+                                Forms\Components\View::make('filament.forms.components.company-info')
74
+                                    ->viewData([
75
+                                        'company_name' => $company->name,
76
+                                        'company_address' => $company->profile->address,
77
+                                        'company_city' => $company->profile->city?->name,
78
+                                        'company_state' => $company->profile->state?->name,
79
+                                        'company_zip' => $company->profile->zip_code,
80
+                                        'company_country' => $company->profile->state?->country->name,
81
+                                    ]),
82
+                            ])->grow(true),
83
+                        ])->from('md'),
84
+                    ]),
85
+                Forms\Components\Section::make('Invoice Details')
86
+                    ->schema([
87
+                        Forms\Components\Split::make([
88
+                            Forms\Components\Group::make([
89
+                                Forms\Components\Select::make('client_id')
90
+                                    ->relationship('client', 'name')
91
+                                    ->preload()
92
+                                    ->searchable()
93
+                                    ->required()
94
+                                    ->live()
95
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
96
+                                        if (! $state) {
97
+                                            return;
98
+                                        }
99
+
100
+                                        $currencyCode = Client::find($state)?->currency_code;
101
+
102
+                                        if ($currencyCode) {
103
+                                            $set('currency_code', $currencyCode);
104
+                                        }
105
+                                    }),
106
+                                CreateCurrencySelect::make('currency_code'),
107
+                            ]),
108
+                            Forms\Components\Group::make([
109
+                                Forms\Components\Placeholder::make('invoice_number')
110
+                                    ->label('Invoice Number')
111
+                                    ->content('Auto-generated'),
112
+                                Forms\Components\TextInput::make('order_number')
113
+                                    ->label('P.O/S.O Number'),
114
+                                Forms\Components\Placeholder::make('date')
115
+                                    ->label('Invoice Date')
116
+                                    ->content('Auto-generated'),
117
+                                Forms\Components\Select::make('payment_terms')
118
+                                    ->label('Payment Due')
119
+                                    ->options(PaymentTerms::class)
120
+                                    ->softRequired()
121
+                                    ->default($company->defaultInvoice->payment_terms)
122
+                                    ->live(),
123
+                                Forms\Components\Select::make('discount_method')
124
+                                    ->label('Discount Method')
125
+                                    ->options(DocumentDiscountMethod::class)
126
+                                    ->selectablePlaceholder(false)
127
+                                    ->default(DocumentDiscountMethod::PerLineItem)
128
+                                    ->afterStateUpdated(function ($state, Forms\Set $set) {
129
+                                        $discountMethod = DocumentDiscountMethod::parse($state);
130
+
131
+                                        if ($discountMethod->isPerDocument()) {
132
+                                            $set('lineItems.*.salesDiscounts', []);
133
+                                        }
134
+                                    })
135
+                                    ->live(),
136
+                            ])->grow(true),
137
+                        ])->from('md'),
138
+                        TableRepeater::make('lineItems')
139
+                            ->relationship()
140
+                            ->saveRelationshipsUsing(null)
141
+                            ->dehydrated(true)
142
+                            ->headers(function (Forms\Get $get) {
143
+                                $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
144
+
145
+                                $headers = [
146
+                                    Header::make('Items')->width($hasDiscounts ? '15%' : '20%'),
147
+                                    Header::make('Description')->width($hasDiscounts ? '25%' : '30%'),  // Increase when no discounts
148
+                                    Header::make('Quantity')->width('10%'),
149
+                                    Header::make('Price')->width('10%'),
150
+                                    Header::make('Taxes')->width($hasDiscounts ? '15%' : '20%'),       // Increase when no discounts
151
+                                ];
152
+
153
+                                if ($hasDiscounts) {
154
+                                    $headers[] = Header::make('Discounts')->width('15%');
155
+                                }
156
+
157
+                                $headers[] = Header::make('Amount')->width('10%')->align('right');
158
+
159
+                                return $headers;
160
+                            })
161
+                            ->schema([
162
+                                Forms\Components\Select::make('offering_id')
163
+                                    ->relationship('sellableOffering', 'name')
164
+                                    ->preload()
165
+                                    ->searchable()
166
+                                    ->required()
167
+                                    ->live()
168
+                                    ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
169
+                                        $offeringId = $state;
170
+                                        $offeringRecord = Offering::with(['salesTaxes', 'salesDiscounts'])->find($offeringId);
171
+
172
+                                        if ($offeringRecord) {
173
+                                            $set('description', $offeringRecord->description);
174
+                                            $set('unit_price', $offeringRecord->price);
175
+                                            $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
176
+
177
+                                            $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
178
+                                            if ($discountMethod->isPerLineItem()) {
179
+                                                $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
180
+                                            }
181
+                                        }
182
+                                    }),
183
+                                Forms\Components\TextInput::make('description'),
184
+                                Forms\Components\TextInput::make('quantity')
185
+                                    ->required()
186
+                                    ->numeric()
187
+                                    ->live()
188
+                                    ->default(1),
189
+                                Forms\Components\TextInput::make('unit_price')
190
+                                    ->hiddenLabel()
191
+                                    ->numeric()
192
+                                    ->live()
193
+                                    ->default(0),
194
+                                Forms\Components\Select::make('salesTaxes')
195
+                                    ->relationship('salesTaxes', 'name')
196
+                                    ->saveRelationshipsUsing(null)
197
+                                    ->dehydrated(true)
198
+                                    ->preload()
199
+                                    ->multiple()
200
+                                    ->live()
201
+                                    ->searchable(),
202
+                                Forms\Components\Select::make('salesDiscounts')
203
+                                    ->relationship('salesDiscounts', 'name')
204
+                                    ->saveRelationshipsUsing(null)
205
+                                    ->dehydrated(true)
206
+                                    ->preload()
207
+                                    ->multiple()
208
+                                    ->live()
209
+                                    ->hidden(function (Forms\Get $get) {
210
+                                        $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
211
+
212
+                                        return $discountMethod->isPerDocument();
213
+                                    })
214
+                                    ->searchable(),
215
+                                Forms\Components\Placeholder::make('total')
216
+                                    ->hiddenLabel()
217
+                                    ->extraAttributes(['class' => 'text-left sm:text-right'])
218
+                                    ->content(function (Forms\Get $get) {
219
+                                        $quantity = max((float) ($get('quantity') ?? 0), 0);
220
+                                        $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
221
+                                        $salesTaxes = $get('salesTaxes') ?? [];
222
+                                        $salesDiscounts = $get('salesDiscounts') ?? [];
223
+                                        $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
224
+
225
+                                        $subtotal = $quantity * $unitPrice;
226
+
227
+                                        $subtotalInCents = CurrencyConverter::convertToCents($subtotal, $currencyCode);
228
+
229
+                                        $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
230
+                                            ->get()
231
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
232
+                                                if ($adjustment->computation->isPercentage()) {
233
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
234
+                                                } else {
235
+                                                    return $adjustment->getRawOriginal('rate');
236
+                                                }
237
+                                            });
238
+
239
+                                        $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
240
+                                            ->get()
241
+                                            ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
242
+                                                if ($adjustment->computation->isPercentage()) {
243
+                                                    return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
244
+                                                } else {
245
+                                                    return $adjustment->getRawOriginal('rate');
246
+                                                }
247
+                                            });
248
+
249
+                                        // Final total
250
+                                        $totalInCents = $subtotalInCents + ($taxAmountInCents - $discountAmountInCents);
251
+
252
+                                        return CurrencyConverter::formatCentsToMoney($totalInCents, $currencyCode);
253
+                                    }),
254
+                            ]),
255
+                        DocumentTotals::make()
256
+                            ->type(DocumentType::Invoice),
257
+                        Forms\Components\Textarea::make('terms')
258
+                            ->columnSpanFull(),
259
+                    ]),
260
+                Forms\Components\Section::make('Invoice Footer')
261
+                    ->collapsible()
262
+                    ->collapsed()
263
+                    ->schema([
264
+                        Forms\Components\Textarea::make('footer')
265
+                            ->columnSpanFull(),
266
+                    ]),
267
+            ]);
268
+    }
269
+
270
+    public static function table(Table $table): Table
271
+    {
272
+        return $table
273
+            ->defaultSort('next_date')
274
+            ->columns([
275
+                Tables\Columns\TextColumn::make('id')
276
+                    ->label('ID')
277
+                    ->sortable()
278
+                    ->toggleable(isToggledHiddenByDefault: true)
279
+                    ->searchable(),
280
+                Tables\Columns\TextColumn::make('status')
281
+                    ->badge()
282
+                    ->searchable(),
283
+                Tables\Columns\TextColumn::make('client.name')
284
+                    ->sortable()
285
+                    ->searchable(),
286
+                Tables\Columns\TextColumn::make('schedule')
287
+                    ->label('Schedule')
288
+                    ->getStateUsing(function (RecurringInvoice $record) {
289
+                        return $record->getScheduleDescription();
290
+                    })
291
+                    ->description(function (RecurringInvoice $record) {
292
+                        return $record->getTimelineDescription();
293
+                    }),
294
+                Tables\Columns\TextColumn::make('last_date')
295
+                    ->label('Last Invoice')
296
+                    ->date()
297
+                    ->sortable()
298
+                    ->searchable(),
299
+                Tables\Columns\TextColumn::make('next_date')
300
+                    ->label('Next Invoice')
301
+                    ->date()
302
+                    ->sortable()
303
+                    ->searchable(),
304
+                Tables\Columns\TextColumn::make('total')
305
+                    ->currencyWithConversion(static fn (RecurringInvoice $record) => $record->currency_code)
306
+                    ->sortable()
307
+                    ->toggleable(),
308
+            ])
309
+            ->filters([
310
+                Tables\Filters\SelectFilter::make('client')
311
+                    ->relationship('client', 'name')
312
+                    ->searchable()
313
+                    ->preload(),
314
+                Tables\Filters\SelectFilter::make('status')
315
+                    ->options(RecurringInvoiceStatus::class)
316
+                    ->native(false),
317
+            ])
318
+            ->actions([
319
+                Tables\Actions\ActionGroup::make([
320
+                    Tables\Actions\EditAction::make(),
321
+                    Tables\Actions\ViewAction::make(),
322
+                    Tables\Actions\DeleteAction::make(),
323
+                    RecurringInvoice::getUpdateScheduleAction(Tables\Actions\Action::class),
324
+                ]),
325
+            ])
326
+            ->bulkActions([
327
+                Tables\Actions\BulkActionGroup::make([
328
+                    Tables\Actions\DeleteBulkAction::make(),
329
+                ]),
330
+            ]);
331
+    }
332
+
333
+    public static function getRelations(): array
334
+    {
335
+        return [
336
+            //
337
+        ];
338
+    }
339
+
340
+    public static function getPages(): array
341
+    {
342
+        return [
343
+            'index' => Pages\ListRecurringInvoices::route('/'),
344
+            'create' => Pages\CreateRecurringInvoice::route('/create'),
345
+            'view' => Pages\ViewRecurringInvoice::route('/{record}'),
346
+            'edit' => Pages\EditRecurringInvoice::route('/{record}/edit'),
347
+        ];
348
+    }
349
+}

+ 36
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/CreateRecurringInvoice.php Visa fil

@@ -0,0 +1,36 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7
+use App\Models\Accounting\RecurringInvoice;
8
+use Filament\Resources\Pages\CreateRecord;
9
+use Filament\Support\Enums\MaxWidth;
10
+use Illuminate\Database\Eloquent\Model;
11
+
12
+class CreateRecurringInvoice extends CreateRecord
13
+{
14
+    use ManagesLineItems;
15
+
16
+    protected static string $resource = RecurringInvoiceResource::class;
17
+
18
+    public function getMaxContentWidth(): MaxWidth | string | null
19
+    {
20
+        return MaxWidth::Full;
21
+    }
22
+
23
+    protected function handleRecordCreation(array $data): Model
24
+    {
25
+        /** @var RecurringInvoice $record */
26
+        $record = parent::handleRecordCreation($data);
27
+
28
+        $this->handleLineItems($record, collect($data['lineItems'] ?? []));
29
+
30
+        $totals = $this->updateDocumentTotals($record, $data);
31
+
32
+        $record->updateQuietly($totals);
33
+
34
+        return $record;
35
+    }
36
+}

+ 46
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/EditRecurringInvoice.php Visa fil

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Concerns\ManagesLineItems;
6
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
7
+use App\Models\Accounting\Estimate;
8
+use Filament\Actions;
9
+use Filament\Resources\Pages\EditRecord;
10
+use Filament\Support\Enums\MaxWidth;
11
+use Illuminate\Database\Eloquent\Model;
12
+
13
+class EditRecurringInvoice extends EditRecord
14
+{
15
+    use ManagesLineItems;
16
+
17
+    protected static string $resource = RecurringInvoiceResource::class;
18
+
19
+    protected function getHeaderActions(): array
20
+    {
21
+        return [
22
+            Actions\DeleteAction::make(),
23
+        ];
24
+    }
25
+
26
+    public function getMaxContentWidth(): MaxWidth | string | null
27
+    {
28
+        return MaxWidth::Full;
29
+    }
30
+
31
+    protected function handleRecordUpdate(Model $record, array $data): Model
32
+    {
33
+        /** @var Estimate $record */
34
+        $lineItems = collect($data['lineItems'] ?? []);
35
+
36
+        $this->deleteRemovedLineItems($record, $lineItems);
37
+
38
+        $this->handleLineItems($record, $lineItems);
39
+
40
+        $totals = $this->updateDocumentTotals($record, $data);
41
+
42
+        $data = array_merge($data, $totals);
43
+
44
+        return parent::handleRecordUpdate($record, $data);
45
+    }
46
+}

+ 51
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ListRecurringInvoices.php Visa fil

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

+ 135
- 0
app/Filament/Company/Resources/Sales/RecurringInvoiceResource/Pages/ViewRecurringInvoice.php Visa fil

@@ -0,0 +1,135 @@
1
+<?php
2
+
3
+namespace App\Filament\Company\Resources\Sales\RecurringInvoiceResource\Pages;
4
+
5
+use App\Enums\Accounting\DocumentType;
6
+use App\Filament\Company\Resources\Sales\ClientResource;
7
+use App\Filament\Company\Resources\Sales\InvoiceResource\Pages\ListInvoices;
8
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
9
+use App\Filament\Infolists\Components\DocumentPreview;
10
+use App\Models\Accounting\RecurringInvoice;
11
+use CodeWithDennis\SimpleAlert\Components\Infolists\SimpleAlert;
12
+use Filament\Actions;
13
+use Filament\Infolists\Components\Actions\Action;
14
+use Filament\Infolists\Components\Grid;
15
+use Filament\Infolists\Components\Section;
16
+use Filament\Infolists\Components\TextEntry;
17
+use Filament\Infolists\Infolist;
18
+use Filament\Resources\Pages\ViewRecord;
19
+use Filament\Support\Enums\FontWeight;
20
+use Filament\Support\Enums\IconPosition;
21
+use Filament\Support\Enums\IconSize;
22
+use Filament\Support\Enums\MaxWidth;
23
+use Illuminate\Support\Str;
24
+
25
+class ViewRecurringInvoice extends ViewRecord
26
+{
27
+    protected static string $resource = RecurringInvoiceResource::class;
28
+
29
+    public function getMaxContentWidth(): MaxWidth | string | null
30
+    {
31
+        return MaxWidth::SixExtraLarge;
32
+    }
33
+
34
+    protected function getHeaderActions(): array
35
+    {
36
+        return [
37
+            Actions\ActionGroup::make([
38
+                Actions\EditAction::make(),
39
+                Actions\DeleteAction::make(),
40
+                RecurringInvoice::getUpdateScheduleAction(),
41
+                RecurringInvoice::getApproveDraftAction(),
42
+            ])
43
+                ->label('Actions')
44
+                ->button()
45
+                ->outlined()
46
+                ->dropdownPlacement('bottom-end')
47
+                ->icon('heroicon-c-chevron-down')
48
+                ->iconSize(IconSize::Small)
49
+                ->iconPosition(IconPosition::After),
50
+        ];
51
+    }
52
+
53
+    public function infolist(Infolist $infolist): Infolist
54
+    {
55
+        return $infolist
56
+            ->schema([
57
+                SimpleAlert::make('scheduleIsNotSet')
58
+                    ->info()
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.')
61
+                    ->visible(fn (RecurringInvoice $record) => ! $record->hasSchedule())
62
+                    ->columnSpanFull()
63
+                    ->actions([
64
+                        RecurringInvoice::getUpdateScheduleAction(Action::class)
65
+                            ->outlined(),
66
+                    ]),
67
+                SimpleAlert::make('readyToApprove')
68
+                    ->info()
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.')
71
+                    ->visible(fn (RecurringInvoice $record) => $record->isDraft() && $record->hasSchedule())
72
+                    ->columnSpanFull()
73
+                    ->actions([
74
+                        RecurringInvoice::getApproveDraftAction(Action::class)
75
+                            ->outlined(),
76
+                    ]),
77
+                Section::make('Invoice Details')
78
+                    ->columns(4)
79
+                    ->schema([
80
+                        Grid::make(1)
81
+                            ->schema([
82
+                                TextEntry::make('status')
83
+                                    ->badge(),
84
+                                TextEntry::make('client.name')
85
+                                    ->label('Client')
86
+                                    ->color('primary')
87
+                                    ->weight(FontWeight::SemiBold)
88
+                                    ->url(static fn (RecurringInvoice $record) => ClientResource::getUrl('edit', ['record' => $record->client_id])),
89
+                                TextEntry::make('last_date')
90
+                                    ->label('Last Invoice')
91
+                                    ->date()
92
+                                    ->placeholder('Not Created'),
93
+                                TextEntry::make('next_date')
94
+                                    ->label('Next Invoice')
95
+                                    ->placeholder('Not Scheduled')
96
+                                    ->date(),
97
+                                TextEntry::make('schedule')
98
+                                    ->label('Schedule')
99
+                                    ->getStateUsing(function (RecurringInvoice $record) {
100
+                                        return $record->getScheduleDescription();
101
+                                    })
102
+                                    ->helperText(function (RecurringInvoice $record) {
103
+                                        return $record->getTimelineDescription();
104
+                                    }),
105
+                                TextEntry::make('occurrences_count')
106
+                                    ->label('Created to Date')
107
+                                    ->visible(static fn (RecurringInvoice $record) => $record->occurrences_count > 0)
108
+                                    ->color('primary')
109
+                                    ->weight(FontWeight::SemiBold)
110
+                                    ->suffix(fn (RecurringInvoice $record) => Str::of(' invoice')->plural($record->occurrences_count))
111
+                                    ->url(static function (RecurringInvoice $record) {
112
+                                        return ListInvoices::getUrl(['recurringInvoice' => $record->id]);
113
+                                    }),
114
+                                TextEntry::make('end_date')
115
+                                    ->label('Ends On')
116
+                                    ->date()
117
+                                    ->visible(fn (RecurringInvoice $record) => $record->end_type?->isOn()),
118
+                                TextEntry::make('approved_at')
119
+                                    ->label('Approved At')
120
+                                    ->placeholder('Not Approved')
121
+                                    ->date(),
122
+                                TextEntry::make('ended_at')
123
+                                    ->label('Ended At')
124
+                                    ->date()
125
+                                    ->visible(fn (RecurringInvoice $record) => $record->ended_at),
126
+                                TextEntry::make('total')
127
+                                    ->label('Invoice Amount')
128
+                                    ->currency(static fn (RecurringInvoice $record) => $record->currency_code),
129
+                            ])->columnSpan(1),
130
+                        DocumentPreview::make()
131
+                            ->type(DocumentType::RecurringInvoice),
132
+                    ]),
133
+            ]);
134
+    }
135
+}

+ 45
- 0
app/Filament/Forms/Components/LabeledField.php Visa fil

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

+ 33
- 0
app/Jobs/GenerateRecurringInvoices.php Visa fil

@@ -0,0 +1,33 @@
1
+<?php
2
+
3
+namespace App\Jobs;
4
+
5
+use App\Enums\Accounting\RecurringInvoiceStatus;
6
+use App\Models\Accounting\RecurringInvoice;
7
+use Illuminate\Contracts\Queue\ShouldQueue;
8
+use Illuminate\Foundation\Bus\Dispatchable;
9
+use Illuminate\Foundation\Queue\Queueable;
10
+use Illuminate\Queue\InteractsWithQueue;
11
+use Illuminate\Queue\SerializesModels;
12
+
13
+class GenerateRecurringInvoices implements ShouldQueue
14
+{
15
+    use Dispatchable;
16
+    use InteractsWithQueue;
17
+    use Queueable;
18
+    use SerializesModels;
19
+
20
+    /**
21
+     * Execute the job.
22
+     */
23
+    public function handle(): void
24
+    {
25
+        RecurringInvoice::query()
26
+            ->where('status', RecurringInvoiceStatus::Active)
27
+            ->chunk(100, function ($recurringInvoices) {
28
+                foreach ($recurringInvoices as $recurringInvoice) {
29
+                    $recurringInvoice->generateDueInvoices();
30
+                }
31
+            });
32
+    }
33
+}

+ 32
- 23
app/Models/Accounting/Bill.php Visa fil

@@ -5,11 +5,10 @@ namespace App\Models\Accounting;
5 5
 use App\Casts\MoneyCast;
6 6
 use App\Casts\RateCast;
7 7
 use App\Collections\Accounting\DocumentCollection;
8
-use App\Concerns\Blamable;
9
-use App\Concerns\CompanyOwned;
10 8
 use App\Enums\Accounting\AdjustmentComputation;
11 9
 use App\Enums\Accounting\BillStatus;
12 10
 use App\Enums\Accounting\DocumentDiscountMethod;
11
+use App\Enums\Accounting\DocumentType;
13 12
 use App\Enums\Accounting\JournalEntryType;
14 13
 use App\Enums\Accounting\TransactionType;
15 14
 use App\Filament\Company\Resources\Purchases\BillResource;
@@ -25,7 +24,6 @@ use Illuminate\Database\Eloquent\Attributes\CollectedBy;
25 24
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
26 25
 use Illuminate\Database\Eloquent\Builder;
27 26
 use Illuminate\Database\Eloquent\Casts\Attribute;
28
-use Illuminate\Database\Eloquent\Factories\HasFactory;
29 27
 use Illuminate\Database\Eloquent\Model;
30 28
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
31 29
 use Illuminate\Database\Eloquent\Relations\MorphMany;
@@ -34,12 +32,8 @@ use Illuminate\Support\Carbon;
34 32
 
35 33
 #[CollectedBy(DocumentCollection::class)]
36 34
 #[ObservedBy(BillObserver::class)]
37
-class Bill extends Model
35
+class Bill extends Document
38 36
 {
39
-    use Blamable;
40
-    use CompanyOwned;
41
-    use HasFactory;
42
-
43 37
     protected $table = 'bills';
44 38
 
45 39
     protected $fillable = [
@@ -81,21 +75,11 @@ class Bill extends Model
81 75
         'amount_due' => MoneyCast::class,
82 76
     ];
83 77
 
84
-    public function currency(): BelongsTo
85
-    {
86
-        return $this->belongsTo(Currency::class, 'currency_code', 'code');
87
-    }
88
-
89 78
     public function vendor(): BelongsTo
90 79
     {
91 80
         return $this->belongsTo(Vendor::class);
92 81
     }
93 82
 
94
-    public function lineItems(): MorphMany
95
-    {
96
-        return $this->morphMany(DocumentLineItem::class, 'documentable');
97
-    }
98
-
99 83
     public function transactions(): MorphMany
100 84
     {
101 85
         return $this->morphMany(Transaction::class, 'transactionable');
@@ -122,6 +106,36 @@ class Bill extends Model
122 106
             ->where('type', TransactionType::Journal);
123 107
     }
124 108
 
109
+    public function documentType(): DocumentType
110
+    {
111
+        return DocumentType::Bill;
112
+    }
113
+
114
+    public function documentNumber(): ?string
115
+    {
116
+        return $this->bill_number;
117
+    }
118
+
119
+    public function documentDate(): ?string
120
+    {
121
+        return $this->date?->toDefaultDateFormat();
122
+    }
123
+
124
+    public function dueDate(): ?string
125
+    {
126
+        return $this->due_date?->toDefaultDateFormat();
127
+    }
128
+
129
+    public function referenceNumber(): ?string
130
+    {
131
+        return $this->order_number;
132
+    }
133
+
134
+    public function amountDue(): ?string
135
+    {
136
+        return $this->amount_due;
137
+    }
138
+
125 139
     protected function isCurrentlyOverdue(): Attribute
126 140
     {
127 141
         return Attribute::get(function () {
@@ -152,11 +166,6 @@ class Bill extends Model
152 166
         ]) && $this->currency_code === CurrencyAccessor::getDefaultCurrency();
153 167
     }
154 168
 
155
-    public function hasLineItems(): bool
156
-    {
157
-        return $this->lineItems()->exists();
158
-    }
159
-
160 169
     public function hasPayments(): bool
161 170
     {
162 171
         return $this->payments->isNotEmpty();

+ 46
- 0
app/Models/Accounting/Document.php Visa fil

@@ -0,0 +1,46 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Concerns\Blamable;
6
+use App\Concerns\CompanyOwned;
7
+use App\Enums\Accounting\DocumentType;
8
+use App\Models\Setting\Currency;
9
+use Illuminate\Database\Eloquent\Factories\HasFactory;
10
+use Illuminate\Database\Eloquent\Model;
11
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
12
+use Illuminate\Database\Eloquent\Relations\MorphMany;
13
+
14
+abstract class Document extends Model
15
+{
16
+    use Blamable;
17
+    use CompanyOwned;
18
+    use HasFactory;
19
+
20
+    public function currency(): BelongsTo
21
+    {
22
+        return $this->belongsTo(Currency::class, 'currency_code', 'code');
23
+    }
24
+
25
+    public function lineItems(): MorphMany
26
+    {
27
+        return $this->morphMany(DocumentLineItem::class, 'documentable');
28
+    }
29
+
30
+    public function hasLineItems(): bool
31
+    {
32
+        return $this->lineItems()->exists();
33
+    }
34
+
35
+    abstract public function documentType(): DocumentType;
36
+
37
+    abstract public function documentNumber(): ?string;
38
+
39
+    abstract public function documentDate(): ?string;
40
+
41
+    abstract public function dueDate(): ?string;
42
+
43
+    abstract public function referenceNumber(): ?string;
44
+
45
+    abstract public function amountDue(): ?string;
46
+}

+ 28
- 21
app/Models/Accounting/Estimate.php Visa fil

@@ -5,16 +5,14 @@ namespace App\Models\Accounting;
5 5
 use App\Casts\MoneyCast;
6 6
 use App\Casts\RateCast;
7 7
 use App\Collections\Accounting\DocumentCollection;
8
-use App\Concerns\Blamable;
9
-use App\Concerns\CompanyOwned;
10 8
 use App\Enums\Accounting\AdjustmentComputation;
11 9
 use App\Enums\Accounting\DocumentDiscountMethod;
10
+use App\Enums\Accounting\DocumentType;
12 11
 use App\Enums\Accounting\EstimateStatus;
13 12
 use App\Enums\Accounting\InvoiceStatus;
14 13
 use App\Filament\Company\Resources\Sales\EstimateResource;
15 14
 use App\Filament\Company\Resources\Sales\InvoiceResource;
16 15
 use App\Models\Common\Client;
17
-use App\Models\Setting\Currency;
18 16
 use App\Observers\EstimateObserver;
19 17
 use Filament\Actions\Action;
20 18
 use Filament\Actions\MountableAction;
@@ -23,21 +21,15 @@ use Illuminate\Database\Eloquent\Attributes\CollectedBy;
23 21
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
24 22
 use Illuminate\Database\Eloquent\Builder;
25 23
 use Illuminate\Database\Eloquent\Casts\Attribute;
26
-use Illuminate\Database\Eloquent\Factories\HasFactory;
27 24
 use Illuminate\Database\Eloquent\Model;
28 25
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
29 26
 use Illuminate\Database\Eloquent\Relations\HasOne;
30
-use Illuminate\Database\Eloquent\Relations\MorphMany;
31 27
 use Illuminate\Support\Carbon;
32 28
 
33 29
 #[CollectedBy(DocumentCollection::class)]
34 30
 #[ObservedBy(EstimateObserver::class)]
35
-class Estimate extends Model
31
+class Estimate extends Document
36 32
 {
37
-    use Blamable;
38
-    use CompanyOwned;
39
-    use HasFactory;
40
-
41 33
     protected $fillable = [
42 34
         'company_id',
43 35
         'client_id',
@@ -92,19 +84,39 @@ class Estimate extends Model
92 84
         return $this->belongsTo(Client::class);
93 85
     }
94 86
 
95
-    public function currency(): BelongsTo
87
+    public function invoice(): HasOne
96 88
     {
97
-        return $this->belongsTo(Currency::class, 'currency_code', 'code');
89
+        return $this->hasOne(Invoice::class);
98 90
     }
99 91
 
100
-    public function invoice(): HasOne
92
+    public function documentType(): DocumentType
101 93
     {
102
-        return $this->hasOne(Invoice::class);
94
+        return DocumentType::Estimate;
95
+    }
96
+
97
+    public function documentNumber(): ?string
98
+    {
99
+        return $this->estimate_number;
100
+    }
101
+
102
+    public function documentDate(): ?string
103
+    {
104
+        return $this->date?->toDateString();
105
+    }
106
+
107
+    public function dueDate(): ?string
108
+    {
109
+        return $this->expiration_date?->toDateString();
110
+    }
111
+
112
+    public function referenceNumber(): ?string
113
+    {
114
+        return $this->reference_number;
103 115
     }
104 116
 
105
-    public function lineItems(): MorphMany
117
+    public function amountDue(): ?string
106 118
     {
107
-        return $this->morphMany(DocumentLineItem::class, 'documentable');
119
+        return $this->total;
108 120
     }
109 121
 
110 122
     protected function isCurrentlyExpired(): Attribute
@@ -190,11 +202,6 @@ class Estimate extends Model
190 202
             && ! $this->wasConverted();
191 203
     }
192 204
 
193
-    public function hasLineItems(): bool
194
-    {
195
-        return $this->lineItems()->exists();
196
-    }
197
-
198 205
     public function scopeActive(Builder $query): Builder
199 206
     {
200 207
         return $query->whereIn('status', [

+ 46
- 21
app/Models/Accounting/Invoice.php Visa fil

@@ -5,10 +5,9 @@ namespace App\Models\Accounting;
5 5
 use App\Casts\MoneyCast;
6 6
 use App\Casts\RateCast;
7 7
 use App\Collections\Accounting\DocumentCollection;
8
-use App\Concerns\Blamable;
9
-use App\Concerns\CompanyOwned;
10 8
 use App\Enums\Accounting\AdjustmentComputation;
11 9
 use App\Enums\Accounting\DocumentDiscountMethod;
10
+use App\Enums\Accounting\DocumentType;
12 11
 use App\Enums\Accounting\InvoiceStatus;
13 12
 use App\Enums\Accounting\JournalEntryType;
14 13
 use App\Enums\Accounting\TransactionType;
@@ -16,7 +15,6 @@ use App\Filament\Company\Resources\Sales\InvoiceResource;
16 15
 use App\Models\Banking\BankAccount;
17 16
 use App\Models\Common\Client;
18 17
 use App\Models\Company;
19
-use App\Models\Setting\Currency;
20 18
 use App\Observers\InvoiceObserver;
21 19
 use App\Utilities\Currency\CurrencyAccessor;
22 20
 use App\Utilities\Currency\CurrencyConverter;
@@ -27,7 +25,6 @@ use Illuminate\Database\Eloquent\Attributes\CollectedBy;
27 25
 use Illuminate\Database\Eloquent\Attributes\ObservedBy;
28 26
 use Illuminate\Database\Eloquent\Builder;
29 27
 use Illuminate\Database\Eloquent\Casts\Attribute;
30
-use Illuminate\Database\Eloquent\Factories\HasFactory;
31 28
 use Illuminate\Database\Eloquent\Model;
32 29
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
33 30
 use Illuminate\Database\Eloquent\Relations\MorphMany;
@@ -36,18 +33,15 @@ use Illuminate\Support\Carbon;
36 33
 
37 34
 #[CollectedBy(DocumentCollection::class)]
38 35
 #[ObservedBy(InvoiceObserver::class)]
39
-class Invoice extends Model
36
+class Invoice extends Document
40 37
 {
41
-    use Blamable;
42
-    use CompanyOwned;
43
-    use HasFactory;
44
-
45 38
     protected $table = 'invoices';
46 39
 
47 40
     protected $fillable = [
48 41
         'company_id',
49 42
         'client_id',
50 43
         'estimate_id',
44
+        'recurring_invoice_id',
51 45
         'logo',
52 46
         'header',
53 47
         'subheader',
@@ -99,19 +93,14 @@ class Invoice extends Model
99 93
         return $this->belongsTo(Client::class);
100 94
     }
101 95
 
102
-    public function currency(): BelongsTo
103
-    {
104
-        return $this->belongsTo(Currency::class, 'currency_code', 'code');
105
-    }
106
-
107 96
     public function estimate(): BelongsTo
108 97
     {
109 98
         return $this->belongsTo(Estimate::class);
110 99
     }
111 100
 
112
-    public function lineItems(): MorphMany
101
+    public function recurringInvoice(): BelongsTo
113 102
     {
114
-        return $this->morphMany(DocumentLineItem::class, 'documentable');
103
+        return $this->belongsTo(RecurringInvoice::class);
115 104
     }
116 105
 
117 106
     public function transactions(): MorphMany
@@ -140,6 +129,47 @@ class Invoice extends Model
140 129
             ->where('type', TransactionType::Journal);
141 130
     }
142 131
 
132
+    protected function sourceType(): Attribute
133
+    {
134
+        return Attribute::get(function () {
135
+            return match (true) {
136
+                $this->estimate_id !== null => DocumentType::Estimate,
137
+                $this->recurring_invoice_id !== null => DocumentType::RecurringInvoice,
138
+                default => null,
139
+            };
140
+        });
141
+    }
142
+
143
+    public function documentType(): DocumentType
144
+    {
145
+        return DocumentType::Invoice;
146
+    }
147
+
148
+    public function documentNumber(): ?string
149
+    {
150
+        return $this->invoice_number;
151
+    }
152
+
153
+    public function documentDate(): ?string
154
+    {
155
+        return $this->date?->toDefaultDateFormat();
156
+    }
157
+
158
+    public function dueDate(): ?string
159
+    {
160
+        return $this->due_date?->toDefaultDateFormat();
161
+    }
162
+
163
+    public function referenceNumber(): ?string
164
+    {
165
+        return $this->order_number;
166
+    }
167
+
168
+    public function amountDue(): ?string
169
+    {
170
+        return $this->amount_due;
171
+    }
172
+
143 173
     public function scopeUnpaid(Builder $query): Builder
144 174
     {
145 175
         return $query->whereNotIn('status', [
@@ -216,11 +246,6 @@ class Invoice extends Model
216 246
         return ! $this->hasBeenSent();
217 247
     }
218 248
 
219
-    public function hasLineItems(): bool
220
-    {
221
-        return $this->lineItems()->exists();
222
-    }
223
-
224 249
     public function hasPayments(): bool
225 250
     {
226 251
         return $this->payments()->exists();

+ 672
- 0
app/Models/Accounting/RecurringInvoice.php Visa fil

@@ -0,0 +1,672 @@
1
+<?php
2
+
3
+namespace App\Models\Accounting;
4
+
5
+use App\Casts\MoneyCast;
6
+use App\Casts\RateCast;
7
+use App\Collections\Accounting\DocumentCollection;
8
+use App\Enums\Accounting\AdjustmentComputation;
9
+use App\Enums\Accounting\DayOfMonth;
10
+use App\Enums\Accounting\DayOfWeek;
11
+use App\Enums\Accounting\DocumentDiscountMethod;
12
+use App\Enums\Accounting\DocumentType;
13
+use App\Enums\Accounting\EndType;
14
+use App\Enums\Accounting\Frequency;
15
+use App\Enums\Accounting\IntervalType;
16
+use App\Enums\Accounting\InvoiceStatus;
17
+use App\Enums\Accounting\Month;
18
+use App\Enums\Accounting\RecurringInvoiceStatus;
19
+use App\Enums\Setting\PaymentTerms;
20
+use App\Filament\Forms\Components\CustomSection;
21
+use App\Models\Common\Client;
22
+use App\Models\Setting\CompanyProfile;
23
+use App\Observers\RecurringInvoiceObserver;
24
+use App\Support\ScheduleHandler;
25
+use App\Utilities\Localization\Timezone;
26
+use CodeWithDennis\SimpleAlert\Components\Forms\SimpleAlert;
27
+use Filament\Actions\Action;
28
+use Filament\Actions\MountableAction;
29
+use Filament\Forms;
30
+use Filament\Forms\Form;
31
+use Guava\FilamentClusters\Forms\Cluster;
32
+use Illuminate\Database\Eloquent\Attributes\CollectedBy;
33
+use Illuminate\Database\Eloquent\Attributes\ObservedBy;
34
+use Illuminate\Database\Eloquent\Model;
35
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
36
+use Illuminate\Database\Eloquent\Relations\HasMany;
37
+use Illuminate\Support\Carbon;
38
+
39
+#[CollectedBy(DocumentCollection::class)]
40
+#[ObservedBy(RecurringInvoiceObserver::class)]
41
+class RecurringInvoice extends Document
42
+{
43
+    protected $table = 'recurring_invoices';
44
+
45
+    protected $fillable = [
46
+        'company_id',
47
+        'client_id',
48
+        'logo',
49
+        'header',
50
+        'subheader',
51
+        'order_number',
52
+        'payment_terms',
53
+        'approved_at',
54
+        'ended_at',
55
+        'frequency',
56
+        'interval_type',
57
+        'interval_value',
58
+        'month',
59
+        'day_of_month',
60
+        'day_of_week',
61
+        'start_date',
62
+        'end_type',
63
+        'max_occurrences',
64
+        'end_date',
65
+        'occurrences_count',
66
+        'timezone',
67
+        'next_date',
68
+        'last_date',
69
+        'auto_send',
70
+        'send_time',
71
+        'status',
72
+        'currency_code',
73
+        'discount_method',
74
+        'discount_computation',
75
+        'discount_rate',
76
+        'subtotal',
77
+        'tax_total',
78
+        'discount_total',
79
+        'total',
80
+        'terms',
81
+        'footer',
82
+        'created_by',
83
+        'updated_by',
84
+    ];
85
+
86
+    protected $casts = [
87
+        'approved_at' => 'datetime',
88
+        'ended_at' => 'datetime',
89
+        'start_date' => 'date',
90
+        'end_date' => 'date',
91
+        'next_date' => 'date',
92
+        'last_date' => 'date',
93
+        'auto_send' => 'boolean',
94
+        'send_time' => 'datetime:H:i',
95
+        'payment_terms' => PaymentTerms::class,
96
+        'frequency' => Frequency::class,
97
+        'interval_type' => IntervalType::class,
98
+        'interval_value' => 'integer',
99
+        'month' => Month::class,
100
+        'day_of_month' => DayOfMonth::class,
101
+        'day_of_week' => DayOfWeek::class,
102
+        'end_type' => EndType::class,
103
+        'status' => RecurringInvoiceStatus::class,
104
+        'discount_method' => DocumentDiscountMethod::class,
105
+        'discount_computation' => AdjustmentComputation::class,
106
+        'discount_rate' => RateCast::class,
107
+        'subtotal' => MoneyCast::class,
108
+        'tax_total' => MoneyCast::class,
109
+        'discount_total' => MoneyCast::class,
110
+        'total' => MoneyCast::class,
111
+    ];
112
+
113
+    public function client(): BelongsTo
114
+    {
115
+        return $this->belongsTo(Client::class);
116
+    }
117
+
118
+    public function invoices(): HasMany
119
+    {
120
+        return $this->hasMany(Invoice::class, 'recurring_invoice_id');
121
+    }
122
+
123
+    public function documentType(): DocumentType
124
+    {
125
+        return DocumentType::RecurringInvoice;
126
+    }
127
+
128
+    public function documentNumber(): ?string
129
+    {
130
+        return 'Auto-generated';
131
+    }
132
+
133
+    public function documentDate(): ?string
134
+    {
135
+        return $this->calculateNextDate()?->toDefaultDateFormat() ?? 'Auto-generated';
136
+    }
137
+
138
+    public function dueDate(): ?string
139
+    {
140
+        return $this->calculateNextDueDate()?->toDefaultDateFormat() ?? 'Auto-generated';
141
+    }
142
+
143
+    public function referenceNumber(): ?string
144
+    {
145
+        return $this->order_number;
146
+    }
147
+
148
+    public function amountDue(): ?string
149
+    {
150
+        return $this->total;
151
+    }
152
+
153
+    public function isDraft(): bool
154
+    {
155
+        return $this->status === RecurringInvoiceStatus::Draft;
156
+    }
157
+
158
+    public function isActive(): bool
159
+    {
160
+        return $this->status === RecurringInvoiceStatus::Active;
161
+    }
162
+
163
+    public function wasApproved(): bool
164
+    {
165
+        return $this->approved_at !== null;
166
+    }
167
+
168
+    public function wasEnded(): bool
169
+    {
170
+        return $this->ended_at !== null;
171
+    }
172
+
173
+    public function isNeverEnding(): bool
174
+    {
175
+        return $this->end_type === EndType::Never;
176
+    }
177
+
178
+    public function canBeApproved(): bool
179
+    {
180
+        return $this->isDraft() && $this->hasSchedule() && ! $this->wasApproved();
181
+    }
182
+
183
+    public function canBeEnded(): bool
184
+    {
185
+        return $this->isActive() && ! $this->wasEnded();
186
+    }
187
+
188
+    public function hasSchedule(): bool
189
+    {
190
+        if (! $this->start_date) {
191
+            return false;
192
+        }
193
+
194
+        if (! $this->wasApproved() && $this->start_date->lt(today())) {
195
+            return false;
196
+        }
197
+
198
+        return true;
199
+    }
200
+
201
+    public function getScheduleDescription(): string
202
+    {
203
+        $frequency = $this->frequency;
204
+
205
+        return match (true) {
206
+            $frequency->isDaily() => 'Repeat daily',
207
+
208
+            $frequency->isWeekly() && $this->day_of_week => "Repeat weekly every {$this->day_of_week->getLabel()}",
209
+
210
+            $frequency->isMonthly() && $this->day_of_month => "Repeat monthly on the {$this->day_of_month->getLabel()} day",
211
+
212
+            $frequency->isYearly() && $this->month && $this->day_of_month => "Repeat yearly on {$this->month->getLabel()} {$this->day_of_month->getLabel()}",
213
+
214
+            $frequency->isCustom() => $this->getCustomScheduleDescription(),
215
+
216
+            default => 'Not Configured',
217
+        };
218
+    }
219
+
220
+    private function getCustomScheduleDescription(): string
221
+    {
222
+        $interval = $this->interval_value > 1
223
+            ? "{$this->interval_value} {$this->interval_type->getPluralLabel()}"
224
+            : $this->interval_type->getSingularLabel();
225
+
226
+        $dayDescription = match (true) {
227
+            $this->interval_type->isWeek() && $this->day_of_week => " on {$this->day_of_week->getLabel()}",
228
+
229
+            $this->interval_type->isMonth() && $this->day_of_month => " on the {$this->day_of_month->getLabel()} day",
230
+
231
+            $this->interval_type->isYear() && $this->month && $this->day_of_month => " on {$this->month->getLabel()} {$this->day_of_month->getLabel()}",
232
+
233
+            default => ''
234
+        };
235
+
236
+        return "Repeat every {$interval}{$dayDescription}";
237
+    }
238
+
239
+    public function getEndDescription(): string
240
+    {
241
+        if (! $this->end_type) {
242
+            return 'Not configured';
243
+        }
244
+
245
+        return match (true) {
246
+            $this->end_type->isNever() => 'Never',
247
+
248
+            $this->end_type->isAfter() && $this->max_occurrences => "After {$this->max_occurrences} " . str($this->max_occurrences === 1 ? 'invoice' : 'invoices'),
249
+
250
+            $this->end_type->isOn() && $this->end_date => 'On ' . $this->end_date->toDefaultDateFormat(),
251
+
252
+            default => 'Not configured'
253
+        };
254
+    }
255
+
256
+    public function getTimelineDescription(): string
257
+    {
258
+        $parts = [];
259
+
260
+        if ($this->start_date) {
261
+            $parts[] = 'First Invoice: ' . $this->start_date->toDefaultDateFormat();
262
+        }
263
+
264
+        if ($this->end_type) {
265
+            $parts[] = 'Ends: ' . $this->getEndDescription();
266
+        }
267
+
268
+        return implode(', ', $parts);
269
+    }
270
+
271
+    public function calculateNextDate(?Carbon $lastDate = null): ?Carbon
272
+    {
273
+        $lastDate ??= $this->last_date;
274
+
275
+        if (! $lastDate && $this->start_date && $this->wasApproved()) {
276
+            return $this->start_date;
277
+        }
278
+
279
+        if (! $lastDate) {
280
+            return null;
281
+        }
282
+
283
+        $nextDate = match (true) {
284
+            $this->frequency->isDaily() => $lastDate->addDay(),
285
+
286
+            $this->frequency->isWeekly() => $this->calculateNextWeeklyDate($lastDate),
287
+
288
+            $this->frequency->isMonthly() => $this->calculateNextMonthlyDate($lastDate),
289
+
290
+            $this->frequency->isYearly() => $this->calculateNextYearlyDate($lastDate),
291
+
292
+            $this->frequency->isCustom() => $this->calculateCustomNextDate($lastDate),
293
+
294
+            default => null
295
+        };
296
+
297
+        if (! $nextDate || $this->hasReachedEnd($nextDate)) {
298
+            return null;
299
+        }
300
+
301
+        return $nextDate;
302
+    }
303
+
304
+    public function calculateNextWeeklyDate(Carbon $lastDate): ?Carbon
305
+    {
306
+        return $lastDate->copy()->next($this->day_of_week->name);
307
+    }
308
+
309
+    public function calculateNextMonthlyDate(Carbon $lastDate): ?Carbon
310
+    {
311
+        return $this->day_of_month->resolveDate($lastDate->copy()->addMonth());
312
+    }
313
+
314
+    public function calculateNextYearlyDate(Carbon $lastDate): ?Carbon
315
+    {
316
+        return $this->day_of_month->resolveDate($lastDate->copy()->addYear()->month($this->month->value));
317
+    }
318
+
319
+    protected function calculateCustomNextDate(Carbon $lastDate): ?Carbon
320
+    {
321
+        $interval = $this->interval_value ?? 1;
322
+
323
+        return match ($this->interval_type) {
324
+            IntervalType::Day => $lastDate->copy()->addDays($interval),
325
+
326
+            IntervalType::Week => $lastDate->copy()->addWeeks($interval),
327
+
328
+            IntervalType::Month => $this->day_of_month->resolveDate($lastDate->copy()->addMonths($interval)),
329
+
330
+            IntervalType::Year => $this->day_of_month->resolveDate($lastDate->copy()->addYears($interval)->month($this->month->value)),
331
+
332
+            default => null
333
+        };
334
+    }
335
+
336
+    public function calculateNextDueDate(): ?Carbon
337
+    {
338
+        if (! $nextDate = $this->calculateNextDate()) {
339
+            return null;
340
+        }
341
+
342
+        if (! $terms = $this->payment_terms) {
343
+            return $nextDate;
344
+        }
345
+
346
+        return $nextDate->copy()->addDays($terms->getDays());
347
+    }
348
+
349
+    public function hasReachedEnd(?Carbon $nextDate = null): bool
350
+    {
351
+        if (! $this->end_type) {
352
+            return false;
353
+        }
354
+
355
+        return match (true) {
356
+            $this->end_type->isNever() => false,
357
+
358
+            $this->end_type->isAfter() => ($this->occurrences_count ?? 0) >= ($this->max_occurrences ?? 0),
359
+
360
+            $this->end_type->isOn() && $this->end_date && $nextDate => $nextDate->greaterThan($this->end_date),
361
+
362
+            default => false
363
+        };
364
+    }
365
+
366
+    public static function getUpdateScheduleAction(string $action = Action::class): MountableAction
367
+    {
368
+        return $action::make('updateSchedule')
369
+            ->label(fn (self $record) => $record->hasSchedule() ? 'Update Schedule' : 'Set Schedule')
370
+            ->icon('heroicon-o-calendar-date-range')
371
+            ->slideOver()
372
+            ->successNotificationTitle('Schedule Updated')
373
+            ->mountUsing(function (self $record, Form $form) {
374
+                $data = $record->attributesToArray();
375
+
376
+                $data['day_of_month'] ??= DayOfMonth::First;
377
+                $data['start_date'] ??= now()->addMonth()->startOfMonth();
378
+
379
+                $form->fill($data);
380
+            })
381
+            ->form([
382
+                CustomSection::make('Frequency')
383
+                    ->contained(false)
384
+                    ->schema(function (Forms\Get $get) {
385
+                        $frequency = Frequency::parse($get('frequency'));
386
+                        $intervalType = IntervalType::parse($get('interval_type'));
387
+                        $month = Month::parse($get('month'));
388
+                        $dayOfMonth = DayOfMonth::parse($get('day_of_month'));
389
+
390
+                        return [
391
+                            Forms\Components\Select::make('frequency')
392
+                                ->label('Repeats')
393
+                                ->options(Frequency::class)
394
+                                ->softRequired()
395
+                                ->live()
396
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
397
+                                    $handler = new ScheduleHandler($set);
398
+                                    $handler->handleFrequencyChange($state);
399
+                                }),
400
+
401
+                            // Custom frequency fields in a nested grid
402
+                            Cluster::make([
403
+                                Forms\Components\TextInput::make('interval_value')
404
+                                    ->softRequired()
405
+                                    ->numeric()
406
+                                    ->default(1),
407
+                                Forms\Components\Select::make('interval_type')
408
+                                    ->options(IntervalType::class)
409
+                                    ->softRequired()
410
+                                    ->default(IntervalType::Month)
411
+                                    ->live()
412
+                                    ->afterStateUpdated(function (Forms\Set $set, $state) {
413
+                                        $handler = new ScheduleHandler($set);
414
+                                        $handler->handleIntervalTypeChange($state);
415
+                                    }),
416
+                            ])
417
+                                ->live()
418
+                                ->label('Every')
419
+                                ->required()
420
+                                ->markAsRequired(false)
421
+                                ->visible($frequency->isCustom()),
422
+
423
+                            // Specific schedule details
424
+                            Forms\Components\Select::make('month')
425
+                                ->label('Month')
426
+                                ->options(Month::class)
427
+                                ->softRequired()
428
+                                ->visible($frequency->isYearly() || $intervalType?->isYear())
429
+                                ->live()
430
+                                ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
431
+                                    $handler = new ScheduleHandler($set, $get);
432
+                                    $handler->handleDateChange('month', $state);
433
+                                }),
434
+
435
+                            Forms\Components\Select::make('day_of_month')
436
+                                ->label('Day of Month')
437
+                                ->options(function () use ($month) {
438
+                                    if (! $month) {
439
+                                        return DayOfMonth::class;
440
+                                    }
441
+
442
+                                    $daysInMonth = Carbon::createFromDate(null, $month->value)->daysInMonth;
443
+
444
+                                    return collect(DayOfMonth::cases())
445
+                                        ->filter(static fn (DayOfMonth $dayOfMonth) => $dayOfMonth->value <= $daysInMonth || $dayOfMonth->isLast())
446
+                                        ->mapWithKeys(fn (DayOfMonth $dayOfMonth) => [$dayOfMonth->value => $dayOfMonth->getLabel()]);
447
+                                })
448
+                                ->softRequired()
449
+                                ->visible(in_array($frequency, [Frequency::Monthly, Frequency::Yearly]) || in_array($intervalType, [IntervalType::Month, IntervalType::Year]))
450
+                                ->live()
451
+                                ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
452
+                                    $handler = new ScheduleHandler($set, $get);
453
+                                    $handler->handleDateChange('day_of_month', $state);
454
+                                }),
455
+
456
+                            SimpleAlert::make('dayOfMonthNotice')
457
+                                ->title(function () use ($dayOfMonth) {
458
+                                    return "The invoice will be created on the {$dayOfMonth->getLabel()} day of each month, or on the last day for months ending earlier.";
459
+                                })
460
+                                ->columnSpanFull()
461
+                                ->visible($dayOfMonth?->value > 28),
462
+
463
+                            Forms\Components\Select::make('day_of_week')
464
+                                ->label('Day of Week')
465
+                                ->options(DayOfWeek::class)
466
+                                ->softRequired()
467
+                                ->visible($frequency->isWeekly() || $intervalType?->isWeek())
468
+                                ->live()
469
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
470
+                                    $handler = new ScheduleHandler($set);
471
+                                    $handler->handleDateChange('day_of_week', $state);
472
+                                }),
473
+                        ];
474
+                    })->columns(2),
475
+
476
+                CustomSection::make('Dates & Time')
477
+                    ->contained(false)
478
+                    ->schema([
479
+                        Forms\Components\DatePicker::make('start_date')
480
+                            ->label('First Invoice Date')
481
+                            ->softRequired()
482
+                            ->live()
483
+                            ->minDate(today())
484
+                            ->closeOnDateSelection()
485
+                            ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
486
+                                $handler = new ScheduleHandler($set, $get);
487
+                                $handler->handleDateChange('start_date', $state);
488
+                            }),
489
+
490
+                        Forms\Components\Group::make(function (Forms\Get $get) {
491
+                            $components = [];
492
+
493
+                            $components[] = Forms\Components\Select::make('end_type')
494
+                                ->label('End Schedule')
495
+                                ->options(EndType::class)
496
+                                ->softRequired()
497
+                                ->live()
498
+                                ->afterStateUpdated(function (Forms\Set $set, $state) {
499
+                                    $endType = EndType::parse($state);
500
+
501
+                                    $set('max_occurrences', $endType?->isAfter() ? 1 : null);
502
+                                    $set('end_date', $endType?->isOn() ? now()->addMonth()->startOfMonth() : null);
503
+                                });
504
+
505
+                            $endType = EndType::parse($get('end_type'));
506
+
507
+                            if ($endType?->isAfter()) {
508
+                                $components[] = Forms\Components\TextInput::make('max_occurrences')
509
+                                    ->numeric()
510
+                                    ->suffix('invoices')
511
+                                    ->live();
512
+                            }
513
+
514
+                            if ($endType?->isOn()) {
515
+                                $components[] = Forms\Components\DatePicker::make('end_date')
516
+                                    ->live();
517
+                            }
518
+
519
+                            return [
520
+                                Cluster::make($components)
521
+                                    ->label('Schedule Ends')
522
+                                    ->required()
523
+                                    ->markAsRequired(false),
524
+                            ];
525
+                        }),
526
+
527
+                        Forms\Components\Select::make('timezone')
528
+                            ->options(Timezone::getTimezoneOptions(CompanyProfile::first()->country))
529
+                            ->searchable()
530
+                            ->softRequired(),
531
+                    ])
532
+                    ->columns(2),
533
+            ])
534
+            ->action(function (self $record, array $data, MountableAction $action) {
535
+                $record->update($data);
536
+
537
+                $action->success();
538
+            });
539
+    }
540
+
541
+    public static function getApproveDraftAction(string $action = Action::class): MountableAction
542
+    {
543
+        return $action::make('approveDraft')
544
+            ->label('Approve')
545
+            ->icon('heroicon-o-check-circle')
546
+            ->visible(function (self $record) {
547
+                return $record->canBeApproved();
548
+            })
549
+            ->databaseTransaction()
550
+            ->successNotificationTitle('Recurring Invoice Approved')
551
+            ->action(function (self $record, MountableAction $action) {
552
+                $record->approveDraft();
553
+
554
+                $action->success();
555
+            });
556
+    }
557
+
558
+    public function approveDraft(?Carbon $approvedAt = null): void
559
+    {
560
+        if (! $this->isDraft()) {
561
+            throw new \RuntimeException('Invoice is not in draft status.');
562
+        }
563
+
564
+        $approvedAt ??= now();
565
+
566
+        $this->update([
567
+            'approved_at' => $approvedAt,
568
+            'status' => RecurringInvoiceStatus::Active,
569
+        ]);
570
+    }
571
+
572
+    public function generateInvoice(): ?Invoice
573
+    {
574
+        if (! $this->shouldGenerateInvoice()) {
575
+            return null;
576
+        }
577
+
578
+        $nextDate = $this->next_date ?? $this->calculateNextDate();
579
+
580
+        if (! $nextDate) {
581
+            return null;
582
+        }
583
+
584
+        $dueDate = $this->calculateNextDueDate();
585
+
586
+        $invoice = $this->invoices()->create([
587
+            'company_id' => $this->company_id,
588
+            'client_id' => $this->client_id,
589
+            'logo' => $this->logo,
590
+            'header' => $this->header,
591
+            'subheader' => $this->subheader,
592
+            'invoice_number' => Invoice::getNextDocumentNumber($this->company),
593
+            'date' => $nextDate,
594
+            'due_date' => $dueDate,
595
+            'status' => InvoiceStatus::Draft,
596
+            'currency_code' => $this->currency_code,
597
+            'discount_method' => $this->discount_method,
598
+            'discount_computation' => $this->discount_computation,
599
+            'discount_rate' => $this->discount_rate,
600
+            'subtotal' => $this->subtotal,
601
+            'tax_total' => $this->tax_total,
602
+            'discount_total' => $this->discount_total,
603
+            'total' => $this->total,
604
+            'terms' => $this->terms,
605
+            'footer' => $this->footer,
606
+            'created_by' => auth()->id(),
607
+            'updated_by' => auth()->id(),
608
+        ]);
609
+
610
+        $this->replicateLineItems($invoice);
611
+
612
+        $this->update([
613
+            'last_date' => $nextDate,
614
+            'next_date' => $this->calculateNextDate($nextDate),
615
+            'occurrences_count' => ($this->occurrences_count ?? 0) + 1,
616
+        ]);
617
+
618
+        return $invoice;
619
+    }
620
+
621
+    public function replicateLineItems(Model $target): void
622
+    {
623
+        $this->lineItems->each(function (DocumentLineItem $lineItem) use ($target) {
624
+            $replica = $lineItem->replicate([
625
+                'documentable_id',
626
+                'documentable_type',
627
+                'subtotal',
628
+                'total',
629
+                'created_by',
630
+                'updated_by',
631
+                'created_at',
632
+                'updated_at',
633
+            ]);
634
+
635
+            $replica->documentable_id = $target->id;
636
+            $replica->documentable_type = $target->getMorphClass();
637
+            $replica->save();
638
+
639
+            $replica->adjustments()->sync($lineItem->adjustments->pluck('id'));
640
+        });
641
+    }
642
+
643
+    public function shouldGenerateInvoice(): bool
644
+    {
645
+        if (! $this->isActive() || $this->hasReachedEnd()) {
646
+            return false;
647
+        }
648
+
649
+        $nextDate = $this->calculateNextDate();
650
+
651
+        if (! $nextDate || $nextDate->startOfDay()->isFuture()) {
652
+            return false;
653
+        }
654
+
655
+        return true;
656
+    }
657
+
658
+    public function generateDueInvoices(): void
659
+    {
660
+        $maxIterations = 100;
661
+
662
+        for ($i = 0; $i < $maxIterations; $i++) {
663
+            $result = $this->generateInvoice();
664
+
665
+            if (! $result) {
666
+                break;
667
+            }
668
+
669
+            $this->refresh();
670
+        }
671
+    }
672
+}

+ 5
- 0
app/Models/Company.php Visa fil

@@ -152,6 +152,11 @@ class Company extends FilamentCompaniesCompany implements HasAvatar
152 152
         return $this->hasMany(Accounting\Invoice::class, 'company_id');
153 153
     }
154 154
 
155
+    public function recurringInvoices(): HasMany
156
+    {
157
+        return $this->hasMany(Accounting\RecurringInvoice::class, 'company_id');
158
+    }
159
+
155 160
     public function locale(): HasOne
156 161
     {
157 162
         return $this->hasOne(Localization::class, 'company_id');

+ 57
- 0
app/Observers/RecurringInvoiceObserver.php Visa fil

@@ -0,0 +1,57 @@
1
+<?php
2
+
3
+namespace App\Observers;
4
+
5
+use App\Enums\Accounting\RecurringInvoiceStatus;
6
+use App\Models\Accounting\DocumentLineItem;
7
+use App\Models\Accounting\RecurringInvoice;
8
+use Illuminate\Support\Facades\DB;
9
+
10
+class RecurringInvoiceObserver
11
+{
12
+    public function saving(RecurringInvoice $recurringInvoice): void
13
+    {
14
+        if (
15
+            $recurringInvoice->wasApproved() &&
16
+            (($recurringInvoice->isDirty('start_date') && ! $recurringInvoice->last_date) || $this->otherScheduleDetailsChanged($recurringInvoice))
17
+        ) {
18
+            $recurringInvoice->next_date = $recurringInvoice->calculateNextDate();
19
+        }
20
+
21
+        if ($recurringInvoice->end_type?->isAfter() && $recurringInvoice->occurrences_count >= $recurringInvoice->max_occurrences) {
22
+            $recurringInvoice->status = RecurringInvoiceStatus::Ended;
23
+            $recurringInvoice->ended_at = now();
24
+        }
25
+    }
26
+
27
+    public function saved(RecurringInvoice $recurringInvoice): void
28
+    {
29
+        if ($recurringInvoice->wasChanged('status')) {
30
+            $recurringInvoice->generateDueInvoices();
31
+        }
32
+    }
33
+
34
+    protected function otherScheduleDetailsChanged(RecurringInvoice $recurringInvoice): bool
35
+    {
36
+        return $recurringInvoice->isDirty([
37
+            'frequency',
38
+            'interval_type',
39
+            'interval_value',
40
+            'month',
41
+            'day_of_month',
42
+            'day_of_week',
43
+            'end_type',
44
+            'max_occurrences',
45
+            'end_date',
46
+        ]);
47
+    }
48
+
49
+    public function deleted(RecurringInvoice $recurringInvoice): void
50
+    {
51
+        DB::transaction(function () use ($recurringInvoice) {
52
+            $recurringInvoice->lineItems()->each(function (DocumentLineItem $lineItem) {
53
+                $lineItem->delete();
54
+            });
55
+        });
56
+    }
57
+}

+ 2
- 0
app/Providers/FilamentCompaniesServiceProvider.php Visa fil

@@ -32,6 +32,7 @@ use App\Filament\Company\Resources\Purchases\VendorResource;
32 32
 use App\Filament\Company\Resources\Sales\ClientResource;
33 33
 use App\Filament\Company\Resources\Sales\EstimateResource;
34 34
 use App\Filament\Company\Resources\Sales\InvoiceResource;
35
+use App\Filament\Company\Resources\Sales\RecurringInvoiceResource;
35 36
 use App\Filament\Components\PanelShiftDropdown;
36 37
 use App\Filament\User\Clusters\Account;
37 38
 use App\Http\Middleware\ConfigureCurrentCompany;
@@ -132,6 +133,7 @@ class FilamentCompaniesServiceProvider extends PanelProvider
132 133
                             ->icon('heroicon-o-currency-dollar')
133 134
                             ->items([
134 135
                                 ...InvoiceResource::getNavigationItems(),
136
+                                ...RecurringInvoiceResource::getNavigationItems(),
135 137
                                 ...EstimateResource::getNavigationItems(),
136 138
                                 ...ClientResource::getNavigationItems(),
137 139
                             ]),

+ 273
- 0
app/Support/ScheduleHandler.php Visa fil

@@ -0,0 +1,273 @@
1
+<?php
2
+
3
+namespace App\Support;
4
+
5
+use App\Enums\Accounting\DayOfMonth;
6
+use App\Enums\Accounting\DayOfWeek;
7
+use App\Enums\Accounting\Frequency;
8
+use App\Enums\Accounting\IntervalType;
9
+use App\Enums\Accounting\Month;
10
+use Carbon\CarbonImmutable;
11
+use Filament\Forms\Get;
12
+use Filament\Forms\Set;
13
+use Illuminate\Support\Carbon;
14
+
15
+class ScheduleHandler
16
+{
17
+    protected CarbonImmutable $today;
18
+
19
+    protected Set $set;
20
+
21
+    protected ?Get $get;
22
+
23
+    public function __construct(Set $set, ?Get $get = null)
24
+    {
25
+        $this->today = today()->toImmutable();
26
+        $this->set = $set;
27
+        $this->get = $get;
28
+    }
29
+
30
+    protected function setMany(Set $set, array $values): void
31
+    {
32
+        foreach ($values as $key => $value) {
33
+            $set($key, $value);
34
+        }
35
+    }
36
+
37
+    public function handleFrequencyChange(mixed $state): void
38
+    {
39
+        $frequency = Frequency::parse($state);
40
+
41
+        match (true) {
42
+            $frequency->isDaily() => $this->handleDaily(),
43
+            $frequency->isWeekly() => $this->handleWeekly(),
44
+            $frequency->isMonthly() => $this->handleMonthly(),
45
+            $frequency->isYearly() => $this->handleYearly(),
46
+            $frequency->isCustom() => $this->handleCustom(),
47
+            default => null,
48
+        };
49
+    }
50
+
51
+    public function handleIntervalTypeChange(mixed $state): void
52
+    {
53
+        $intervalType = IntervalType::parse($state);
54
+
55
+        match (true) {
56
+            $intervalType->isWeek() => $this->handleWeeklyInterval(),
57
+            $intervalType->isMonth() => $this->handleMonthlyInterval(),
58
+            $intervalType->isYear() => $this->handleYearlyInterval(),
59
+            default => null,
60
+        };
61
+    }
62
+
63
+    public function handleDateChange(?string $component, mixed $state): void
64
+    {
65
+        match ($component) {
66
+            'start_date' => $this->syncComponentsToStartDate(Carbon::parse($state)),
67
+            'month' => $this->handleMonthChange($state),
68
+            'day_of_month' => $this->handleDayOfMonthChange($state),
69
+            'day_of_week' => $this->handleDayOfWeekChange($state),
70
+            default => null,
71
+        };
72
+    }
73
+
74
+    protected function handleDaily(): void
75
+    {
76
+        $this->setMany($this->set, [
77
+            'interval_value' => null,
78
+            'interval_type' => null,
79
+            'day_of_month' => null,
80
+            'start_date' => $this->today,
81
+        ]);
82
+    }
83
+
84
+    protected function handleWeekly(): void
85
+    {
86
+        $currentDayOfWeek = DayOfWeek::parse($this->today->dayOfWeek);
87
+
88
+        $this->setMany($this->set, [
89
+            'day_of_week' => $currentDayOfWeek,
90
+            'start_date' => $this->today,
91
+            'interval_value' => null,
92
+            'interval_type' => null,
93
+            'day_of_month' => null,
94
+        ]);
95
+    }
96
+
97
+    protected function handleMonthly(): void
98
+    {
99
+        $dayOfMonth = DayOfMonth::First;
100
+        $date = $dayOfMonth->resolveDate($this->today);
101
+
102
+        $adjustedStartDate = $date->lt($this->today)
103
+            ? $dayOfMonth->resolveDate($date->addMonth())
104
+            : $dayOfMonth->resolveDate($date);
105
+
106
+        $this->setMany($this->set, [
107
+            'month' => null,
108
+            'day_of_month' => $dayOfMonth,
109
+            'start_date' => $adjustedStartDate,
110
+            'interval_value' => null,
111
+            'interval_type' => null,
112
+        ]);
113
+    }
114
+
115
+    protected function handleYearly(): void
116
+    {
117
+        $currentMonth = Month::parse($this->today->month);
118
+        $currentDayOfMonth = DayOfMonth::parse($this->today->day);
119
+
120
+        $this->setMany($this->set, [
121
+            'month' => $currentMonth,
122
+            'day_of_month' => $currentDayOfMonth,
123
+            'start_date' => $this->today,
124
+            'interval_value' => null,
125
+            'interval_type' => null,
126
+        ]);
127
+    }
128
+
129
+    protected function handleCustom(): void
130
+    {
131
+        $dayOfMonth = DayOfMonth::First;
132
+        $date = $dayOfMonth->resolveDate($this->today);
133
+
134
+        $adjustedStartDate = $date->lt($this->today)
135
+            ? $dayOfMonth->resolveDate($date->addMonth())
136
+            : $dayOfMonth->resolveDate($date);
137
+
138
+        $this->setMany($this->set, [
139
+            'interval_value' => 1,
140
+            'interval_type' => IntervalType::Month,
141
+            'month' => null,
142
+            'day_of_month' => $dayOfMonth,
143
+            'start_date' => $adjustedStartDate,
144
+        ]);
145
+    }
146
+
147
+    protected function handleWeeklyInterval(): void
148
+    {
149
+        $currentDayOfWeek = DayOfWeek::parse($this->today->dayOfWeek);
150
+
151
+        $this->setMany($this->set, [
152
+            'day_of_week' => $currentDayOfWeek,
153
+            'start_date' => $this->today,
154
+        ]);
155
+    }
156
+
157
+    protected function handleMonthlyInterval(): void
158
+    {
159
+        $dayOfMonth = DayOfMonth::First;
160
+        $date = $dayOfMonth->resolveDate($this->today);
161
+
162
+        $adjustedStartDate = $date->lt($this->today)
163
+            ? $dayOfMonth->resolveDate($date->addMonth())
164
+            : $dayOfMonth->resolveDate($date);
165
+
166
+        $this->setMany($this->set, [
167
+            'month' => null,
168
+            'day_of_month' => $dayOfMonth,
169
+            'start_date' => $adjustedStartDate,
170
+        ]);
171
+    }
172
+
173
+    protected function handleYearlyInterval(): void
174
+    {
175
+        $currentMonth = Month::parse($this->today->month);
176
+        $currentDayOfMonth = DayOfMonth::parse($this->today->day);
177
+
178
+        $this->setMany($this->set, [
179
+            'month' => $currentMonth,
180
+            'day_of_month' => $currentDayOfMonth,
181
+            'start_date' => $this->today,
182
+        ]);
183
+    }
184
+
185
+    protected function syncComponentsToStartDate(Carbon $startDate): void
186
+    {
187
+        $frequency = Frequency::parse(($this->get)('frequency'));
188
+        $intervalType = IntervalType::parse(($this->get)('interval_type'));
189
+
190
+        if ($frequency->isWeekly() || $intervalType?->isWeek()) {
191
+            ($this->set)('day_of_week', DayOfWeek::parse($startDate->dayOfWeek));
192
+        }
193
+
194
+        if ($frequency->isMonthly() || $intervalType?->isMonth() ||
195
+            $frequency->isYearly() || $intervalType?->isYear()) {
196
+            ($this->set)('day_of_month', $startDate->day);
197
+        }
198
+
199
+        if ($frequency->isYearly() || $intervalType?->isYear()) {
200
+            ($this->set)('month', Month::parse($startDate->month));
201
+        }
202
+    }
203
+
204
+    protected function handleMonthChange(mixed $state): void
205
+    {
206
+        if (! $this->get) {
207
+            return;
208
+        }
209
+
210
+        $dayOfMonth = DayOfMonth::parse(($this->get)('day_of_month'));
211
+        $frequency = Frequency::parse(($this->get)('frequency'));
212
+        $intervalType = IntervalType::parse(($this->get)('interval_type'));
213
+        $month = Month::parse($state);
214
+
215
+        if (($frequency->isYearly() || $intervalType?->isYear()) && $month && $dayOfMonth) {
216
+            $date = $dayOfMonth->resolveDate($this->today->month($month->value));
217
+
218
+            $adjustedStartDate = $date->lt($this->today)
219
+                ? $dayOfMonth->resolveDate($date->addYear()->month($month->value))
220
+                : $dayOfMonth->resolveDate($date->month($month->value));
221
+
222
+            $adjustedDay = min($dayOfMonth->value, $adjustedStartDate->daysInMonth);
223
+
224
+            $this->setMany($this->set, [
225
+                'day_of_month' => $adjustedDay,
226
+                'start_date' => $adjustedStartDate,
227
+            ]);
228
+        }
229
+    }
230
+
231
+    protected function handleDayOfMonthChange(mixed $state): void
232
+    {
233
+        if (! $this->get) {
234
+            return;
235
+        }
236
+
237
+        $dayOfMonth = DayOfMonth::parse($state);
238
+        $frequency = Frequency::parse(($this->get)('frequency'));
239
+        $intervalType = IntervalType::parse(($this->get)('interval_type'));
240
+        $month = Month::parse(($this->get)('month'));
241
+
242
+        if (($frequency->isMonthly() || $intervalType?->isMonth()) && $dayOfMonth) {
243
+            $date = $dayOfMonth->resolveDate($this->today);
244
+
245
+            $adjustedStartDate = $date->lt($this->today)
246
+                ? $dayOfMonth->resolveDate($date->addMonth())
247
+                : $dayOfMonth->resolveDate($date);
248
+
249
+            ($this->set)('start_date', $adjustedStartDate);
250
+        }
251
+
252
+        if (($frequency->isYearly() || $intervalType?->isYear()) && $month && $dayOfMonth) {
253
+            $date = $dayOfMonth->resolveDate($this->today->month($month->value));
254
+
255
+            $adjustedStartDate = $date->lt($this->today)
256
+                ? $dayOfMonth->resolveDate($date->addYear()->month($month->value))
257
+                : $dayOfMonth->resolveDate($date->month($month->value));
258
+
259
+            ($this->set)('start_date', $adjustedStartDate);
260
+        }
261
+    }
262
+
263
+    protected function handleDayOfWeekChange(mixed $state): void
264
+    {
265
+        $dayOfWeek = DayOfWeek::parse($state);
266
+
267
+        $adjustedStartDate = $this->today->is($dayOfWeek->name)
268
+            ? $this->today
269
+            : $this->today->next($dayOfWeek->name);
270
+
271
+        ($this->set)('start_date', $adjustedStartDate);
272
+    }
273
+}

+ 0
- 118
app/View/Models/DocumentPreviewViewModel.php Visa fil

@@ -1,118 +0,0 @@
1
-<?php
2
-
3
-namespace App\View\Models;
4
-
5
-use App\Enums\Accounting\DocumentType;
6
-use App\Models\Accounting\DocumentLineItem;
7
-use App\Models\Common\Client;
8
-use App\Models\Company;
9
-use App\Models\Setting\DocumentDefault;
10
-use App\Utilities\Currency\CurrencyAccessor;
11
-use App\Utilities\Currency\CurrencyConverter;
12
-use Illuminate\Database\Eloquent\Model;
13
-
14
-class DocumentPreviewViewModel
15
-{
16
-    public function __construct(
17
-        public Model $document,
18
-        public DocumentType $documentType = DocumentType::Invoice,
19
-    ) {}
20
-
21
-    public function buildViewData(): array
22
-    {
23
-        return [
24
-            'company' => $this->getCompanyDetails(),
25
-            'client' => $this->getClientDetails(),
26
-            'metadata' => $this->getDocumentMetadata(),
27
-            'lineItems' => $this->getLineItems(),
28
-            'totals' => $this->getTotals(),
29
-            'header' => $this->document->header,
30
-            'footer' => $this->document->footer,
31
-            'terms' => $this->document->terms,
32
-            'logo' => $this->document->logo,
33
-            'style' => $this->getStyle(),
34
-            'labels' => $this->documentType->getLabels(),
35
-        ];
36
-    }
37
-
38
-    private function getCompanyDetails(): array
39
-    {
40
-        /** @var Company $company */
41
-        $company = $this->document->company;
42
-        $profile = $company->profile;
43
-
44
-        return [
45
-            'name' => $company->name,
46
-            'address' => $profile->address ?? '',
47
-            'city' => $profile->city?->name ?? '',
48
-            'state' => $profile->state?->name ?? '',
49
-            'zip_code' => $profile->zip_code ?? '',
50
-            'country' => $profile->state?->country->name ?? '',
51
-        ];
52
-    }
53
-
54
-    private function getClientDetails(): array
55
-    {
56
-        /** @var Client $client */
57
-        $client = $this->document->client;
58
-        $address = $client->billingAddress ?? null;
59
-
60
-        return [
61
-            'name' => $client->name,
62
-            'address_line_1' => $address->address_line_1 ?? '',
63
-            'address_line_2' => $address->address_line_2 ?? '',
64
-            'city' => $address->city ?? '',
65
-            'state' => $address->state ?? '',
66
-            'postal_code' => $address->postal_code ?? '',
67
-            'country' => $address->country ?? '',
68
-        ];
69
-    }
70
-
71
-    private function getDocumentMetadata(): array
72
-    {
73
-        return [
74
-            'number' => $this->document->invoice_number ?? $this->document->estimate_number,
75
-            'reference_number' => $this->document->order_number ?? $this->document->reference_number,
76
-            'date' => $this->document->date?->toDefaultDateFormat(),
77
-            'due_date' => $this->document->due_date?->toDefaultDateFormat() ?? $this->document->expiration_date?->toDefaultDateFormat(),
78
-            'currency_code' => $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency(),
79
-        ];
80
-    }
81
-
82
-    private function getLineItems(): array
83
-    {
84
-        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
85
-
86
-        return $this->document->lineItems->map(fn (DocumentLineItem $item) => [
87
-            'name' => $item->offering->name ?? '',
88
-            'description' => $item->description ?? '',
89
-            'quantity' => $item->quantity,
90
-            'unit_price' => CurrencyConverter::formatToMoney($item->unit_price, $currencyCode),
91
-            'subtotal' => CurrencyConverter::formatToMoney($item->subtotal, $currencyCode),
92
-        ])->toArray();
93
-    }
94
-
95
-    private function getTotals(): array
96
-    {
97
-        $currencyCode = $this->document->currency_code ?? CurrencyAccessor::getDefaultCurrency();
98
-
99
-        return [
100
-            'subtotal' => CurrencyConverter::formatToMoney($this->document->subtotal, $currencyCode),
101
-            'discount' => CurrencyConverter::formatToMoney($this->document->discount_total, $currencyCode),
102
-            'tax' => CurrencyConverter::formatToMoney($this->document->tax_total, $currencyCode),
103
-            'total' => CurrencyConverter::formatToMoney($this->document->total, $currencyCode),
104
-            'amount_due' => $this->document->amount_due ? CurrencyConverter::formatToMoney($this->document->amount_due, $currencyCode) : null,
105
-        ];
106
-    }
107
-
108
-    private function getStyle(): array
109
-    {
110
-        /** @var DocumentDefault $settings */
111
-        $settings = $this->document->company->defaultInvoice;
112
-
113
-        return [
114
-            'accent_color' => $settings->accent_color ?? '#000000',
115
-            'show_logo' => $settings->show_logo ?? false,
116
-        ];
117
-    }
118
-}

+ 2
- 0
composer.json Visa fil

@@ -17,6 +17,7 @@
17 17
         "andrewdwallo/transmatic": "^1.1",
18 18
         "awcodes/filament-table-repeater": "^3.0",
19 19
         "barryvdh/laravel-snappy": "^1.0",
20
+        "codewithdennis/filament-simple-alert": "^3.0",
20 21
         "filament/filament": "^3.2",
21 22
         "guava/filament-clusters": "^1.1",
22 23
         "guzzlehttp/guzzle": "^7.8",
@@ -24,6 +25,7 @@
24 25
         "laravel/framework": "^11.0",
25 26
         "laravel/sanctum": "^4.0",
26 27
         "laravel/tinker": "^2.9",
28
+        "spatie/laravel-view-models": "^1.6",
27 29
         "squirephp/model": "^3.4",
28 30
         "squirephp/repository": "^3.4",
29 31
         "symfony/intl": "^6.3"

+ 304
- 160
composer.lock
Filskillnaden har hållits tillbaka eftersom den är för stor
Visa fil


+ 2
- 1
database/factories/Accounting/DocumentLineItemFactory.php Visa fil

@@ -6,6 +6,7 @@ use App\Models\Accounting\Bill;
6 6
 use App\Models\Accounting\DocumentLineItem;
7 7
 use App\Models\Accounting\Estimate;
8 8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Accounting\RecurringInvoice;
9 10
 use App\Models\Common\Offering;
10 11
 use Illuminate\Database\Eloquent\Factories\Factory;
11 12
 
@@ -37,7 +38,7 @@ class DocumentLineItemFactory extends Factory
37 38
         ];
38 39
     }
39 40
 
40
-    public function forInvoice(Invoice $invoice): static
41
+    public function forInvoice(Invoice | RecurringInvoice $invoice): static
41 42
     {
42 43
         return $this
43 44
             ->for($invoice, 'documentable')

+ 288
- 0
database/factories/Accounting/RecurringInvoiceFactory.php Visa fil

@@ -0,0 +1,288 @@
1
+<?php
2
+
3
+namespace Database\Factories\Accounting;
4
+
5
+use App\Enums\Accounting\DayOfMonth;
6
+use App\Enums\Accounting\DayOfWeek;
7
+use App\Enums\Accounting\EndType;
8
+use App\Enums\Accounting\Frequency;
9
+use App\Enums\Accounting\IntervalType;
10
+use App\Enums\Accounting\Month;
11
+use App\Enums\Accounting\RecurringInvoiceStatus;
12
+use App\Enums\Setting\PaymentTerms;
13
+use App\Models\Accounting\DocumentLineItem;
14
+use App\Models\Accounting\RecurringInvoice;
15
+use App\Models\Common\Client;
16
+use Illuminate\Database\Eloquent\Factories\Factory;
17
+use Illuminate\Support\Carbon;
18
+
19
+/**
20
+ * @extends Factory<RecurringInvoice>
21
+ */
22
+class RecurringInvoiceFactory extends Factory
23
+{
24
+    /**
25
+     * The name of the factory's corresponding model.
26
+     */
27
+    protected $model = RecurringInvoice::class;
28
+
29
+    /**
30
+     * Define the model's default state.
31
+     *
32
+     * @return array<string, mixed>
33
+     */
34
+    public function definition(): array
35
+    {
36
+        return [
37
+            'company_id' => 1,
38
+            'client_id' => Client::inRandomOrder()->value('id'),
39
+            'header' => 'Invoice',
40
+            'subheader' => 'Invoice',
41
+            'order_number' => $this->faker->unique()->numerify('ORD-#####'),
42
+            'payment_terms' => PaymentTerms::Net30,
43
+            'status' => RecurringInvoiceStatus::Draft,
44
+            'currency_code' => 'USD',
45
+            'terms' => $this->faker->sentence,
46
+            'footer' => $this->faker->sentence,
47
+            'created_by' => 1,
48
+            'updated_by' => 1,
49
+        ];
50
+    }
51
+
52
+    public function withLineItems(int $count = 3): static
53
+    {
54
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) use ($count) {
55
+            DocumentLineItem::factory()
56
+                ->count($count)
57
+                ->forInvoice($recurringInvoice)
58
+                ->create();
59
+
60
+            $this->recalculateTotals($recurringInvoice);
61
+        });
62
+    }
63
+
64
+    public function withSchedule(
65
+        ?Frequency $frequency = null,
66
+        ?Carbon $startDate = null,
67
+        ?EndType $endType = null
68
+    ): static {
69
+        $frequency ??= $this->faker->randomElement(Frequency::class);
70
+        $endType ??= EndType::Never;
71
+
72
+        // Adjust the start date range based on frequency
73
+        $startDate = match ($frequency) {
74
+            Frequency::Daily => Carbon::parse($this->faker->dateTimeBetween('-30 days')), // At most 30 days back
75
+            default => $startDate ?? Carbon::parse($this->faker->dateTimeBetween('-1 year')),
76
+        };
77
+
78
+        return match ($frequency) {
79
+            Frequency::Daily => $this->withDailySchedule($startDate, $endType),
80
+            Frequency::Weekly => $this->withWeeklySchedule($startDate, $endType),
81
+            Frequency::Monthly => $this->withMonthlySchedule($startDate, $endType),
82
+            Frequency::Yearly => $this->withYearlySchedule($startDate, $endType),
83
+            Frequency::Custom => $this->withCustomSchedule($startDate, $endType),
84
+        };
85
+    }
86
+
87
+    protected function withDailySchedule(Carbon $startDate, EndType $endType): static
88
+    {
89
+        return $this->state([
90
+            'frequency' => Frequency::Daily,
91
+            'start_date' => $startDate,
92
+            'end_type' => $endType,
93
+        ]);
94
+    }
95
+
96
+    protected function withWeeklySchedule(Carbon $startDate, EndType $endType): static
97
+    {
98
+        return $this->state([
99
+            'frequency' => Frequency::Weekly,
100
+            'day_of_week' => DayOfWeek::from($startDate->dayOfWeek),
101
+            'start_date' => $startDate,
102
+            'end_type' => $endType,
103
+        ]);
104
+    }
105
+
106
+    protected function withMonthlySchedule(Carbon $startDate, EndType $endType): static
107
+    {
108
+        return $this->state([
109
+            'frequency' => Frequency::Monthly,
110
+            'day_of_month' => DayOfMonth::from($startDate->day),
111
+            'start_date' => $startDate,
112
+            'end_type' => $endType,
113
+        ]);
114
+    }
115
+
116
+    protected function withYearlySchedule(Carbon $startDate, EndType $endType): static
117
+    {
118
+        return $this->state([
119
+            'frequency' => Frequency::Yearly,
120
+            'month' => Month::from($startDate->month),
121
+            'day_of_month' => DayOfMonth::from($startDate->day),
122
+            'start_date' => $startDate,
123
+            'end_type' => $endType,
124
+        ]);
125
+    }
126
+
127
+    protected function withCustomSchedule(
128
+        Carbon $startDate,
129
+        EndType $endType,
130
+        ?IntervalType $intervalType = null,
131
+        ?int $intervalValue = null
132
+    ): static {
133
+        $intervalType ??= $this->faker->randomElement(IntervalType::class);
134
+        $intervalValue ??= match ($intervalType) {
135
+            IntervalType::Day => $this->faker->numberBetween(1, 7),
136
+            IntervalType::Week => $this->faker->numberBetween(1, 4),
137
+            IntervalType::Month => $this->faker->numberBetween(1, 3),
138
+            IntervalType::Year => 1,
139
+        };
140
+
141
+        $state = [
142
+            'frequency' => Frequency::Custom,
143
+            'interval_type' => $intervalType,
144
+            'interval_value' => $intervalValue,
145
+            'start_date' => $startDate,
146
+            'end_type' => $endType,
147
+        ];
148
+
149
+        // Add interval-specific attributes
150
+        switch ($intervalType) {
151
+            case IntervalType::Day:
152
+                // No additional attributes needed
153
+                break;
154
+
155
+            case IntervalType::Week:
156
+                $state['day_of_week'] = DayOfWeek::from($startDate->dayOfWeek);
157
+
158
+                break;
159
+
160
+            case IntervalType::Month:
161
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
162
+
163
+                break;
164
+
165
+            case IntervalType::Year:
166
+                $state['month'] = Month::from($startDate->month);
167
+                $state['day_of_month'] = DayOfMonth::from($startDate->day);
168
+
169
+                break;
170
+        }
171
+
172
+        return $this->state($state);
173
+    }
174
+
175
+    public function endAfter(int $occurrences = 12): static
176
+    {
177
+        return $this->state([
178
+            'end_type' => EndType::After,
179
+            'max_occurrences' => $occurrences,
180
+        ]);
181
+    }
182
+
183
+    public function endOn(?Carbon $endDate = null): static
184
+    {
185
+        $endDate ??= now()->addMonths($this->faker->numberBetween(1, 12));
186
+
187
+        return $this->state([
188
+            'end_type' => EndType::On,
189
+            'end_date' => $endDate,
190
+        ]);
191
+    }
192
+
193
+    public function autoSend(string $sendTime = '09:00'): static
194
+    {
195
+        return $this->state([
196
+            'auto_send' => true,
197
+            'send_time' => $sendTime,
198
+        ]);
199
+    }
200
+
201
+    public function approved(): static
202
+    {
203
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
204
+            $this->ensureLineItems($recurringInvoice);
205
+
206
+            if (! $recurringInvoice->hasSchedule()) {
207
+                $this->withSchedule()->callAfterCreating(collect([$recurringInvoice]));
208
+                $recurringInvoice->refresh();
209
+            }
210
+
211
+            if (! $recurringInvoice->canBeApproved()) {
212
+                return;
213
+            }
214
+
215
+            $approvedAt = $recurringInvoice->start_date
216
+                ? $recurringInvoice->start_date->copy()->subDays($this->faker->numberBetween(1, 7))
217
+                : now()->subDays($this->faker->numberBetween(1, 30));
218
+
219
+            $recurringInvoice->approveDraft($approvedAt);
220
+        });
221
+    }
222
+
223
+    public function active(): static
224
+    {
225
+        return $this->withLineItems()
226
+            ->withSchedule()
227
+            ->approved();
228
+    }
229
+
230
+    public function ended(): static
231
+    {
232
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
233
+            if (! $recurringInvoice->canBeEnded()) {
234
+                $this->active()->callAfterCreating(collect([$recurringInvoice]));
235
+            }
236
+
237
+            $endedAt = now()->subDays($this->faker->numberBetween(1, 30));
238
+
239
+            $recurringInvoice->update([
240
+                'ended_at' => $endedAt,
241
+                'status' => RecurringInvoiceStatus::Ended,
242
+            ]);
243
+        });
244
+    }
245
+
246
+    public function configure(): static
247
+    {
248
+        return $this->afterCreating(function (RecurringInvoice $recurringInvoice) {
249
+            $this->ensureLineItems($recurringInvoice);
250
+
251
+            $nextDate = $recurringInvoice->calculateNextDate();
252
+
253
+            if ($nextDate) {
254
+                $recurringInvoice->updateQuietly([
255
+                    'next_date' => $nextDate,
256
+                ]);
257
+            }
258
+        });
259
+    }
260
+
261
+    protected function ensureLineItems(RecurringInvoice $recurringInvoice): void
262
+    {
263
+        if (! $recurringInvoice->hasLineItems()) {
264
+            $this->withLineItems()->callAfterCreating(collect([$recurringInvoice]));
265
+        }
266
+    }
267
+
268
+    protected function recalculateTotals(RecurringInvoice $recurringInvoice): void
269
+    {
270
+        $recurringInvoice->refresh();
271
+
272
+        if (! $recurringInvoice->hasLineItems()) {
273
+            return;
274
+        }
275
+
276
+        $subtotal = $recurringInvoice->lineItems()->sum('subtotal') / 100;
277
+        $taxTotal = $recurringInvoice->lineItems()->sum('tax_total') / 100;
278
+        $discountTotal = $recurringInvoice->lineItems()->sum('discount_total') / 100;
279
+        $grandTotal = $subtotal + $taxTotal - $discountTotal;
280
+
281
+        $recurringInvoice->update([
282
+            'subtotal' => $subtotal,
283
+            'tax_total' => $taxTotal,
284
+            'discount_total' => $discountTotal,
285
+            'total' => $grandTotal,
286
+        ]);
287
+    }
288
+}

+ 81
- 0
database/factories/CompanyFactory.php Visa fil

@@ -2,9 +2,11 @@
2 2
 
3 3
 namespace Database\Factories;
4 4
 
5
+use App\Enums\Accounting\Frequency;
5 6
 use App\Models\Accounting\Bill;
6 7
 use App\Models\Accounting\Estimate;
7 8
 use App\Models\Accounting\Invoice;
9
+use App\Models\Accounting\RecurringInvoice;
8 10
 use App\Models\Accounting\Transaction;
9 11
 use App\Models\Common\Client;
10 12
 use App\Models\Common\Offering;
@@ -164,6 +166,85 @@ class CompanyFactory extends Factory
164 166
         });
165 167
     }
166 168
 
169
+    public function withRecurringInvoices(int $count = 10): self
170
+    {
171
+        return $this->afterCreating(function (Company $company) use ($count) {
172
+            $draftCount = (int) floor($count * 0.2);     // 20% drafts without schedule
173
+            $scheduledCount = (int) floor($count * 0.2);  // 20% drafts with schedule
174
+            $activeCount = (int) floor($count * 0.4);     // 40% active and generating
175
+            $endedCount = (int) floor($count * 0.1);      // 10% manually ended
176
+            $completedCount = $count - ($draftCount + $scheduledCount + $activeCount + $endedCount); // 10% completed by end conditions
177
+
178
+            // Draft recurring invoices (no schedule)
179
+            RecurringInvoice::factory()
180
+                ->count($draftCount)
181
+                ->withLineItems()
182
+                ->create([
183
+                    'company_id' => $company->id,
184
+                    'created_by' => $company->user_id,
185
+                    'updated_by' => $company->user_id,
186
+                ]);
187
+
188
+            // Draft recurring invoices with schedule
189
+            RecurringInvoice::factory()
190
+                ->count($scheduledCount)
191
+                ->withLineItems()
192
+                ->withSchedule()
193
+                ->create([
194
+                    'company_id' => $company->id,
195
+                    'created_by' => $company->user_id,
196
+                    'updated_by' => $company->user_id,
197
+                ]);
198
+
199
+            // Active recurring invoices with various schedules and historical invoices
200
+            $frequencies = [
201
+                Frequency::Daily,
202
+                Frequency::Weekly,
203
+                Frequency::Monthly,
204
+                Frequency::Yearly,
205
+                Frequency::Custom,
206
+            ];
207
+
208
+            foreach (array_chunk(range(1, $activeCount), (int) ceil($activeCount / count($frequencies))) as $chunk) {
209
+                RecurringInvoice::factory()
210
+                    ->count(count($chunk))
211
+                    ->withLineItems()
212
+                    ->withSchedule(fake()->randomElement($frequencies)) // Randomize frequency
213
+                    ->active()
214
+                    ->create([
215
+                        'company_id' => $company->id,
216
+                        'created_by' => $company->user_id,
217
+                        'updated_by' => $company->user_id,
218
+                    ]);
219
+            }
220
+
221
+            // Manually ended recurring invoices
222
+            RecurringInvoice::factory()
223
+                ->count($endedCount)
224
+                ->withLineItems()
225
+                ->withSchedule()
226
+                ->ended()
227
+                ->create([
228
+                    'company_id' => $company->id,
229
+                    'created_by' => $company->user_id,
230
+                    'updated_by' => $company->user_id,
231
+                ]);
232
+
233
+            // Completed recurring invoices (reached end conditions)
234
+            RecurringInvoice::factory()
235
+                ->count($completedCount)
236
+                ->withLineItems()
237
+                ->withSchedule()
238
+                ->endAfter($this->faker->numberBetween(5, 12))
239
+                ->active()
240
+                ->create([
241
+                    'company_id' => $company->id,
242
+                    'created_by' => $company->user_id,
243
+                    'updated_by' => $company->user_id,
244
+                ]);
245
+        });
246
+    }
247
+
167 248
     public function withEstimates(int $count = 10): self
168 249
     {
169 250
         return $this->afterCreating(function (Company $company) use ($count) {

+ 65
- 0
database/migrations/2024_11_27_223001_create_recurring_invoices_table.php Visa fil

@@ -0,0 +1,65 @@
1
+<?php
2
+
3
+use Illuminate\Database\Migrations\Migration;
4
+use Illuminate\Database\Schema\Blueprint;
5
+use Illuminate\Support\Facades\Schema;
6
+
7
+return new class extends Migration
8
+{
9
+    /**
10
+     * Run the migrations.
11
+     */
12
+    public function up(): void
13
+    {
14
+        Schema::create('recurring_invoices', function (Blueprint $table) {
15
+            $table->id();
16
+            $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18
+            $table->string('logo')->nullable();
19
+            $table->string('header')->nullable();
20
+            $table->string('subheader')->nullable();
21
+            $table->string('order_number')->nullable(); // PO, SO, etc.
22
+            $table->string('payment_terms')->default('due_upon_receipt');
23
+            $table->timestamp('approved_at')->nullable();
24
+            $table->timestamp('ended_at')->nullable();
25
+            $table->string('frequency')->default('monthly'); // daily, weekly, monthly, yearly, custom
26
+            $table->string('interval_type')->nullable(); // for custom frequency "day(s), week(s), month(s), year(s)"
27
+            $table->tinyInteger('interval_value')->nullable(); // "every x" value (only for custom frequency)
28
+            $table->tinyInteger('month')->nullable(); // 1-12 for yearly frequency
29
+            $table->tinyInteger('day_of_month')->nullable(); // 1-31 for monthly, yearly, custom yearly frequency
30
+            $table->tinyInteger('day_of_week')->nullable(); // 1-7 for weekly, custom weekly frequency
31
+            $table->date('start_date')->nullable();
32
+            $table->string('end_type')->default('never'); // never, after, on
33
+            $table->smallInteger('max_occurrences')->nullable(); // when end_type is 'after'
34
+            $table->date('end_date')->nullable(); // when end_type is 'on'
35
+            $table->smallInteger('occurrences_count')->default(0);
36
+            $table->string('timezone')->default(config('app.timezone'));
37
+            $table->date('next_date')->nullable();
38
+            $table->date('last_date')->nullable();
39
+            $table->boolean('auto_send')->default(false);
40
+            $table->time('send_time')->default('09:00:00');
41
+            $table->string('status')->default('draft');
42
+            $table->string('currency_code')->nullable();
43
+            $table->string('discount_method')->default('per_line_item');
44
+            $table->string('discount_computation')->default('percentage');
45
+            $table->integer('discount_rate')->default(0);
46
+            $table->integer('subtotal')->default(0);
47
+            $table->integer('tax_total')->default(0);
48
+            $table->integer('discount_total')->default(0);
49
+            $table->integer('total')->default(0);
50
+            $table->text('terms')->nullable(); // terms, notes
51
+            $table->text('footer')->nullable();
52
+            $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
53
+            $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
54
+            $table->timestamps();
55
+        });
56
+    }
57
+
58
+    /**
59
+     * Reverse the migrations.
60
+     */
61
+    public function down(): void
62
+    {
63
+        Schema::dropIfExists('recurring_invoices');
64
+    }
65
+};

+ 1
- 0
database/migrations/2024_11_27_223015_create_invoices_table.php Visa fil

@@ -16,6 +16,7 @@ return new class extends Migration
16 16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17 17
             $table->foreignId('client_id')->nullable()->constrained('clients')->nullOnDelete();
18 18
             $table->foreignId('estimate_id')->nullable()->constrained('estimates')->nullOnDelete();
19
+            $table->foreignId('recurring_invoice_id')->nullable()->constrained('recurring_invoices')->nullOnDelete();
19 20
             $table->string('logo')->nullable();
20 21
             $table->string('header')->nullable();
21 22
             $table->string('subheader')->nullable();

+ 1
- 0
database/seeders/DatabaseSeeder.php Visa fil

@@ -25,6 +25,7 @@ class DatabaseSeeder extends Seeder
25 25
                     ->withClients()
26 26
                     ->withVendors()
27 27
                     ->withInvoices(50)
28
+                    ->withRecurringInvoices()
28 29
                     ->withEstimates(50)
29 30
                     ->withBills(50);
30 31
             })

+ 110
- 110
package-lock.json Visa fil

@@ -575,9 +575,9 @@
575 575
             }
576 576
         },
577 577
         "node_modules/@rollup/rollup-android-arm-eabi": {
578
-            "version": "4.29.1",
579
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz",
580
-            "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==",
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==",
581 581
             "cpu": [
582 582
                 "arm"
583 583
             ],
@@ -589,9 +589,9 @@
589 589
             ]
590 590
         },
591 591
         "node_modules/@rollup/rollup-android-arm64": {
592
-            "version": "4.29.1",
593
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.1.tgz",
594
-            "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==",
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==",
595 595
             "cpu": [
596 596
                 "arm64"
597 597
             ],
@@ -603,9 +603,9 @@
603 603
             ]
604 604
         },
605 605
         "node_modules/@rollup/rollup-darwin-arm64": {
606
-            "version": "4.29.1",
607
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.1.tgz",
608
-            "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==",
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==",
609 609
             "cpu": [
610 610
                 "arm64"
611 611
             ],
@@ -617,9 +617,9 @@
617 617
             ]
618 618
         },
619 619
         "node_modules/@rollup/rollup-darwin-x64": {
620
-            "version": "4.29.1",
621
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.1.tgz",
622
-            "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==",
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==",
623 623
             "cpu": [
624 624
                 "x64"
625 625
             ],
@@ -631,9 +631,9 @@
631 631
             ]
632 632
         },
633 633
         "node_modules/@rollup/rollup-freebsd-arm64": {
634
-            "version": "4.29.1",
635
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.1.tgz",
636
-            "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==",
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==",
637 637
             "cpu": [
638 638
                 "arm64"
639 639
             ],
@@ -645,9 +645,9 @@
645 645
             ]
646 646
         },
647 647
         "node_modules/@rollup/rollup-freebsd-x64": {
648
-            "version": "4.29.1",
649
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.1.tgz",
650
-            "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==",
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==",
651 651
             "cpu": [
652 652
                 "x64"
653 653
             ],
@@ -659,9 +659,9 @@
659 659
             ]
660 660
         },
661 661
         "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
662
-            "version": "4.29.1",
663
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.1.tgz",
664
-            "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==",
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==",
665 665
             "cpu": [
666 666
                 "arm"
667 667
             ],
@@ -673,9 +673,9 @@
673 673
             ]
674 674
         },
675 675
         "node_modules/@rollup/rollup-linux-arm-musleabihf": {
676
-            "version": "4.29.1",
677
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.1.tgz",
678
-            "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==",
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==",
679 679
             "cpu": [
680 680
                 "arm"
681 681
             ],
@@ -687,9 +687,9 @@
687 687
             ]
688 688
         },
689 689
         "node_modules/@rollup/rollup-linux-arm64-gnu": {
690
-            "version": "4.29.1",
691
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.1.tgz",
692
-            "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==",
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==",
693 693
             "cpu": [
694 694
                 "arm64"
695 695
             ],
@@ -701,9 +701,9 @@
701 701
             ]
702 702
         },
703 703
         "node_modules/@rollup/rollup-linux-arm64-musl": {
704
-            "version": "4.29.1",
705
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.1.tgz",
706
-            "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==",
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==",
707 707
             "cpu": [
708 708
                 "arm64"
709 709
             ],
@@ -715,9 +715,9 @@
715 715
             ]
716 716
         },
717 717
         "node_modules/@rollup/rollup-linux-loongarch64-gnu": {
718
-            "version": "4.29.1",
719
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.1.tgz",
720
-            "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==",
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==",
721 721
             "cpu": [
722 722
                 "loong64"
723 723
             ],
@@ -729,9 +729,9 @@
729 729
             ]
730 730
         },
731 731
         "node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
732
-            "version": "4.29.1",
733
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.1.tgz",
734
-            "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==",
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==",
735 735
             "cpu": [
736 736
                 "ppc64"
737 737
             ],
@@ -743,9 +743,9 @@
743 743
             ]
744 744
         },
745 745
         "node_modules/@rollup/rollup-linux-riscv64-gnu": {
746
-            "version": "4.29.1",
747
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.1.tgz",
748
-            "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==",
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==",
749 749
             "cpu": [
750 750
                 "riscv64"
751 751
             ],
@@ -757,9 +757,9 @@
757 757
             ]
758 758
         },
759 759
         "node_modules/@rollup/rollup-linux-s390x-gnu": {
760
-            "version": "4.29.1",
761
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.1.tgz",
762
-            "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==",
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==",
763 763
             "cpu": [
764 764
                 "s390x"
765 765
             ],
@@ -771,9 +771,9 @@
771 771
             ]
772 772
         },
773 773
         "node_modules/@rollup/rollup-linux-x64-gnu": {
774
-            "version": "4.29.1",
775
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.1.tgz",
776
-            "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==",
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==",
777 777
             "cpu": [
778 778
                 "x64"
779 779
             ],
@@ -785,9 +785,9 @@
785 785
             ]
786 786
         },
787 787
         "node_modules/@rollup/rollup-linux-x64-musl": {
788
-            "version": "4.29.1",
789
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.1.tgz",
790
-            "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==",
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==",
791 791
             "cpu": [
792 792
                 "x64"
793 793
             ],
@@ -799,9 +799,9 @@
799 799
             ]
800 800
         },
801 801
         "node_modules/@rollup/rollup-win32-arm64-msvc": {
802
-            "version": "4.29.1",
803
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.1.tgz",
804
-            "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==",
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==",
805 805
             "cpu": [
806 806
                 "arm64"
807 807
             ],
@@ -813,9 +813,9 @@
813 813
             ]
814 814
         },
815 815
         "node_modules/@rollup/rollup-win32-ia32-msvc": {
816
-            "version": "4.29.1",
817
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.1.tgz",
818
-            "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==",
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==",
819 819
             "cpu": [
820 820
                 "ia32"
821 821
             ],
@@ -827,9 +827,9 @@
827 827
             ]
828 828
         },
829 829
         "node_modules/@rollup/rollup-win32-x64-msvc": {
830
-            "version": "4.29.1",
831
-            "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.1.tgz",
832
-            "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==",
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==",
833 833
             "cpu": [
834 834
                 "x64"
835 835
             ],
@@ -841,22 +841,22 @@
841 841
             ]
842 842
         },
843 843
         "node_modules/@tailwindcss/forms": {
844
-            "version": "0.5.9",
845
-            "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz",
846
-            "integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==",
844
+            "version": "0.5.10",
845
+            "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
846
+            "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
847 847
             "dev": true,
848 848
             "license": "MIT",
849 849
             "dependencies": {
850 850
                 "mini-svg-data-uri": "^1.2.3"
851 851
             },
852 852
             "peerDependencies": {
853
-                "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20"
853
+                "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
854 854
             }
855 855
         },
856 856
         "node_modules/@tailwindcss/typography": {
857
-            "version": "0.5.15",
858
-            "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz",
859
-            "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==",
857
+            "version": "0.5.16",
858
+            "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
859
+            "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
860 860
             "dev": true,
861 861
             "license": "MIT",
862 862
             "dependencies": {
@@ -866,7 +866,7 @@
866 866
                 "postcss-selector-parser": "6.0.10"
867 867
             },
868 868
             "peerDependencies": {
869
-                "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20"
869
+                "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
870 870
             }
871 871
         },
872 872
         "node_modules/@types/estree": {
@@ -1031,9 +1031,9 @@
1031 1031
             }
1032 1032
         },
1033 1033
         "node_modules/browserslist": {
1034
-            "version": "4.24.3",
1035
-            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.3.tgz",
1036
-            "integrity": "sha512-1CPmv8iobE2fyRMV97dAcMVegvvWKxmq94hkLiAkUGwKVTyDLw33K+ZxiFrREKmmps4rIw6grcCFCnTMSZ/YiA==",
1034
+            "version": "4.24.4",
1035
+            "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz",
1036
+            "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==",
1037 1037
             "dev": true,
1038 1038
             "funding": [
1039 1039
                 {
@@ -1074,9 +1074,9 @@
1074 1074
             }
1075 1075
         },
1076 1076
         "node_modules/caniuse-lite": {
1077
-            "version": "1.0.30001690",
1078
-            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz",
1079
-            "integrity": "sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w==",
1077
+            "version": "1.0.30001692",
1078
+            "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001692.tgz",
1079
+            "integrity": "sha512-A95VKan0kdtrsnMubMKxEKUKImOPSuCpYgxSQBo036P5YYgVIcOYJEgt/txJWqObiRQeISNCfef9nvlQ0vbV7A==",
1080 1080
             "dev": true,
1081 1081
             "funding": [
1082 1082
                 {
@@ -1235,9 +1235,9 @@
1235 1235
             "license": "MIT"
1236 1236
         },
1237 1237
         "node_modules/electron-to-chromium": {
1238
-            "version": "1.5.76",
1239
-            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.76.tgz",
1240
-            "integrity": "sha512-CjVQyG7n7Sr+eBXE86HIulnL5N8xZY1sgmOPGuq/F0Rr0FJq63lg0kEtOIDfZBk44FnDLf6FUJ+dsJcuiUDdDQ==",
1238
+            "version": "1.5.80",
1239
+            "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.80.tgz",
1240
+            "integrity": "sha512-LTrKpW0AqIuHwmlVNV+cjFYTnXtM9K37OGhpe0ZI10ScPSxqVSryZHIY3WnCS5NSYbBODRTZyhRMS2h5FAEqAw==",
1241 1241
             "dev": true,
1242 1242
             "license": "ISC"
1243 1243
         },
@@ -1300,9 +1300,9 @@
1300 1300
             }
1301 1301
         },
1302 1302
         "node_modules/fast-glob": {
1303
-            "version": "3.3.2",
1304
-            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
1305
-            "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
1303
+            "version": "3.3.3",
1304
+            "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
1305
+            "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
1306 1306
             "dev": true,
1307 1307
             "license": "MIT",
1308 1308
             "dependencies": {
@@ -1310,7 +1310,7 @@
1310 1310
                 "@nodelib/fs.walk": "^1.2.3",
1311 1311
                 "glob-parent": "^5.1.2",
1312 1312
                 "merge2": "^1.3.0",
1313
-                "micromatch": "^4.0.4"
1313
+                "micromatch": "^4.0.8"
1314 1314
             },
1315 1315
             "engines": {
1316 1316
                 "node": ">=8.6.0"
@@ -2242,9 +2242,9 @@
2242 2242
             }
2243 2243
         },
2244 2244
         "node_modules/rollup": {
2245
-            "version": "4.29.1",
2246
-            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz",
2247
-            "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==",
2245
+            "version": "4.30.1",
2246
+            "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.30.1.tgz",
2247
+            "integrity": "sha512-mlJ4glW020fPuLi7DkM/lN97mYEZGWeqBnrljzN0gs7GLctqX3lNWxKQ7Gl712UAX+6fog/L3jh4gb7R6aVi3w==",
2248 2248
             "dev": true,
2249 2249
             "license": "MIT",
2250 2250
             "dependencies": {
@@ -2258,25 +2258,25 @@
2258 2258
                 "npm": ">=8.0.0"
2259 2259
             },
2260 2260
             "optionalDependencies": {
2261
-                "@rollup/rollup-android-arm-eabi": "4.29.1",
2262
-                "@rollup/rollup-android-arm64": "4.29.1",
2263
-                "@rollup/rollup-darwin-arm64": "4.29.1",
2264
-                "@rollup/rollup-darwin-x64": "4.29.1",
2265
-                "@rollup/rollup-freebsd-arm64": "4.29.1",
2266
-                "@rollup/rollup-freebsd-x64": "4.29.1",
2267
-                "@rollup/rollup-linux-arm-gnueabihf": "4.29.1",
2268
-                "@rollup/rollup-linux-arm-musleabihf": "4.29.1",
2269
-                "@rollup/rollup-linux-arm64-gnu": "4.29.1",
2270
-                "@rollup/rollup-linux-arm64-musl": "4.29.1",
2271
-                "@rollup/rollup-linux-loongarch64-gnu": "4.29.1",
2272
-                "@rollup/rollup-linux-powerpc64le-gnu": "4.29.1",
2273
-                "@rollup/rollup-linux-riscv64-gnu": "4.29.1",
2274
-                "@rollup/rollup-linux-s390x-gnu": "4.29.1",
2275
-                "@rollup/rollup-linux-x64-gnu": "4.29.1",
2276
-                "@rollup/rollup-linux-x64-musl": "4.29.1",
2277
-                "@rollup/rollup-win32-arm64-msvc": "4.29.1",
2278
-                "@rollup/rollup-win32-ia32-msvc": "4.29.1",
2279
-                "@rollup/rollup-win32-x64-msvc": "4.29.1",
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",
2280 2280
                 "fsevents": "~2.3.2"
2281 2281
             }
2282 2282
         },
@@ -2586,9 +2586,9 @@
2586 2586
             "license": "Apache-2.0"
2587 2587
         },
2588 2588
         "node_modules/update-browserslist-db": {
2589
-            "version": "1.1.1",
2590
-            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz",
2591
-            "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==",
2589
+            "version": "1.1.2",
2590
+            "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
2591
+            "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
2592 2592
             "dev": true,
2593 2593
             "funding": [
2594 2594
                 {
@@ -2607,7 +2607,7 @@
2607 2607
             "license": "MIT",
2608 2608
             "dependencies": {
2609 2609
                 "escalade": "^3.2.0",
2610
-                "picocolors": "^1.1.0"
2610
+                "picocolors": "^1.1.1"
2611 2611
             },
2612 2612
             "bin": {
2613 2613
                 "update-browserslist-db": "cli.js"
@@ -2624,9 +2624,9 @@
2624 2624
             "license": "MIT"
2625 2625
         },
2626 2626
         "node_modules/vite": {
2627
-            "version": "6.0.6",
2628
-            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.6.tgz",
2629
-            "integrity": "sha512-NSjmUuckPmDU18bHz7QZ+bTYhRR0iA72cs2QAxCqDpafJ0S6qetco0LB3WW2OxlMHS0JmAv+yZ/R3uPmMyGTjQ==",
2627
+            "version": "6.0.7",
2628
+            "resolved": "https://registry.npmjs.org/vite/-/vite-6.0.7.tgz",
2629
+            "integrity": "sha512-RDt8r/7qx9940f8FcOIAH9PTViRrghKaK2K1jY3RaAURrEUbm9Du1mJ72G+jlhtG3WwodnfzY8ORQZbBavZEAQ==",
2630 2630
             "dev": true,
2631 2631
             "license": "MIT",
2632 2632
             "dependencies": {
@@ -2821,9 +2821,9 @@
2821 2821
             }
2822 2822
         },
2823 2823
         "node_modules/yaml": {
2824
-            "version": "2.6.1",
2825
-            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
2826
-            "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
2824
+            "version": "2.7.0",
2825
+            "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz",
2826
+            "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==",
2827 2827
             "dev": true,
2828 2828
             "license": "ISC",
2829 2829
             "bin": {

+ 27
- 0
resources/css/filament/company/custom-data-table.css Visa fil

@@ -0,0 +1,27 @@
1
+.es-table__header-ctn, .es-table__footer-ctn {
2
+    @apply divide-y divide-gray-200 dark:divide-white/10 min-h-12;
3
+}
4
+
5
+.es-table .es-table__rowgroup td:first-child {
6
+    padding-left: 3rem;
7
+}
8
+
9
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(2) {
10
+    word-wrap: break-word;
11
+    word-break: break-word;
12
+    white-space: normal;
13
+}
14
+
15
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(3) {
16
+    word-wrap: break-word;
17
+    word-break: break-word;
18
+    white-space: normal;
19
+}
20
+
21
+.es-table .es-table__rowgroup .es-table__row > td:nth-child(4) {
22
+    white-space: nowrap;
23
+}
24
+
25
+.es-table .es-table__rowgroup .es-table__row > td:last-child {
26
+    padding-right: 3rem;
27
+}

+ 125
- 0
resources/css/filament/company/custom-section.css Visa fil

@@ -0,0 +1,125 @@
1
+.fi-custom-section {
2
+    &:not(.fi-section-not-contained) {
3
+        & .fi-section-content {
4
+            @apply p-6;
5
+        }
6
+
7
+        & .fi-section-footer {
8
+            @apply border-t border-gray-200 px-6 py-4 dark:border-white/10;
9
+        }
10
+
11
+        &:not(.fi-aside) {
12
+            @apply rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10;
13
+
14
+            & .fi-section-header {
15
+                @apply px-6 py-4;
16
+            }
17
+
18
+            &.fi-section-has-header {
19
+                & .fi-section-content-ctn {
20
+                    @apply border-t border-gray-200 dark:border-white/10;
21
+                }
22
+            }
23
+        }
24
+
25
+        &.fi-aside {
26
+            & .fi-section-content-ctn {
27
+                @apply rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 md:col-span-2;
28
+            }
29
+        }
30
+
31
+        &.fi-compact {
32
+            &:not(.fi-aside) {
33
+                & .fi-section-header {
34
+                    @apply px-4 py-2.5;
35
+                }
36
+            }
37
+
38
+            & .fi-section-content {
39
+                @apply p-4;
40
+            }
41
+
42
+            & .fi-section-footer {
43
+                @apply px-4 py-2.5;
44
+            }
45
+        }
46
+    }
47
+
48
+    &.fi-section-not-contained:not(.fi-aside) {
49
+        @apply grid gap-y-4;
50
+
51
+        & .fi-section-header {
52
+            @apply py-2;
53
+        }
54
+
55
+        & .fi-section-content-ctn {
56
+            @apply grid gap-y-4;
57
+        }
58
+
59
+        &.fi-compact {
60
+            @apply gap-y-2.5;
61
+
62
+            & .fi-section-content-ctn {
63
+                @apply gap-y-2.5;
64
+            }
65
+        }
66
+    }
67
+
68
+    &.fi-aside {
69
+        @apply grid grid-cols-1 items-start gap-x-6 gap-y-4 md:grid-cols-3;
70
+    }
71
+
72
+    &.fi-collapsible {
73
+        & .fi-section-header {
74
+            @apply cursor-pointer;
75
+        }
76
+    }
77
+
78
+    &.fi-collapsed {
79
+        & .fi-section-collapse-btn {
80
+            @apply rotate-180;
81
+        }
82
+
83
+        & .fi-section-content-ctn {
84
+            @apply invisible absolute h-0 overflow-hidden border-none;
85
+        }
86
+    }
87
+
88
+    &.fi-section-has-content-before {
89
+        & .fi-section-content-ctn {
90
+            @apply md:order-first;
91
+        }
92
+    }
93
+
94
+    & .fi-section-header {
95
+        @apply flex items-center gap-3;
96
+    }
97
+
98
+    & .fi-section-header-icon {
99
+        @apply size-6 self-start text-gray-400 dark:text-gray-500;
100
+
101
+        &.fi-color-custom {
102
+            @apply text-custom-500 dark:text-custom-400;
103
+        }
104
+
105
+        &.fi-size-sm {
106
+            @apply mt-1 size-4;
107
+        }
108
+
109
+        &.fi-size-md {
110
+            @apply mt-0.5 size-5;
111
+        }
112
+    }
113
+
114
+    & .fi-section-header-text-ctn {
115
+        @apply grid flex-1 gap-y-1;
116
+    }
117
+
118
+    & .fi-section-header-heading {
119
+        @apply text-base font-semibold leading-6 text-gray-950 dark:text-white;
120
+    }
121
+
122
+    & .fi-section-header-description {
123
+        @apply overflow-hidden break-words text-sm text-gray-500 dark:text-gray-400;
124
+    }
125
+}

+ 92
- 0
resources/css/filament/company/form-fields.css Visa fil

@@ -0,0 +1,92 @@
1
+/* Filament Repeater Styles */
2
+.fi-fo-repeater.uncontained .fi-fo-repeater-item {
3
+    @apply divide-y-0 rounded-none bg-none shadow-none ring-0 ring-gray-950/5 dark:divide-white/10 dark:bg-white/5 dark:ring-white/10;
4
+
5
+    .fi-fo-repeater-item-header {
6
+        @apply px-0;
7
+
8
+        > h4 {
9
+            @apply text-base font-semibold leading-6 text-gray-950 dark:text-white;
10
+        }
11
+    }
12
+
13
+    .fi-fo-repeater-item-content {
14
+        @apply py-4 px-0;
15
+    }
16
+}
17
+
18
+.fi-fo-repeater-item {
19
+    @apply divide-y divide-gray-200 rounded-xl bg-white dark:bg-gray-900;
20
+}
21
+
22
+/* Report Field Styles */
23
+.fi-fo-field-wrp.report-hidden-label > div.grid.gap-y-2 > div.flex.items-center {
24
+    @apply hidden;
25
+}
26
+
27
+.fi-fo-field-wrp.report-hidden-label {
28
+    @apply lg:mt-8;
29
+}
30
+
31
+/* Choices.js select field overrides */
32
+.choices__list.choices__list--single {
33
+    @apply w-full;
34
+}
35
+
36
+.choices:focus-visible {
37
+    outline: none;
38
+}
39
+
40
+.choices__group {
41
+    @apply text-gray-900 dark:text-white font-semibold;
42
+}
43
+
44
+.choices[data-type="select-one"] .choices__inner {
45
+    line-height: 1.5;
46
+    display: flex;
47
+    align-items: center;
48
+    min-height: 2.25rem;
49
+    box-sizing: border-box;
50
+}
51
+
52
+.choices:not(.is-disabled) .choices__item {
53
+    cursor: pointer;
54
+}
55
+
56
+/* Table Repeater Styles */
57
+.table-repeater-container {
58
+    @apply rounded-none ring-0;
59
+}
60
+
61
+.table-repeater-component {
62
+    @apply space-y-10;
63
+}
64
+
65
+.table-repeater-component ul {
66
+    @apply justify-start;
67
+}
68
+
69
+.table-repeater-row {
70
+    @apply divide-x-0 !important;
71
+}
72
+
73
+.table-repeater-column {
74
+    @apply py-2 !important;
75
+}
76
+
77
+.table-repeater-header {
78
+    @apply rounded-t-none !important;
79
+}
80
+
81
+.table-repeater-rows-wrapper {
82
+    @apply divide-gray-300 last:border-b last:border-gray-300 dark:divide-white/20 dark:last:border-white/20;
83
+}
84
+
85
+.table-repeater-header tr {
86
+    @apply divide-x-0 text-base sm:text-sm sm:leading-6 !important;
87
+}
88
+
89
+.table-repeater-header-column {
90
+    @apply ps-3 pe-3 font-semibold bg-gray-200 dark:bg-gray-800 rounded-none !important;
91
+}
92
+

+ 18
- 0
resources/css/filament/company/modal.css Visa fil

@@ -0,0 +1,18 @@
1
+/* Journal Entry Modal Styles */
2
+.fi-modal.fi-width-screen {
3
+    .fi-modal-header {
4
+        @apply xl:px-80;
5
+
6
+        .absolute.end-4.top-4 {
7
+            @apply xl:end-80;
8
+        }
9
+    }
10
+
11
+    .fi-modal-content {
12
+        @apply xl:px-80;
13
+    }
14
+
15
+    .fi-modal-footer {
16
+        @apply xl:px-80;
17
+    }
18
+}

+ 15
- 0
resources/css/filament/company/report-card.css Visa fil

@@ -0,0 +1,15 @@
1
+.es-report-card {
2
+    @apply md:!grid-cols-2;
3
+
4
+    .fi-fo-component-ctn {
5
+        @apply divide-y divide-gray-200 dark:divide-white/10 !gap-0;
6
+    }
7
+
8
+    .fi-section-content-ctn {
9
+        @apply md:!col-span-1;
10
+    }
11
+
12
+    .fi-section-content {
13
+        @apply !p-0;
14
+    }
15
+}

+ 1
- 0
resources/css/filament/company/tailwind.config.js Visa fil

@@ -13,6 +13,7 @@ export default {
13 13
         './vendor/andrewdwallo/filament-selectify/resources/views/**/*.blade.php',
14 14
         './vendor/awcodes/filament-table-repeater/resources/**/*.blade.php',
15 15
         './vendor/jaocero/radio-deck/resources/views/**/*.blade.php',
16
+        './vendor/codewithdennis/filament-simple-alert/resources/**/*.blade.php',
16 17
     ],
17 18
     theme: {
18 19
         extend: {

+ 11
- 176
resources/css/filament/company/theme.css Visa fil

@@ -1,163 +1,23 @@
1 1
 @import '/vendor/filament/filament/resources/css/theme.css';
2
-@import 'tooltip.css';
3 2
 @import '/vendor/awcodes/filament-table-repeater/resources/css/plugin.css';
3
+@import 'custom-data-table.css';
4
+@import 'custom-section.css';
5
+@import 'form-fields.css';
6
+@import 'modal.css';
7
+@import 'report-card.css';
8
+@import 'tooltip.css';
9
+@import 'top-navigation.css';
4 10
 
5 11
 @config 'tailwind.config.js';
6 12
 
7
-.fi-fo-repeater.uncontained .fi-fo-repeater-item {
8
-    @apply divide-y-0 rounded-none bg-none shadow-none ring-0 ring-gray-950/5 dark:divide-white/10 dark:bg-white/5 dark:ring-white/10;
9
-
10
-    .fi-fo-repeater-item-header {
11
-        @apply px-0;
12
-
13
-        > h4 {
14
-            @apply text-base font-semibold leading-6 text-gray-950 dark:text-white;
15
-        }
16
-    }
17
-
18
-    .fi-fo-repeater-item-content {
19
-        @apply py-4 px-0;
20
-    }
21
-}
22
-
23
-.fi-fo-field-wrp.report-hidden-label > div.grid.gap-y-2 > div.flex.items-center {
24
-    @apply hidden;
25
-}
26
-
27
-.fi-fo-repeater-item {
28
-    @apply divide-y divide-gray-200 rounded-xl bg-white dark:bg-gray-900;
29
-}
30
-
31
-.fi-fo-field-wrp.report-hidden-label {
32
-    @apply lg:mt-8;
33
-}
34
-
35
-.choices__list.choices__list--single {
36
-    @apply w-full;
37
-}
38
-
39
-.choices:focus-visible {
40
-    outline: none;
41
-}
42
-
43
-.choices__group {
44
-    @apply text-gray-900 dark:text-white font-semibold;
45
-}
46
-
47
-.choices[data-type="select-one"] .choices__inner {
48
-    line-height: 1.5;
49
-    display: flex;
50
-    align-items: center;
51
-    min-height: 2.25rem;
52
-    box-sizing: border-box;
53
-}
54
-
55
-.choices:not(.is-disabled) .choices__item {
56
-    cursor: pointer;
57
-}
58
-
59
-.table-repeater-container {
60
-    @apply rounded-none ring-0;
61
-}
62
-
63
-.table-repeater-component {
64
-    @apply space-y-10;
65
-}
66
-
67
-.table-repeater-component ul {
68
-    @apply justify-start;
69
-}
70
-
71
-.table-repeater-row {
72
-    @apply divide-x-0 !important;
73
-}
74
-
75
-.table-repeater-column {
76
-    @apply py-2 !important;
77
-}
78
-
79
-.table-repeater-header {
80
-    @apply rounded-t-none !important;
81
-}
82
-
83
-.table-repeater-rows-wrapper {
84
-    @apply divide-gray-300 last:border-b last:border-gray-300 dark:divide-white/20 dark:last:border-white/20;
85
-}
86
-
87
-.table-repeater-header tr {
88
-    @apply divide-x-0 text-base sm:text-sm sm:leading-6 !important;
89
-}
90
-
91
-.table-repeater-header-column {
92
-    @apply ps-3 pe-3 font-semibold bg-gray-200 dark:bg-gray-800 rounded-none !important;
93
-}
94
-
95
-.es-report-card {
96
-    @apply md:!grid-cols-2;
97
-
98
-    .fi-fo-component-ctn {
99
-        @apply divide-y divide-gray-200 dark:divide-white/10 !gap-0;
100
-    }
101
-
102
-    .fi-section-content-ctn {
103
-        @apply md:!col-span-1;
104
-    }
105
-
106
-    .fi-section-content {
107
-        @apply !p-0;
108
-    }
109
-}
110
-
111
-.fi-modal.fi-width-screen {
112
-    .fi-modal-header {
113
-        @apply xl:px-80;
114
-
115
-        .absolute.end-4.top-4 {
116
-            @apply xl:end-80;
117
-        }
118
-    }
119
-
120
-    .fi-modal-content {
121
-        @apply xl:px-80;
122
-    }
123
-
124
-    .fi-modal-footer {
125
-        @apply xl:px-80;
126
-    }
127
-}
128
-
129
-.es-table__header-ctn, .es-table__footer-ctn {
130
-    @apply divide-y divide-gray-200 dark:divide-white/10 min-h-12;
131
-}
132
-
133
-.es-table .es-table__rowgroup td:first-child {
134
-    padding-left: 3rem;
135
-}
136
-
137
-.es-table .es-table__rowgroup .es-table__row > td:nth-child(2) {
138
-    word-wrap: break-word;
139
-    word-break: break-word;
140
-    white-space: normal;
141
-}
142
-
143
-.es-table .es-table__rowgroup .es-table__row > td:nth-child(3) {
144
-    word-wrap: break-word;
145
-    word-break: break-word;
146
-    white-space: normal;
147
-}
148
-
149
-.es-table .es-table__rowgroup .es-table__row > td:nth-child(4) {
150
-    white-space: nowrap;
151
-}
152
-
153
-.es-table .es-table__rowgroup .es-table__row > td:last-child {
154
-    padding-right: 3rem;
155
-}
156
-
157 13
 .fi-ta-empty-state-icon-ctn {
158 14
     @apply bg-platinum;
159 15
 }
160 16
 
17
+.fi-badge {
18
+    display: inline-flex;
19
+}
20
+
161 21
 :not(.dark) .fi-body {
162 22
     position: relative;
163 23
     background-color: #E8E9EB;
@@ -180,28 +40,3 @@
180 40
     pointer-events: none;
181 41
     z-index: -1;
182 42
 }
183
-
184
-.fi-topbar > nav, .fi-sidebar-header {
185
-    @apply bg-transparent ring-0 shadow-none !important;
186
-    transition: background-color 0.3s, top 0.3s;
187
-}
188
-
189
-.fi-topbar > nav.topbar-hovered, .fi-sidebar-header.topbar-hovered {
190
-    background-color: rgba(255, 255, 255, 0.75) !important;
191
-}
192
-
193
-:is(.dark .fi-topbar > nav.topbar-hovered, .dark .fi-sidebar-header.topbar-hovered) {
194
-    @apply bg-gray-900/75 !important;
195
-}
196
-
197
-.fi-topbar > nav.topbar-scrolled, .fi-sidebar-header.topbar-scrolled {
198
-    background-color: rgba(255, 255, 255, 0.5) !important;
199
-}
200
-
201
-:is(.dark .fi-topbar > nav.topbar-scrolled, .dark .fi-sidebar-header.topbar-scrolled) {
202
-    @apply bg-gray-900/50 !important;
203
-}
204
-
205
-.fi-badge {
206
-    display: inline-flex;
207
-}

+ 20
- 0
resources/css/filament/company/top-navigation.css Visa fil

@@ -0,0 +1,20 @@
1
+.fi-topbar > nav, .fi-sidebar-header {
2
+    @apply bg-transparent ring-0 shadow-none !important;
3
+    transition: background-color 0.3s, top 0.3s;
4
+}
5
+
6
+.fi-topbar > nav.topbar-hovered, .fi-sidebar-header.topbar-hovered {
7
+    background-color: rgba(255, 255, 255, 0.75) !important;
8
+}
9
+
10
+:is(.dark .fi-topbar > nav.topbar-hovered, .dark .fi-sidebar-header.topbar-hovered) {
11
+    @apply bg-gray-900/75 !important;
12
+}
13
+
14
+.fi-topbar > nav.topbar-scrolled, .fi-sidebar-header.topbar-scrolled {
15
+    background-color: rgba(255, 255, 255, 0.5) !important;
16
+}
17
+
18
+:is(.dark .fi-topbar > nav.topbar-scrolled, .dark .fi-sidebar-header.topbar-scrolled) {
19
+    @apply bg-gray-900/50 !important;
20
+}

+ 8
- 1
resources/data/lang/en.json Visa fil

@@ -216,5 +216,12 @@
216 216
     "Create": "Create",
217 217
     "Estimate Header": "Estimate Header",
218 218
     "Estimate Details": "Estimate Details",
219
-    "Estimate Footer": "Estimate Footer"
219
+    "Estimate Footer": "Estimate Footer",
220
+    "Scheduling": "Scheduling",
221
+    "Scheduling Form": "Scheduling Form",
222
+    "Approve": "Approve",
223
+    "Frequency": "Frequency",
224
+    "Schedule Bounds": "Schedule Bounds",
225
+    "Time Zone": "Time Zone",
226
+    "Dates & Time": "Dates & Time"
220 227
 }

+ 16
- 46
resources/views/components/custom-section.blade.php Visa fil

@@ -66,10 +66,13 @@
66 66
     @endif
67 67
     {{
68 68
         $attributes->class([
69
-            'fi-section',
70
-            'fi-aside grid grid-cols-1 items-start gap-x-6 gap-y-4 md:grid-cols-3' => $aside && $contained,
71
-            'fi-aside grid grid-cols-1 items-start gap-x-6 gap-y-4 md:grid-cols-3 pt-4' => $aside && ! $contained,
72
-            'rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10' => $contained && ! $aside,
69
+            'fi-custom-section',
70
+            'fi-section-not-contained' => ! $contained,
71
+            'fi-section-has-content-before' => $contentBefore,
72
+            'fi-section-has-header' => $hasHeader,
73
+            'fi-aside' => $aside,
74
+            'fi-compact' => $compact,
75
+            'fi-collapsible' => $collapsible,
73 76
         ])
74 77
     }}
75 78
 >
@@ -78,31 +81,20 @@
78 81
             @if ($collapsible)
79 82
                 x-on:click="isCollapsed = ! isCollapsed"
80 83
             @endif
81
-            @class([
82
-                'fi-section-header flex flex-col gap-3',
83
-                'cursor-pointer' => $collapsible,
84
-                'px-6 py-4' => $contained && ! $aside,
85
-                'px-4 py-2.5' => $compact && ! $aside,
86
-                'py-4' => ! $compact && ! $aside,
87
-            ])
84
+            class="fi-section-header"
88 85
         >
89 86
             <div class="flex items-center gap-3">
90 87
                 @if ($hasIcon)
91 88
                     <x-filament::icon
92 89
                         :icon="$icon"
93 90
                         @class([
94
-                            'fi-section-header-icon self-start',
91
+                            'fi-section-header-icon',
95 92
                             match ($iconColor) {
96
-                                'gray' => 'text-gray-400 dark:text-gray-500',
97
-                                default => 'fi-color-custom text-custom-500 dark:text-custom-400',
93
+                                'gray' => null,
94
+                                default => 'fi-color-custom',
98 95
                             },
99 96
                             is_string($iconColor) ? "fi-color-{$iconColor}" : null,
100
-                            match ($iconSize) {
101
-                                IconSize::Small, 'sm' => 'h-4 w-4 mt-1',
102
-                                IconSize::Medium, 'md' => 'h-5 w-5 mt-0.5',
103
-                                IconSize::Large, 'lg' => 'h-6 w-6',
104
-                                default => $iconSize,
105
-                            },
97
+                            ($iconSize instanceof IconSize) ? "fi-size-{$iconSize->value}" : (is_string($iconSize) ? $iconSize : null),
106 98
                         ])
107 99
                         @style([
108 100
                             \Filament\Support\get_color_css_variables(
@@ -115,7 +107,7 @@
115 107
                 @endif
116 108
 
117 109
                 @if ($hasHeading || $hasDescription)
118
-                    <div class="grid flex-1 gap-y-1">
110
+                    <div class="fi-section-header-text-ctn">
119 111
                         @if ($hasHeading)
120 112
                             <x-filament::section.heading>
121 113
                                 {{ $heading }}
@@ -171,37 +163,15 @@
171 163
             @if ($collapsed || $persistCollapsed)
172 164
                 x-cloak
173 165
             @endif
174
-            x-bind:class="{ 'invisible h-0 overflow-y-hidden border-none': isCollapsed }"
175 166
         @endif
176
-        @class([
177
-            'fi-section-content-ctn',
178
-            'border-t border-gray-200 dark:border-white/10' => $hasHeader && ! $aside && $contained,
179
-            'rounded-xl bg-white shadow-sm ring-1 ring-gray-950/5 dark:bg-gray-900 dark:ring-white/10 md:col-span-2' => $aside && $contained,
180
-            'md:col-span-2' => $aside && ! $contained,
181
-            'md:order-first' => $contentBefore,
182
-        ])
167
+        class="fi-section-content-ctn"
183 168
     >
184
-        <div
185
-            @class([
186
-                'fi-section-content',
187
-                'pt-4' => ! $contained && ! $aside,
188
-                'p-4' => $compact && $contained,
189
-                'p-6' => ! $compact && $contained,
190
-            ])
191
-        >
169
+        <div class="fi-section-content">
192 170
             {{ $slot }}
193 171
         </div>
194 172
 
195 173
         @if ($hasFooterActions)
196
-            <footer
197
-                @class([
198
-                    'fi-section-footer',
199
-                    'border-t border-gray-200 dark:border-white/10' => $contained,
200
-                    'mt-6' => ! $contained,
201
-                    'px-6 py-4' => ! $compact && $contained,
202
-                    'px-4 py-2.5' => $compact && $contained,
203
-                ])
204
-            >
174
+            <footer class="fi-section-footer">
205 175
                 <x-filament::actions
206 176
                     :actions="$footerActions"
207 177
                     :alignment="$footerActionsAlignment"

+ 18
- 0
resources/views/filament/company/resources/sales/invoice-resource/pages/list-invoices.blade.php Visa fil

@@ -0,0 +1,18 @@
1
+<x-filament-panels::page
2
+    @class([
3
+        'fi-resource-list-records-page',
4
+        'fi-resource-' . str_replace('/', '-', $this->getResource()::getSlug()),
5
+    ])
6
+>
7
+    <div class="flex flex-col gap-y-6">
8
+        {{ $this->infolist }}
9
+
10
+        <x-filament-panels::resources.tabs />
11
+
12
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_BEFORE, scopes: $this->getRenderHookScopes()) }}
13
+
14
+        {{ $this->table }}
15
+
16
+        {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::RESOURCE_PAGES_LIST_RECORDS_TABLE_AFTER, scopes: $this->getRenderHookScopes()) }}
17
+    </div>
18
+</x-filament-panels::page>

+ 35
- 0
resources/views/filament/forms/components/labeled-field.blade.php Visa fil

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

+ 49
- 63
resources/views/filament/infolists/components/document-preview.blade.php Visa fil

@@ -1,10 +1,5 @@
1 1
 @php
2
-    use App\View\Models\DocumentPreviewViewModel;
3
-    use App\Enums\Accounting\DocumentType;
4
-
5
-    $type = $getType();
6
-    $viewModel = new DocumentPreviewViewModel($getRecord(), $type);
7
-    extract($viewModel->buildViewData(), EXTR_SKIP);
2
+    $document = \App\DTO\DocumentDTO::fromModel($getRecord());
8 3
 @endphp
9 4
 
10 5
 <div {{ $attributes }}>
@@ -13,16 +8,16 @@
13 8
         <x-company.invoice.header class="bg-gray-800 h-24">
14 9
             <!-- Logo -->
15 10
             <div class="w-2/3">
16
-                @if($logo && $style['show_logo'])
17
-                    <x-company.invoice.logo class="ml-8" :src="$logo"/>
11
+                @if($document->logo && $document->showLogo)
12
+                    <x-company.invoice.logo class="ml-8" :src="$document->logo"/>
18 13
                 @endif
19 14
             </div>
20 15
 
21 16
             <!-- Ribbon Container -->
22 17
             <div class="w-1/3 absolute right-0 top-0 p-3 h-32 flex flex-col justify-end rounded-bl-sm"
23
-                 style="background: {{ $style['accent_color'] }};">
24
-                @if($header)
25
-                    <h1 class="text-4xl font-bold text-white text-center uppercase">{{ $header }}</h1>
18
+                 style="background: {{ $document->accentColor }};">
19
+                @if($document->header)
20
+                    <h1 class="text-4xl font-bold text-white text-center uppercase">{{ $document->header }}</h1>
26 21
                 @endif
27 22
             </div>
28 23
         </x-company.invoice.header>
@@ -30,12 +25,11 @@
30 25
         <!-- Company Details -->
31 26
         <x-company.invoice.metadata class="modern-template-metadata space-y-8">
32 27
             <div class="text-sm">
33
-                <h2 class="text-lg font-semibold">{{ $company['name'] }}</h2>
34
-                @if($company['address'] && $company['city'] && $company['state'] && $company['zip_code'])
35
-                    <p>{{ $company['address'] }}</p>
36
-                    <p>{{ $company['city'] }}
37
-                        , {{ $company['state'] }} {{ $company['zip_code'] }}</p>
38
-                    <p>{{ $company['country'] }}</p>
28
+                <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>
39 33
                 @endif
40 34
             </div>
41 35
 
@@ -44,20 +38,20 @@
44 38
                 <div class="text-sm tracking-tight">
45 39
                     <h3 class="text-gray-600 dark:text-gray-400 font-medium tracking-tight mb-1">BILL TO</h3>
46 40
                     <p class="text-base font-bold"
47
-                       style="color: {{ $style['accent_color'] }}">{{ $client['name'] }}</p>
41
+                       style="color: {{ $document->accentColor }}">{{ $document->client->name }}</p>
48 42
 
49
-                    @if($client['address_line_1'])
50
-                        <p>{{ $client['address_line_1'] }}</p>
43
+                    @if($document->client->addressLine1)
44
+                        <p>{{ $document->client->addressLine1 }}</p>
51 45
 
52
-                        @if($client['address_line_2'])
53
-                            <p>{{ $client['address_line_2'] }}</p>
46
+                        @if($document->client->addressLine2)
47
+                            <p>{{ $document->client->addressLine2 }}</p>
54 48
                         @endif
55 49
                         <p>
56
-                            {{ $client['city'] }}{{ $client['state'] ? ', ' . $client['state'] : '' }}
57
-                            {{ $client['postal_code'] }}
50
+                            {{ $document->client->city }}{{ $document->client->state ? ', ' . $document->client->state: '' }}
51
+                            {{ $document->client->postalCode }}
58 52
                         </p>
59
-                        @if($client['country'])
60
-                            <p>{{ $client['country'] }}</p>
53
+                        @if($document->client->country)
54
+                            <p>{{ $document->client->country }}</p>
61 55
                         @endif
62 56
                     @endif
63 57
                 </div>
@@ -66,22 +60,22 @@
66 60
                     <table class="min-w-full">
67 61
                         <tbody>
68 62
                         <tr>
69
-                            <td class="font-semibold text-right pr-2">{{ $labels['number'] }}:</td>
70
-                            <td class="text-left pl-2">{{ $metadata['number'] }}</td>
63
+                            <td class="font-semibold text-right pr-2">{{ $document->label->number }}:</td>
64
+                            <td class="text-left pl-2">{{ $document->number }}</td>
71 65
                         </tr>
72
-                        @if($metadata['reference_number'])
66
+                        @if($document->referenceNumber)
73 67
                             <tr>
74
-                                <td class="font-semibold text-right pr-2">{{ $labels['reference_number'] }}:</td>
75
-                                <td class="text-left pl-2">{{ $metadata['reference_number'] }}</td>
68
+                                <td class="font-semibold text-right pr-2">{{ $document->label->referenceNumber }}:</td>
69
+                                <td class="text-left pl-2">{{ $document->referenceNumber }}</td>
76 70
                             </tr>
77 71
                         @endif
78 72
                         <tr>
79
-                            <td class="font-semibold text-right pr-2">{{ $labels['date'] }}:</td>
80
-                            <td class="text-left pl-2">{{ $metadata['date'] }}</td>
73
+                            <td class="font-semibold text-right pr-2">{{ $document->label->date }}:</td>
74
+                            <td class="text-left pl-2">{{ $document->date }}</td>
81 75
                         </tr>
82 76
                         <tr>
83
-                            <td class="font-semibold text-right pr-2">{{ $labels['due_date'] }}:</td>
84
-                            <td class="text-left pl-2">{{ $metadata['due_date'] }}</td>
77
+                            <td class="font-semibold text-right pr-2">{{ $document->label->dueDate }}:</td>
78
+                            <td class="text-left pl-2">{{ $document->dueDate }}</td>
85 79
                         </tr>
86 80
                         </tbody>
87 81
                     </table>
@@ -101,17 +95,17 @@
101 95
                 </tr>
102 96
                 </thead>
103 97
                 <tbody class="text-sm tracking-tight border-y-2">
104
-                @foreach($lineItems as $index => $item)
98
+                @foreach($document->lineItems as $index => $item)
105 99
                     <tr @class(['bg-gray-100 dark:bg-gray-800' => $index % 2 === 0])>
106 100
                         <td class="text-left pl-6 font-semibold py-3">
107
-                            {{ $item['name'] }}
108
-                            @if($item['description'])
109
-                                <div class="text-gray-600 font-normal line-clamp-2 mt-1">{{ $item['description'] }}</div>
101
+                            {{ $item->name }}
102
+                            @if($item->description)
103
+                                <div class="text-gray-600 font-normal line-clamp-2 mt-1">{{ $item->description }}</div>
110 104
                             @endif
111 105
                         </td>
112
-                        <td class="text-center py-3">{{ $item['quantity'] }}</td>
113
-                        <td class="text-right py-3">{{ $item['unit_price'] }}</td>
114
-                        <td class="text-right pr-6 py-3">{{ $item['subtotal'] }}</td>
106
+                        <td class="text-center py-3">{{ $item->quantity }}</td>
107
+                        <td class="text-right py-3">{{ $item->unitPrice }}</td>
108
+                        <td class="text-right pr-6 py-3">{{ $item->subtotal }}</td>
115 109
                     </tr>
116 110
                 @endforeach
117 111
                 </tbody>
@@ -119,44 +113,36 @@
119 113
                 <tr>
120 114
                     <td class="pl-6 py-2" colspan="2"></td>
121 115
                     <td class="text-right font-semibold py-2">Subtotal:</td>
122
-                    <td class="text-right pr-6 py-2">{{ $totals['subtotal'] }}</td>
116
+                    <td class="text-right pr-6 py-2">{{ $document->subtotal }}</td>
123 117
                 </tr>
124
-                @if($totals['discount'])
118
+                @if($document->discount)
125 119
                     <tr class="text-success-800 dark:text-success-600">
126 120
                         <td class="pl-6 py-2" colspan="2"></td>
127 121
                         <td class="text-right py-2">Discount:</td>
128 122
                         <td class="text-right pr-6 py-2">
129
-                            ({{ $totals['discount'] }})
123
+                            ({{ $document->discount }})
130 124
                         </td>
131 125
                     </tr>
132 126
                 @endif
133
-                @if($totals['tax'])
127
+                @if($document->tax)
134 128
                     <tr>
135 129
                         <td class="pl-6 py-2" colspan="2"></td>
136 130
                         <td class="text-right py-2">Tax:</td>
137
-                        <td class="text-right pr-6 py-2">{{ $totals['tax'] }}</td>
131
+                        <td class="text-right pr-6 py-2">{{ $document->tax }}</td>
138 132
                     </tr>
139 133
                 @endif
140 134
                 <tr>
141 135
                     <td class="pl-6 py-2" colspan="2"></td>
142 136
                     <td class="text-right font-semibold border-t py-2">Total:</td>
143
-                    <td class="text-right border-t pr-6 py-2">{{ $totals['total'] }}</td>
137
+                    <td class="text-right border-t pr-6 py-2">{{ $document->total }}</td>
144 138
                 </tr>
145
-                @if($totals['amount_due'])
146
-                    <tr>
147
-                        <td class="pl-6 py-2" colspan="2"></td>
148
-                        <td class="text-right font-semibold border-t-4 border-double py-2">Amount Due
149
-                            ({{ $metadata['currency_code'] }}):
150
-                        </td>
151
-                        <td class="text-right border-t-4 border-double pr-6 py-2">{{ $totals['amount_due'] }}</td>
152
-                    </tr>
153
-                @else
139
+                @if($document->amountDue)
154 140
                     <tr>
155 141
                         <td class="pl-6 py-2" colspan="2"></td>
156
-                        <td class="text-right font-semibold border-t-4 border-double py-2">Grand Total
157
-                            ({{ $metadata['currency_code'] }}):
142
+                        <td class="text-right font-semibold border-t-4 border-double py-2">{{ $document->label->amountDue }}
143
+                            ({{ $document->currencyCode }}):
158 144
                         </td>
159
-                        <td class="text-right border-t-4 border-double pr-6 py-2">{{ $totals['total'] }}</td>
145
+                        <td class="text-right border-t-4 border-double pr-6 py-2">{{ $document->amountDue }}</td>
160 146
                     </tr>
161 147
                 @endif
162 148
                 </tfoot>
@@ -165,13 +151,13 @@
165 151
 
166 152
         <!-- Footer Notes -->
167 153
         <x-company.invoice.footer class="modern-template-footer tracking-tight">
168
-            <h4 class="font-semibold px-6 text-sm" style="color: {{ $style['accent_color'] }}">
154
+            <h4 class="font-semibold px-6 text-sm" style="color: {{ $document->accentColor }}">
169 155
                 Terms & Conditions
170 156
             </h4>
171 157
             <span class="border-t-2 my-2 border-gray-300 block w-full"></span>
172 158
             <div class="flex justify-between space-x-4 px-6 text-sm">
173
-                <p class="w-1/2 break-words line-clamp-4">{{ $terms }}</p>
174
-                <p class="w-1/2 break-words line-clamp-4">{{ $footer }}</p>
159
+                <p class="w-1/2 break-words line-clamp-4">{{ $document->terms }}</p>
160
+                <p class="w-1/2 break-words line-clamp-4">{{ $document->footer }}</p>
175 161
             </div>
176 162
         </x-company.invoice.footer>
177 163
     </x-company.invoice.container>

+ 2
- 0
routes/console.php Visa fil

@@ -1,6 +1,8 @@
1 1
 <?php
2 2
 
3
+use App\Console\Commands\TriggerRecurringInvoiceGeneration;
3 4
 use App\Console\Commands\UpdateOverdueInvoices;
4 5
 use Illuminate\Support\Facades\Schedule;
5 6
 
6 7
 Schedule::command(UpdateOverdueInvoices::class)->everyFiveMinutes();
8
+Schedule::command(TriggerRecurringInvoiceGeneration::class, ['--queue'])->everyMinute();

+ 29
- 0
tests/Feature/Accounting/RecurringInvoiceTest.php Visa fil

@@ -0,0 +1,29 @@
1
+<?php
2
+
3
+use App\Enums\Accounting\IntervalType;
4
+use App\Models\Accounting\RecurringInvoice;
5
+
6
+test('example', function () {
7
+    $recurringInvoice = RecurringInvoice::factory()
8
+        ->custom(IntervalType::Week, 2)
9
+        ->create([
10
+            'start_date' => today(),
11
+            'day_of_week' => today()->dayOfWeek,
12
+        ]);
13
+
14
+    $recurringInvoice->refresh();
15
+
16
+    $nextInvoiceDate = $recurringInvoice->calculateNextDate();
17
+
18
+    expect($nextInvoiceDate)->toEqual(today());
19
+
20
+    $recurringInvoice->update([
21
+        'last_date' => $nextInvoiceDate,
22
+    ]);
23
+
24
+    $recurringInvoice->refresh();
25
+
26
+    $nextInvoiceDate = $recurringInvoice->calculateNextDate();
27
+
28
+    expect($nextInvoiceDate)->toEqual(today()->addWeeks(2));
29
+});

Laddar…
Avbryt
Spara