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

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