Andrew Wallo 6 months ago
parent
commit
7c53ce90db

+ 32
- 0
app/Enums/Accounting/BudgetSourceType.php View File

1
+<?php
2
+
3
+namespace App\Enums\Accounting;
4
+
5
+use App\Enums\Concerns\ParsesEnum;
6
+use Filament\Support\Contracts\HasLabel;
7
+
8
+enum BudgetSourceType: string implements HasLabel
9
+{
10
+    use ParsesEnum;
11
+
12
+    case Budget = 'budget';
13
+    case Actuals = 'actuals';
14
+
15
+    public function getLabel(): string
16
+    {
17
+        return match ($this) {
18
+            self::Budget => 'Copy from a previous budget',
19
+            self::Actuals => 'Use historical actuals',
20
+        };
21
+    }
22
+
23
+    public function isBudget(): bool
24
+    {
25
+        return $this === self::Budget;
26
+    }
27
+
28
+    public function isActuals(): bool
29
+    {
30
+        return $this === self::Actuals;
31
+    }
32
+}

+ 50
- 19
app/Filament/Company/Resources/Accounting/BudgetResource.php View File

255
                                 Header::make('Account')
255
                                 Header::make('Account')
256
                                     ->label('Account')
256
                                     ->label('Account')
257
                                     ->width('200px'),
257
                                     ->width('200px'),
258
+                                Header::make('total')
259
+                                    ->label('Total')
260
+                                    ->width('120px')
261
+                                    ->align(Alignment::Right),
262
+                                Header::make('action')
263
+                                    ->label('')
264
+                                    ->width('40px')
265
+                                    ->align(Alignment::Center),
258
                             ];
266
                             ];
259
 
267
 
260
                             foreach ($periods as $period) {
268
                             foreach ($periods as $period) {
264
                                     ->align(Alignment::Right);
272
                                     ->align(Alignment::Right);
265
                             }
273
                             }
266
 
274
 
267
-                            $headers[] = Header::make('total')
268
-                                ->label('Total')
269
-                                ->width('120px')
270
-                                ->align(Alignment::Right);
271
-
272
                             return [
275
                             return [
273
                                 CustomTableRepeater::make('budgetItems')
276
                                 CustomTableRepeater::make('budgetItems')
274
                                     ->relationship()
277
                                     ->relationship()
279
                                             ->hiddenLabel()
282
                                             ->hiddenLabel()
280
                                             ->content(fn (BudgetItem $record) => $record->account->name ?? ''),
283
                                             ->content(fn (BudgetItem $record) => $record->account->name ?? ''),
281
 
284
 
285
+                                        Forms\Components\TextInput::make('total')
286
+                                            ->hiddenLabel()
287
+                                            ->mask(RawJs::make('$money($input)'))
288
+                                            ->stripCharacters(',')
289
+                                            ->numeric()
290
+                                            ->afterStateHydrated(function ($component, $state, BudgetItem $record) use ($periods) {
291
+                                                $total = 0;
292
+                                                // Calculate the total for this budget item across all periods
293
+                                                foreach ($periods as $period) {
294
+                                                    $allocation = $record->allocations->firstWhere('period', $period);
295
+                                                    $total += $allocation ? $allocation->amount : 0;
296
+                                                }
297
+                                                $component->state($total);
298
+                                            })
299
+                                            ->dehydrated(false),
300
+
301
+                                        Forms\Components\Actions::make([
302
+                                            Forms\Components\Actions\Action::make('disperse')
303
+                                                ->label('Disperse')
304
+                                                ->icon('heroicon-m-chevron-double-right')
305
+                                                ->color('primary')
306
+                                                ->iconButton()
307
+                                                ->action(function (Forms\Set $set, Forms\Get $get, BudgetItem $record, $livewire) use ($periods) {
308
+                                                    $total = CurrencyConverter::convertToCents($get('total'));
309
+                                                    ray($total);
310
+                                                    $numPeriods = count($periods);
311
+
312
+                                                    if ($numPeriods === 0) {
313
+                                                        return;
314
+                                                    }
315
+
316
+                                                    $baseAmount = floor($total / $numPeriods);
317
+                                                    $remainder = $total - ($baseAmount * $numPeriods);
318
+
319
+                                                    foreach ($periods as $index => $period) {
320
+                                                        $amount = $baseAmount + ($index === 0 ? $remainder : 0);
321
+                                                        $formattedAmount = CurrencyConverter::convertCentsToFormatSimple($amount);
322
+                                                        $set("allocations.{$period}", $formattedAmount);
323
+                                                    }
324
+                                                }),
325
+                                        ]),
326
+
282
                                         // Create a field for each period
327
                                         // Create a field for each period
283
                                         ...collect($periods)->map(function ($period) {
328
                                         ...collect($periods)->map(function ($period) {
284
                                             return Forms\Components\TextInput::make("allocations.{$period}")
329
                                             return Forms\Components\TextInput::make("allocations.{$period}")
292
                                                 })
337
                                                 })
293
                                                 ->dehydrated(false); // We'll handle saving manually
338
                                                 ->dehydrated(false); // We'll handle saving manually
294
                                         })->toArray(),
