You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

TransactionResource.php 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. <?php
  2. namespace App\Filament\Company\Resources\Accounting;
  3. use App\Enums\Accounting\TransactionType;
  4. use App\Filament\Company\Resources\Accounting\TransactionResource\Pages;
  5. use App\Filament\Forms\Components\DateRangeSelect;
  6. use App\Filament\Tables\Actions\EditTransactionAction;
  7. use App\Filament\Tables\Actions\ReplicateBulkAction;
  8. use App\Models\Accounting\JournalEntry;
  9. use App\Models\Accounting\Transaction;
  10. use Exception;
  11. use Filament\Forms\Components\DatePicker;
  12. use Filament\Forms\Components\Grid;
  13. use Filament\Forms\Form;
  14. use Filament\Forms\Set;
  15. use Filament\Notifications\Notification;
  16. use Filament\Resources\Resource;
  17. use Filament\Support\Colors\Color;
  18. use Filament\Support\Enums\FontWeight;
  19. use Filament\Support\Enums\MaxWidth;
  20. use Filament\Tables;
  21. use Filament\Tables\Table;
  22. use Illuminate\Database\Eloquent\Builder;
  23. use Illuminate\Database\Eloquent\Collection;
  24. use Illuminate\Support\Carbon;
  25. class TransactionResource extends Resource
  26. {
  27. protected static ?string $model = Transaction::class;
  28. protected static ?string $navigationGroup = 'Accounting';
  29. protected static ?string $navigationIcon = 'heroicon-o-banknotes';
  30. protected static ?string $recordTitleAttribute = 'description';
  31. protected static bool $isGloballySearchable = false;
  32. public static function form(Form $form): Form
  33. {
  34. return $form
  35. ->schema([]);
  36. }
  37. public static function table(Table $table): Table
  38. {
  39. return $table
  40. ->modifyQueryUsing(function (Builder $query) {
  41. $query->with([
  42. 'account',
  43. 'bankAccount.account',
  44. 'journalEntries.account',
  45. ])
  46. ->where(function (Builder $query) {
  47. $query->whereNull('transactionable_id')
  48. ->orWhere('is_payment', true);
  49. });
  50. })
  51. ->columns([
  52. Tables\Columns\TextColumn::make('posted_at')
  53. ->label('Date')
  54. ->sortable()
  55. ->defaultDateFormat(),
  56. Tables\Columns\TextColumn::make('type')
  57. ->label('Type')
  58. ->sortable()
  59. ->toggleable(isToggledHiddenByDefault: true),
  60. Tables\Columns\TextColumn::make('description')
  61. ->label('Description')
  62. ->limit(50)
  63. ->toggleable(),
  64. Tables\Columns\TextColumn::make('bankAccount.account.name')
  65. ->label('Account')
  66. ->toggleable(),
  67. Tables\Columns\TextColumn::make('account.name')
  68. ->label('Category')
  69. ->prefix(static fn (Transaction $transaction) => $transaction->type->isTransfer() ? 'Transfer to ' : null)
  70. ->toggleable()
  71. ->state(static fn (Transaction $transaction) => $transaction->account->name ?? 'Journal Entry'),
  72. Tables\Columns\TextColumn::make('amount')
  73. ->label('Amount')
  74. ->weight(static fn (Transaction $transaction) => $transaction->reviewed ? null : FontWeight::SemiBold)
  75. ->color(
  76. static fn (Transaction $transaction) => match ($transaction->type) {
  77. TransactionType::Deposit => Color::rgb('rgb(' . Color::Green[700] . ')'),
  78. TransactionType::Journal => 'primary',
  79. default => null,
  80. }
  81. )
  82. ->sortable()
  83. ->currency(static fn (Transaction $transaction) => $transaction->bankAccount?->account->currency_code),
  84. ])
  85. ->recordClasses(static fn (Transaction $transaction) => $transaction->reviewed ? 'bg-primary-300/10' : null)
  86. ->defaultSort('posted_at', 'desc')
  87. ->filters([
  88. Tables\Filters\SelectFilter::make('bank_account_id')
  89. ->label('Account')
  90. ->searchable()
  91. ->options(fn () => Transaction::getBankAccountOptions(false)),
  92. Tables\Filters\SelectFilter::make('account_id')
  93. ->label('Category')
  94. ->multiple()
  95. ->options(fn () => Transaction::getChartAccountOptions(nominalAccountsOnly: false)),
  96. Tables\Filters\TernaryFilter::make('reviewed')
  97. ->label('Status')
  98. ->native(false)
  99. ->trueLabel('Reviewed')
  100. ->falseLabel('Not Reviewed'),
  101. Tables\Filters\SelectFilter::make('type')
  102. ->label('Type')
  103. ->native(false)
  104. ->options(TransactionType::class),
  105. static::buildDateRangeFilter('posted_at', 'Posted', true),
  106. static::buildDateRangeFilter('updated_at', 'Last modified'),
  107. ])
  108. ->filtersFormSchema(fn (array $filters): array => [
  109. Grid::make()
  110. ->schema([
  111. $filters['bank_account_id'],
  112. $filters['account_id'],
  113. $filters['reviewed'],
  114. $filters['type'],
  115. ])
  116. ->columnSpanFull()
  117. ->extraAttributes(['class' => 'border-b border-gray-200 dark:border-white/10 pb-8']),
  118. $filters['posted_at'],
  119. $filters['updated_at'],
  120. ])
  121. ->filtersFormWidth(MaxWidth::ThreeExtraLarge)
  122. ->actions([
  123. Tables\Actions\Action::make('markAsReviewed')
  124. ->label('Mark as reviewed')
  125. ->view('filament.company.components.tables.actions.mark-as-reviewed')
  126. ->icon(static fn (Transaction $transaction) => $transaction->reviewed ? 'heroicon-s-check-circle' : 'heroicon-o-check-circle')
  127. ->color(static fn (Transaction $transaction, Tables\Actions\Action $action) => match (static::determineTransactionState($transaction, $action)) {
  128. 'reviewed' => 'primary',
  129. 'unreviewed' => Color::rgb('rgb(' . Color::Gray[600] . ')'),
  130. 'uncategorized' => 'gray',
  131. })
  132. ->tooltip(static fn (Transaction $transaction, Tables\Actions\Action $action) => match (static::determineTransactionState($transaction, $action)) {
  133. 'reviewed' => 'Reviewed',
  134. 'unreviewed' => 'Mark as reviewed',
  135. 'uncategorized' => 'Categorize first to mark as reviewed',
  136. })
  137. ->disabled(fn (Transaction $transaction): bool => $transaction->isUncategorized())
  138. ->action(fn (Transaction $transaction) => $transaction->update(['reviewed' => ! $transaction->reviewed])),
  139. Tables\Actions\ActionGroup::make([
  140. Tables\Actions\ActionGroup::make([
  141. EditTransactionAction::make('editTransaction')
  142. ->label('Edit transaction')
  143. ->visible(static fn (Transaction $transaction) => $transaction->type->isStandard() && ! $transaction->transactionable_id),
  144. EditTransactionAction::make('editTransfer')
  145. ->label('Edit transfer')
  146. ->type(TransactionType::Transfer)
  147. ->visible(static fn (Transaction $transaction) => $transaction->type->isTransfer()),
  148. EditTransactionAction::make('editJournalTransaction')
  149. ->label('Edit journal transaction')
  150. ->type(TransactionType::Journal)
  151. ->visible(static fn (Transaction $transaction) => $transaction->type->isJournal() && ! $transaction->transactionable_id),
  152. Tables\Actions\ReplicateAction::make()
  153. ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
  154. ->modal(false)
  155. ->beforeReplicaSaved(static function (Transaction $replica) {
  156. $replica->description = '(Copy of) ' . $replica->description;
  157. })
  158. ->hidden(static fn (Transaction $transaction) => $transaction->transactionable_id)
  159. ->after(static function (Transaction $original, Transaction $replica) {
  160. $original->journalEntries->each(function (JournalEntry $entry) use ($replica) {
  161. $entry->replicate([
  162. 'transaction_id',
  163. ])->fill([
  164. 'transaction_id' => $replica->id,
  165. ])->save();
  166. });
  167. }),
  168. ])->dropdown(false),
  169. Tables\Actions\DeleteAction::make(),
  170. ]),
  171. ])
  172. ->bulkActions([
  173. Tables\Actions\BulkActionGroup::make([
  174. Tables\Actions\DeleteBulkAction::make(),
  175. ReplicateBulkAction::make()
  176. ->label('Replicate')
  177. ->modalWidth(MaxWidth::Large)
  178. ->modalDescription('Replicating transactions will also replicate their journal entries. Are you sure you want to proceed?')
  179. ->successNotificationTitle('Transactions replicated successfully')
  180. ->failureNotificationTitle('Failed to replicate transactions')
  181. ->deselectRecordsAfterCompletion()
  182. ->excludeAttributes(['created_by', 'updated_by', 'created_at', 'updated_at'])
  183. ->beforeReplicaSaved(static function (Transaction $replica) {
  184. $replica->description = '(Copy of) ' . $replica->description;
  185. })
  186. ->before(function (Collection $records, ReplicateBulkAction $action) {
  187. $isInvalid = $records->contains(fn (Transaction $record) => $record->transactionable_id);
  188. if ($isInvalid) {
  189. Notification::make()
  190. ->title('Cannot replicate transactions')
  191. ->body('You cannot replicate transactions associated with bills or invoices')
  192. ->persistent()
  193. ->danger()
  194. ->send();
  195. $action->cancel(true);
  196. }
  197. })
  198. ->withReplicatedRelationships(['journalEntries']),
  199. ]),
  200. ]);
  201. }
  202. public static function getRelations(): array
  203. {
  204. return [
  205. //
  206. ];
  207. }
  208. public static function getPages(): array
  209. {
  210. return [
  211. 'index' => Pages\ListTransactions::route('/'),
  212. 'view' => Pages\ViewTransaction::route('/{record}'),
  213. ];
  214. }
  215. /**
  216. * @throws Exception
  217. */
  218. public static function buildDateRangeFilter(string $fieldPrefix, string $label, bool $hasBottomBorder = false): Tables\Filters\Filter
  219. {
  220. return Tables\Filters\Filter::make($fieldPrefix)
  221. ->columnSpanFull()
  222. ->form([
  223. Grid::make()
  224. ->live()
  225. ->schema([
  226. DateRangeSelect::make("{$fieldPrefix}_date_range")
  227. ->label($label)
  228. ->selectablePlaceholder(false)
  229. ->placeholder('Select a date range')
  230. ->startDateField("{$fieldPrefix}_start_date")
  231. ->endDateField("{$fieldPrefix}_end_date"),
  232. DatePicker::make("{$fieldPrefix}_start_date")
  233. ->label("{$label} from")
  234. ->columnStart(1)
  235. ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
  236. $set("{$fieldPrefix}_date_range", 'Custom');
  237. }),
  238. DatePicker::make("{$fieldPrefix}_end_date")
  239. ->label("{$label} to")
  240. ->afterStateUpdated(static function (Set $set) use ($fieldPrefix) {
  241. $set("{$fieldPrefix}_date_range", 'Custom');
  242. }),
  243. ])
  244. ->extraAttributes($hasBottomBorder ? ['class' => 'border-b border-gray-200 dark:border-white/10 pb-8'] : []),
  245. ])
  246. ->query(function (Builder $query, array $data) use ($fieldPrefix): Builder {
  247. $query
  248. ->when($data["{$fieldPrefix}_start_date"], fn (Builder $query, $startDate) => $query->whereDate($fieldPrefix, '>=', $startDate))
  249. ->when($data["{$fieldPrefix}_end_date"], fn (Builder $query, $endDate) => $query->whereDate($fieldPrefix, '<=', $endDate));
  250. return $query;
  251. })
  252. ->indicateUsing(function (array $data) use ($fieldPrefix, $label): array {
  253. $indicators = [];
  254. static::addIndicatorForDateRange($data, "{$fieldPrefix}_start_date", "{$fieldPrefix}_end_date", $label, $indicators);
  255. return $indicators;
  256. });
  257. }
  258. public static function addIndicatorForDateRange($data, $startKey, $endKey, $labelPrefix, &$indicators): void
  259. {
  260. $formattedStartDate = filled($data[$startKey]) ? Carbon::parse($data[$startKey])->toFormattedDateString() : null;
  261. $formattedEndDate = filled($data[$endKey]) ? Carbon::parse($data[$endKey])->toFormattedDateString() : null;
  262. if ($formattedStartDate && $formattedEndDate) {
  263. // If both start and end dates are set, show the combined date range as the indicator, no specific field needs to be removed since the entire filter will be removed
  264. $indicators[] = Tables\Filters\Indicator::make("{$labelPrefix}: {$formattedStartDate} - {$formattedEndDate}");
  265. } else {
  266. if ($formattedStartDate) {
  267. $indicators[] = Tables\Filters\Indicator::make("{$labelPrefix} After: {$formattedStartDate}")
  268. ->removeField($startKey);
  269. }
  270. if ($formattedEndDate) {
  271. $indicators[] = Tables\Filters\Indicator::make("{$labelPrefix} Before: {$formattedEndDate}")
  272. ->removeField($endKey);
  273. }
  274. }
  275. }
  276. protected static function determineTransactionState(Transaction $transaction, Tables\Actions\Action $action): string
  277. {
  278. if ($transaction->reviewed) {
  279. return 'reviewed';
  280. }
  281. if ($transaction->reviewed === false && $action->isEnabled()) {
  282. return 'unreviewed';
  283. }
  284. return 'uncategorized';
  285. }
  286. }