Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

TransactionResource.php 15KB

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