339
                                         })->toArray(),
295
-
296
-                                        Forms\Components\Placeholder::make('total')
297
-                                            ->hiddenLabel()
298
-                                            ->content(function (BudgetItem $record) use ($periods) {
299
-                                                $total = 0;
300
-
301
-                                                // Calculate the total for this budget item across all periods
302
-                                                foreach ($periods as $period) {
303
-                                                    $allocation = $record->allocations->firstWhere('period', $period);
304
-                                                    $total += $allocation ? $allocation->amount : 0;
305
-                                                }
306
-
307
-                                                return CurrencyConverter::formatToMoney($total);
308
-                                            }),
309
                                     ])
340
                                     ])
310
                                     ->spreadsheet()
341
                                     ->spreadsheet()
311
                                     ->itemLabel(fn (BudgetItem $record) => $record->account->name ?? 'Budget Item')
342
                                     ->itemLabel(fn (BudgetItem $record) => $record->account->name ?? 'Budget Item')

+ 19
- 20
app/Filament/Company/Resources/Accounting/BudgetResource/Pages/CreateBudget.php View File

3
 namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
3
 namespace App\Filament\Company\Resources\Accounting\BudgetResource\Pages;
4
 
4
 
5
 use App\Enums\Accounting\BudgetIntervalType;
5
 use App\Enums\Accounting\BudgetIntervalType;
6
+use App\Enums\Accounting\BudgetSourceType;
6
 use App\Facades\Accounting;
7
 use App\Facades\Accounting;
7
 use App\Filament\Company\Resources\Accounting\BudgetResource;
8
 use App\Filament\Company\Resources\Accounting\BudgetResource;
8
 use App\Filament\Forms\Components\CustomSection;
9
 use App\Filament\Forms\Components\CustomSection;
9
 use App\Models\Accounting\Account;
10
 use App\Models\Accounting\Account;
10
 use App\Models\Accounting\Budget;
11
 use App\Models\Accounting\Budget;
11
-use App\Models\Accounting\BudgetAllocation;
12
 use App\Models\Accounting\BudgetItem;
12
 use App\Models\Accounting\BudgetItem;
13
 use App\Utilities\Currency\CurrencyConverter;
13
 use App\Utilities\Currency\CurrencyConverter;
14
 use Filament\Forms;
14
 use Filament\Forms;
36
 
36
 
37
     public function getAccountsWithActuals(): Collection
37
     public function getAccountsWithActuals(): Collection
