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 16KB

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