38
     {
38
     {
39
-        $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
39
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
40
 
40
 
41
         if (blank($fiscalYear)) {
41
         if (blank($fiscalYear)) {
42
             return collect();
42
             return collect();
54
 
54
 
55
     public function getAccountsWithoutActuals(): Collection
55
     public function getAccountsWithoutActuals(): Collection
56
     {
56
     {
57
-        $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
57
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
58
 
58
 
59
         if (blank($fiscalYear)) {
59
         if (blank($fiscalYear)) {
60
             return collect();
60
             return collect();
68
 
68
 
69
     public function getAccountBalances(): Collection
69
     public function getAccountBalances(): Collection
70
     {
70
     {
71
-        $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
71
+        $fiscalYear = $this->data['source_fiscal_year'] ?? null;
72
 
72
 
73
         if (blank($fiscalYear)) {
73
         if (blank($fiscalYear)) {
74
             return collect();
74
             return collect();
144
 
144
 
145
                     Forms\Components\Grid::make(1)
145
                     Forms\Components\Grid::make(1)
146
                         ->schema([
146
                         ->schema([
147
-                            Forms\Components\Select::make('prefill_method')
147
+                            Forms\Components\Select::make('source_type')
148
                                 ->label('Prefill Method')
148
                                 ->label('Prefill Method')
149
-                                ->options([
150
-                                    'previous_budget' => 'Copy from a previous budget',
151
-                                    'actuals' => 'Use historical actuals',
152
-                                ])
149
+                                ->options(BudgetSourceType::class)
153
                                 ->live()
150
                                 ->live()
154
                                 ->required(),
151
                                 ->required(),
155
 
152
 
161
                                     ->pluck('name', 'id'))
158
                                     ->pluck('name', 'id'))
162
                                 ->searchable()
159
                                 ->searchable()
163
                                 ->required()
160
                                 ->required()
164
-                                ->visible(fn (Forms\Get $get) => $get('prefill_method') === 'previous_budget'),
161
+                                ->visible(fn (Forms\Get $get) => BudgetSourceType::parse($get('source_type'))?->isBudget()),
165
 
162
 
166
                             // If user selects to use historical actuals
163
                             // If user selects to use historical actuals
167
-                            Forms\Components\Select::make('actuals_fiscal_year')
168
-                                ->label('Reference Fiscal Year')
164
+                            Forms\Components\Select::make('source_fiscal_year')
165
+                                ->label('Fiscal Year')
169
                                 ->options(function () {
166
                                 ->options(function () {
170
                                     $options = [];
167
                                     $options = [];
171
                                     $company = auth()->user()->currentCompany;
168
                                     $company = auth()->user()->currentCompany;
193
                                     // Update the selected_accounts field to exclude accounts without actuals
190
                                     // Update the selected_accounts field to exclude accounts without actuals
194
                                     $set('selected_accounts', $accountIdsWithoutActuals);
191
                                     $set('selected_accounts', $accountIdsWithoutActuals);
195
                                 })
192
                                 })
196
-                                ->visible(fn (Forms\Get $get) => $get('prefill_method') === 'actuals'),
193
+                                ->visible(fn (Forms\Get $get) => BudgetSourceType::parse($get('source_type'))?->isActuals()),
197
                         ])->visible(fn (Forms\Get $get) => $get('prefill_data') === true),
194
                         ])->visible(fn (Forms\Get $get) => $get('prefill_data') === true),
198
 
195
 
199
                     CustomSection::make('Account Selection')
196
                     CustomSection::make('Account Selection')
226
                                     return $this->getBudgetableAccounts()->pluck('name', 'id')->toArray();
223
                                     return $this->getBudgetableAccounts()->pluck('name', 'id')->toArray();
227
                                 })
224
                                 })
228
                                 ->descriptions(function (Forms\Components\CheckboxList $component) {
225
                                 ->descriptions(function (Forms\Components\CheckboxList $component) {
229
-                                    $fiscalYear = $this->data['actuals_fiscal_year'] ?? null;
226
+                                    $fiscalYear = $this->data['source_fiscal_year'] ?? null;
230
 
227
 
231
                                     if (blank($fiscalYear)) {
228
                                     if (blank($fiscalYear)) {
232
                                         return [];
229
                                         return [];
284
                                     $set('exclude_accounts_without_actuals', $allAccountsWithoutActualsSelected);
281
                                     $set('exclude_accounts_without_actuals', $allAccountsWithoutActualsSelected);
285
                                 }),
282
                                 }),
286
                         ])
283
                         ])
287
-                        ->visible(function () {
284
+                        ->visible(function (Forms\Get $get) {
288
                             // Only show when using actuals with valid fiscal year AND accounts without transactions exist
285
                             // Only show when using actuals with valid fiscal year AND accounts without transactions exist
289
-                            $prefillMethod = $this->data['prefill_method'] ?? null;
286
+                            $prefillSourceType = BudgetSourceType::parse($get('source_type'));
290
 
287
 
291
-                            if ($prefillMethod !== 'actuals' || blank($this->data['actuals_fiscal_year'] ?? null)) {
288
+                            if ($prefillSourceType !== BudgetSourceType::Actuals || blank($get('source_fiscal_year'))) {
292
                                 return false;
289
                                 return false;
293
                             }
290
                             }
294
 
291
 
306
     {
303
     {
307
         /** @var Budget $budget */
304
         /** @var Budget $budget */
308
         $budget = Budget::create([
305
         $budget = Budget::create([
306
+            'source_budget_id' => $data['source_budget_id'] ?? null,
307
+            'source_fiscal_year' => $data['source_fiscal_year'] ?? null,
308
+            'source_type' => $data['source_type'] ?? null,
309
             'name' => $data['name'],
309
             'name' => $data['name'],
310
             'interval_type' => $data['interval_type'],
310
             'interval_type' => $data['interval_type'],
311
             'start_date' => $data['start_date'],
311
             'start_date' => $data['start_date'],
330
             $budgetEndDate = Carbon::parse($data['end_date']);
330
             $budgetEndDate = Carbon::parse($data['end_date']);
331
 
331
 
332
             // Determine amounts based on the prefill method
332
             // Determine amounts based on the prefill method
333
-            $amounts = match ($data['prefill_method'] ?? null) {
334
-                'actuals' => $this->getAmountsFromActuals($account, $data['actuals_fiscal_year'], BudgetIntervalType::parse($data['interval_type'])),
333
+            $amounts = match ($data['source_type'] ?? null) {
334
+                'actuals' => $this->getAmountsFromActuals($account, $data['source_fiscal_year'], BudgetIntervalType::parse($data['interval_type'])),
335
                 'previous_budget' => $this->getAmountsFromPreviousBudget($account, $data['source_budget_id'], BudgetIntervalType::parse($data['interval_type'])),
335
                 'previous_budget' => $this->getAmountsFromPreviousBudget($account, $data['source_budget_id'], BudgetIntervalType::parse($data['interval_type'])),
336
                 default => $this->generateZeroAmounts($data['start_date'], $data['end_date'], BudgetIntervalType::parse($data['interval_type'])),
336
                 default => $this->generateZeroAmounts($data['start_date'], $data['end_date'], BudgetIntervalType::parse($data['interval_type'])),
337
             };
337
             };
619
             BudgetIntervalType::Month => $date->format('M Y'),
619
             BudgetIntervalType::Month => $date->format('M Y'),
620
             BudgetIntervalType::Quarter => 'Q' . $date->quarter . ' ' . $date->year,
620
             BudgetIntervalType::Quarter => 'Q' . $date->quarter . ' ' . $date->year,
621
             BudgetIntervalType::Year => (string) $date->year,
621
             BudgetIntervalType::Year => (string) $date->year,
622
-            default => $date->format('Y-m-d'),
623
         };
622
         };
624
     }
623
     }
625
 
624
 

+ 84
- 11
app/Filament/Company/Resources/Accounting/BudgetResource/RelationManagers/BudgetItemsRelationManager.php View File

2
 
2
 
3
 namespace App\Filament\Company\Resources\Accounting\BudgetResource\RelationManagers;
3
 namespace App\Filament\Company\Resources\Accounting\BudgetResource\RelationManagers;
4
 
4
 
5
+use App\Filament\Tables\Columns\DeferredTextInputColumn;
6
+use App\Models\Accounting\BudgetAllocation;
5
 use App\Models\Accounting\BudgetItem;
7
 use App\Models\Accounting\BudgetItem;
8
+use Filament\Notifications\Notification;
6
 use Filament\Resources\RelationManagers\RelationManager;
9
 use Filament\Resources\RelationManagers\RelationManager;
10
+use Filament\Tables\Actions\Action;
7
 use Filament\Tables\Columns\TextColumn;
11
 use Filament\Tables\Columns\TextColumn;
8
-use Filament\Tables\Columns\TextInputColumn;
9
 use Filament\Tables\Table;
12
 use Filament\Tables\Table;
13
+use Livewire\Attributes\On;
10
 
14
 
11
 class BudgetItemsRelationManager extends RelationManager
15
 class BudgetItemsRelationManager extends RelationManager
12
 {
16
 {
14
 
18
 
15
     protected static bool $isLazy = false;
19
     protected static bool $isLazy = false;
16
 
20
 
21
+    // Store changes that are pending
22
+    public array $batchChanges = [];
23
+
24
+    // Listen for events from the custom column
25
+
26
+    #[On('batch-column-changed')]
27
+    public function handleBatchColumnChanged($data): void
28
+    {
29
+        ray($data);
30
+        // Store the changed value
31
+        $key = "{$data['recordKey']}.{$data['name']}";
32
+        $this->batchChanges[$key] = $data['value'];
33
+    }
34
+
35
+    #[On('save-batch-changes')]
36
+    public function saveBatchChanges(): void
37
+    {
38
+        ray('Saving batch changes');
39
+        foreach ($this->batchChanges as $key => $value) {
40
+            // Parse the composite key
41
+            [$recordKey, $column] = explode('.', $key, 2);
42
+
43
+            // Extract period from the column name (e.g., "allocations_by_period.2023-Q1")
44
+            preg_match('/allocations_by_period\.(.+)/', $column, $matches);
45
+            $period = $matches[1] ?? null;
46
+
47
+            if (! $period) {
48
+                continue;
49
+            }
50
+
51
+            // Find the record
52
+            $record = BudgetItem::find($recordKey);
53
+            if (! $record) {
54
+                continue;
55
+            }
56
+
57
+            // Update the allocation
58
+            $allocation = $record->allocations->firstWhere('period', $period);
59
+            if ($allocation) {
60
+                $allocation->update(['amount' => $value]);
61
+            } else {
62
+                $record->allocations()->create([
63
+                    'period' => $period,
64
+                    'amount' => $value,
65
+                    // Add other required fields
66
+                ]);
67
+            }
68
+        }
69
+
70
+        // Clear the batch changes
71
+        $this->batchChanges = [];
72
+
73
+        // Notify the user
74
+        Notification::make()
75
+            ->title('Budget allocations updated')
76
+            ->success()
77
+            ->send();
78
+    }
79
+
17
     public function table(Table $table): Table
80
     public function table(Table $table): Table
18
     {
81
     {
19
         $budget = $this->getOwnerRecord();
82
         $budget = $this->getOwnerRecord();
20
 
83
 
21
         // Get distinct periods for this budget
84
         // Get distinct periods for this budget
22
-        $periods = \App\Models\Accounting\BudgetAllocation::query()
85
+        $periods = BudgetAllocation::query()
23
             ->join('budget_items', 'budget_allocations.budget_item_id', '=', 'budget_items.id')
86
             ->join('budget_items', 'budget_allocations.budget_item_id', '=', 'budget_items.id')
24
             ->where('budget_items.budget_id', $budget->id)
87
             ->where('budget_items.budget_id', $budget->id)
25
             ->orderBy('start_date')
88
             ->orderBy('start_date')
28
             ->values()
91
             ->values()
29
             ->toArray();
92
             ->toArray();
30
 
93
 
94
+        ray($this->batchChanges);
95
+
31
         return $table
96
         return $table
32
             ->recordTitleAttribute('account_id')
97
             ->recordTitleAttribute('account_id')
33
             ->paginated(false)
98
             ->paginated(false)
34
             ->modifyQueryUsing(
99
             ->modifyQueryUsing(
35
                 fn ($query) => $query->with(['account', 'allocations'])
100
                 fn ($query) => $query->with(['account', 'allocations'])
36
             )
101
             )
102
+            ->headerActions([
103
+                Action::make('saveBatchChanges')
104
+                    ->label('Save All Changes')
105
+                    ->action('saveBatchChanges')
106
+                    ->color('primary')
107
+                    ->icon('heroicon-o-check-circle'),
108
+            ])
37
             ->columns(array_merge([
109
             ->columns(array_merge([
38
                 TextColumn::make('account.name')
110
                 TextColumn::make('account.name')
39
                     ->label('Accounts')
111
                     ->label('Accounts')
40
                     ->sortable()
112
                     ->sortable()
41
                     ->searchable(),
113
                     ->searchable(),
42
             ], collect($periods)->map(
114
             ], collect($periods)->map(
43
-                fn ($period) => TextInputColumn::make("allocations_by_period.{$period}")
115
+                fn ($period) => DeferredTextInputColumn::make("allocations_by_period.{$period}")
44
                     ->label($period)
116
                     ->label($period)
45
-                    ->getStateUsing(
46
-                        fn ($record) => $record->allocations->firstWhere('period', $period)?->amount
47
-                    )
48
-                    ->updateStateUsing(function (BudgetItem $record, $state) use ($period) {
49
-                        $allocation = $record->allocations->firstWhere('period', $period);
50
-
51
-                        if ($allocation) {
52
-                            $allocation->update(['amount' => $state]);
117
+                    ->getStateUsing(function ($record, DeferredTextInputColumn $column) use ($period) {
118
+                        $key = "{$record->getKey()}.{$column->getName()}";
119
+
120
+                        // Check if batch change exists
121
+                        if (isset($this->batchChanges[$key])) {
122
+                            return $this->batchChanges[$key];
53
                         }
123
                         }
124
+
125
+                        return $record->allocations->firstWhere('period', $period)?->amount;
54
                     })
126
                     })
127
+                    ->batchMode(),
55
             )->all()));
128
             )->all()));
56
     }
129
     }
57
 }
130
 }

+ 25
- 0
app/Filament/Tables/Columns/DeferredTextInputColumn.php View File

1
+<?php
2
+
3
+namespace App\Filament\Tables\Columns;
4
+
5
+use Closure;
6
+use Filament\Tables\Columns\TextInputColumn;
7
+
8
+class DeferredTextInputColumn extends TextInputColumn
9
+{
10
+    protected string $view = 'filament.tables.columns.deferred-text-input-column';
11
+
12
+    protected bool | Closure $batchMode = false;
13
+
14
+    public function batchMode(bool | Closure $condition = true): static
15
+    {
16
+        $this->batchMode = $condition;
17
+
18
+        return $this;
19
+    }
20
+
21
+    public function getBatchMode(): bool
22
+    {
23
+        return $this->evaluate($this->batchMode);
24
+    }
25
+}

+ 24
- 3
app/Models/Accounting/Budget.php View File

5
 use App\Concerns\Blamable;
5
 use App\Concerns\Blamable;
6
 use App\Concerns\CompanyOwned;
6
 use App\Concerns\CompanyOwned;
7
 use App\Enums\Accounting\BudgetIntervalType;
7
 use App\Enums\Accounting\BudgetIntervalType;
8
+use App\Enums\Accounting\BudgetSourceType;
8
 use App\Enums\Accounting\BudgetStatus;
9
 use App\Enums\Accounting\BudgetStatus;
9
 use App\Filament\Company\Resources\Accounting\BudgetResource;
10
 use App\Filament\Company\Resources\Accounting\BudgetResource;
11
+use App\Models\User;
10
 use Filament\Actions\Action;
12
 use Filament\Actions\Action;
11
 use Filament\Actions\MountableAction;
13
 use Filament\Actions\MountableAction;
12
 use Filament\Actions\ReplicateAction;
14
 use Filament\Actions\ReplicateAction;
14
 use Illuminate\Database\Eloquent\Casts\Attribute;
16
 use Illuminate\Database\Eloquent\Casts\Attribute;
15
 use Illuminate\Database\Eloquent\Factories\HasFactory;
17
 use Illuminate\Database\Eloquent\Factories\HasFactory;
16
 use Illuminate\Database\Eloquent\Model;
18
 use Illuminate\Database\Eloquent\Model;
19
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
17
 use Illuminate\Database\Eloquent\Relations\HasMany;
20
 use Illuminate\Database\Eloquent\Relations\HasMany;
18
 use Illuminate\Database\Eloquent\Relations\HasManyThrough;
21
 use Illuminate\Database\Eloquent\Relations\HasManyThrough;
19
 use Illuminate\Support\Carbon;
22
 use Illuminate\Support\Carbon;
26
 
29
 
27
     protected $fillable = [
30
     protected $fillable = [
28
         'company_id',
31
         'company_id',
32
+        'source_budget_id',
33
+        'source_fiscal_year',
34
+        'source_type',
29
         'name',
35
         'name',
30
         'start_date',
36
         'start_date',
31
         'end_date',
37
         'end_date',
33
         'interval_type', // day, week, month, quarter, year
39
         'interval_type', // day, week, month, quarter, year
34
         'notes',
40
         'notes',
35
         'approved_at',
41
         'approved_at',
42
+        'approved_by_id',
36
         'closed_at',
43
         'closed_at',
37
         'created_by',
44
         'created_by',
38
         'updated_by',
45
         'updated_by',
39
     ];
46
     ];
40
 
47
 
41
     protected $casts = [
48
     protected $casts = [
49
+        'source_fiscal_year' => 'integer',
50
+        'source_type' => BudgetSourceType::class,
42
         'start_date' => 'date',
51
         'start_date' => 'date',
43
         'end_date' => 'date',
52
         'end_date' => 'date',
44
         'status' => BudgetStatus::class,
53
         'status' => BudgetStatus::class,
47
         'closed_at' => 'datetime',
56
         'closed_at' => 'datetime',
48
     ];
57
     ];
49
 
58
 
59
+    public function sourceBudget(): BelongsTo
60
+    {
61
+        return $this->belongsTo(self::class, 'source_budget_id');
62
+    }
63
+
64
+    public function derivedBudgets(): HasMany
65
+    {
66
+        return $this->hasMany(self::class, 'source_budget_id');
67
+    }
68
+
69
+    public function approvedBy(): BelongsTo
70
+    {
71
+        return $this->belongsTo(User::class, 'approved_by_id');
72
+    }
73
+
50
     public function budgetItems(): HasMany
74
     public function budgetItems(): HasMany
51
     {
75
     {
52
         return $this->hasMany(BudgetItem::class);
76
         return $this->hasMany(BudgetItem::class);
59
 
83
 
60
     /**
84
     /**
61
      * Get all periods for this budget in chronological order.
85
      * Get all periods for this budget in chronological order.
62
-     *
63
-     * @return array
64
      */
86
      */
65
     public function getPeriods(): array
87
     public function getPeriods(): array
66
     {
88
     {
75
             ->toArray();
97
             ->toArray();
76
     }
98
     }
77
 
99
 
78
-
79
     public function isDraft(): bool
100
     public function isDraft(): bool
80
     {
101
     {
81
         return $this->status === BudgetStatus::Draft;
102
         return $this->status === BudgetStatus::Draft;

+ 5
- 0
database/migrations/2025_03_15_191245_create_budgets_table.php View File

14
         Schema::create('budgets', function (Blueprint $table) {
14
         Schema::create('budgets', function (Blueprint $table) {
15
             $table->id();
15
             $table->id();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
16
             $table->foreignId('company_id')->constrained()->cascadeOnDelete();
17
+            $table->foreignId('source_budget_id')->nullable()->constrained('budgets')->nullOnDelete();
18
+            // Source fiscal year
19
+            $table->year('source_fiscal_year')->nullable();
20
+            $table->string('source_type')->nullable(); // budget, actuals
17
             $table->string('name');
21
             $table->string('name');
18
             $table->date('start_date');
22
             $table->date('start_date');
19
             $table->date('end_date');
23
             $table->date('end_date');
21
             $table->string('interval_type')->default('month'); // day, week, month, quarter, year
25
             $table->string('interval_type')->default('month'); // day, week, month, quarter, year
22
             $table->text('notes')->nullable();
26
             $table->text('notes')->nullable();
23
             $table->timestamp('approved_at')->nullable();
27
             $table->timestamp('approved_at')->nullable();
28
+            $table->foreignId('approved_by_id')->nullable()->constrained('users')->nullOnDelete();
24
             $table->timestamp('closed_at')->nullable();
29
             $table->timestamp('closed_at')->nullable();
25
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
30
             $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
26
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
31
             $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();

+ 6
- 11
resources/css/filament/company/form-fields.css View File

76
     .table-repeater-container {
76
     .table-repeater-container {
77
         border: 1px solid #e5e7eb !important;
77
         border: 1px solid #e5e7eb !important;
78
         border-radius: 0 !important;
78
         border-radius: 0 !important;
79
-        box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
80
         @apply ring-0 !important;
79
         @apply ring-0 !important;
81
     }
80
     }
82
 
81
 
84
 
83
 
85
     .table-repeater-header {
84
     .table-repeater-header {
86
         background-color: #f8f9fa !important;
85
         background-color: #f8f9fa !important;
87
-        border-radius: 0 !important;
88
     }
86
     }
89
 
87
 
90
     .table-repeater-header-column {
88
     .table-repeater-header-column {
91
         border: 1px solid #e5e7eb !important;
89
         border: 1px solid #e5e7eb !important;
92
         background-color: #f8f9fa !important;
90
         background-color: #f8f9fa !important;
93
-        border-radius: 0 !important;
94
         font-weight: 600 !important;
91
         font-weight: 600 !important;
95
         padding: 8px 12px !important;
92
         padding: 8px 12px !important;
96
     }
93
     }
106
 
103
 
107
     .table-repeater-column input {
104
     .table-repeater-column input {
108
         text-align: right !important;
105
         text-align: right !important;
109
-        width: 100% !important;
110
     }
106
     }
111
 
107
 
112
     /* Flatten inputs */
108
     /* Flatten inputs */
114
     .fi-input-wrapper,
110
     .fi-input-wrapper,
115
     .fi-input {
111
     .fi-input {
116
         padding: 0 !important;
112
         padding: 0 !important;
117
-        width: 100% !important;
118
     }
113
     }
119
 
114
 
120
     .fi-input-wrp,
115
     .fi-input-wrp,
121
     .fi-fo-file-upload .filepond--root {
116
     .fi-fo-file-upload .filepond--root {
122
-        @apply ring-0 bg-transparent shadow-none !important;
117
+        @apply ring-0 bg-transparent shadow-none rounded-none !important;
123
     }
118
     }
124
 
119
 
125
     .fi-input-wrp input {
120
     .fi-input-wrp input {
126
-        @apply bg-transparent border-0 shadow-none !important;
121
+        @apply bg-transparent !important;
127
     }
122
     }
128
 
123
 
129
     /* Focus states */
124
     /* Focus states */
134
         z-index: 1 !important;
129
         z-index: 1 !important;
135
     }
130
     }
136
 
131
 
137
-    .fi-input-wrp:focus-within {
138
-        @apply ring-0 shadow-none !important;
139
-    }
140
-
141
     input:focus,
132
     input:focus,
142
     select:focus,
133
     select:focus,
143
     textarea:focus {
134
     textarea:focus {
195
     }
186
     }
196
 }
187
 }
197
 
188
 
189
+.is-spreadsheet .table-repeater-column .fi-input-wrp-suffix {
190
+    padding-right: 0 !important;
191
+}
192
+

+ 152
- 0
resources/views/filament/tables/columns/deferred-text-input-column.blade.php View File

1
+@php
2
+    use Filament\Support\Enums\Alignment;
3
+
4
+    $isDisabled = $isDisabled();
5
+    $state = $getState();
6
+    $mask = $getMask();
7
+    $batchMode = $getBatchMode();
8
+
9
+    $alignment = $getAlignment() ?? Alignment::Start;
10
+
11
+    if (! $alignment instanceof Alignment) {
12
+        $alignment = filled($alignment) ? (Alignment::tryFrom($alignment) ?? $alignment) : null;
13
+    }
14
+
15
+    if (filled($mask)) {
16
+        $type = 'text';
17
+    } else {
18
+        $type = $getType();
19
+    }
20
+@endphp
21
+
22
+<div
23
+    x-data="{
24
+        error: undefined,
25
+
26
+        isEditing: false,
27
+
28
+        isLoading: false,
29
+
30
+        name: @js($getName()),
31
+
32
+        recordKey: @js($recordKey),
33
+
34
+        state: @js($state),
35
+    }"
36
+    x-init="
37
+        () => {
38
+            Livewire.hook('commit', ({ component, commit, succeed, fail, respond }) => {
39
+                succeed(({ snapshot, effect }) => {
40
+                    $nextTick(() => {
41
+                        if (component.id !== @js($this->getId())) {
42
+                            return
43
+                        }
44
+
45
+                        if (isEditing) {
46
+                            return
47
+                        }
48
+
49
+                        if (! $refs.newState) {
50
+                            return
51
+                        }
52
+
53
+                        let newState = $refs.newState.value.replaceAll('\\'+String.fromCharCode(34), String.fromCharCode(34))
54
+
55
+                        if (state === newState) {
56
+                            return
57
+                        }
58
+
59
+                        state = newState
60
+                    })
61
+                })
62
+            })
63
+        }
64
+    "
65
+    {{
66
+        $attributes
67
+            ->merge($getExtraAttributes(), escape: false)
68
+            ->class([
69
+                'fi-ta-text-input w-full min-w-48',
70
+                'px-3 py-4' => ! $isInline(),
71
+            ])
72
+    }}
73
+>
74
+    <input
75
+        type="hidden"
76
+        value="{{ str($state)->replace('"', '\\"') }}"
77
+        x-ref="newState"
78
+    />
79
+
80
+    <x-filament::input.wrapper
81
+        :alpine-disabled="'isLoading || ' . \Illuminate\Support\Js::from($isDisabled)"
82
+        alpine-valid="error === undefined"
83
+        x-tooltip="
84
+            error === undefined
85
+                ? false
86
+                : {
87
+                    content: error,
88
+                    theme: $store.theme,
89
+                }
90
+        "
91
+        x-on:click.stop.prevent=""
92
+    >
93
+        {{-- format-ignore-start --}}
94
+        <x-filament::input
95
+            :disabled="$isDisabled"
96
+            :input-mode="$getInputMode()"
97
+            :placeholder="$getPlaceholder()"
98
+            :step="$getStep()"
99
+            :type="$type"
100
+            :x-bind:disabled="$isDisabled ? null : 'isLoading'"
101
+            x-model="state"
102
+            x-on:blur="isEditing = false"
103
+            x-on:focus="isEditing = true"
104
+            :attributes="
105
+                \Filament\Support\prepare_inherited_attributes(
106
+                    $getExtraInputAttributeBag()
107
+                        ->merge([
108
+                            'x-on:change' . ($type === 'number' ? '.debounce.1s' : null) => $batchMode ? '
109
+                                $wire.dispatch(\'batch-column-changed\', {
110
+                                    data: {
111
+                                        name: name,
112
+                                        recordKey: recordKey,
113
+                                        value: $event.target.value
114
+                                    }
115
+                                })
116
+                            ' : '
117
+                                isLoading = true
118
+
119
+                                const response = await $wire.updateTableColumnState(
120
+                                    name,
121
+                                    recordKey,
122
+                                    $event.target.value,
123
+                                )
124
+
125
+                                error = response?.error ?? undefined
126
+
127
+                                if (! error) {
128
+                                    state = response
129
+                                }
130
+
131
+                                isLoading = false
132
+                            ',
133
+                            'x-on:keydown.enter' => $batchMode ? '$wire.dispatch(\'save-batch-changes\')' : '',
134
+                            'x-mask' . ($mask instanceof \Filament\Support\RawJs ? ':dynamic' : '') => filled($mask) ? $mask : null,
135
+                        ])
136
+                        ->class([
137
+                            match ($alignment) {
138
+                                Alignment::Start => 'text-start',
139
+                                Alignment::Center => 'text-center',
140
+                                Alignment::End => 'text-end',
141
+                                Alignment::Left => 'text-left',
142
+                                Alignment::Right => 'text-right',
143
+                                Alignment::Justify, Alignment::Between => 'text-justify',
144
+                                default => $alignment,
145
+                            },
146
+                        ])
147
+                )
148
+            "
149
+        />
150
+        {{-- format-ignore-end --}}
151
+    </x-filament::input.wrapper>
152
+</div>

Loading…
Cancel
Save