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.

EstimateResource.php 29KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542
  1. <?php
  2. namespace App\Filament\Company\Resources\Sales;
  3. use App\Enums\Accounting\AdjustmentCategory;
  4. use App\Enums\Accounting\AdjustmentStatus;
  5. use App\Enums\Accounting\AdjustmentType;
  6. use App\Enums\Accounting\DocumentDiscountMethod;
  7. use App\Enums\Accounting\DocumentType;
  8. use App\Enums\Accounting\EstimateStatus;
  9. use App\Filament\Company\Resources\Sales\ClientResource\RelationManagers\EstimatesRelationManager;
  10. use App\Filament\Company\Resources\Sales\EstimateResource\Pages;
  11. use App\Filament\Company\Resources\Sales\EstimateResource\Widgets;
  12. use App\Filament\Forms\Components\CreateAdjustmentSelect;
  13. use App\Filament\Forms\Components\CreateCurrencySelect;
  14. use App\Filament\Forms\Components\DocumentFooterSection;
  15. use App\Filament\Forms\Components\DocumentHeaderSection;
  16. use App\Filament\Forms\Components\DocumentTotals;
  17. use App\Filament\Tables\Actions\ReplicateBulkAction;
  18. use App\Filament\Tables\Columns;
  19. use App\Filament\Tables\Filters\DateRangeFilter;
  20. use App\Models\Accounting\Adjustment;
  21. use App\Models\Accounting\DocumentLineItem;
  22. use App\Models\Accounting\Estimate;
  23. use App\Models\Common\Client;
  24. use App\Models\Common\Offering;
  25. use App\Utilities\Currency\CurrencyAccessor;
  26. use App\Utilities\Currency\CurrencyConverter;
  27. use App\Utilities\RateCalculator;
  28. use Awcodes\TableRepeater\Components\TableRepeater;
  29. use Awcodes\TableRepeater\Header;
  30. use Filament\Forms;
  31. use Filament\Forms\Form;
  32. use Filament\Notifications\Notification;
  33. use Filament\Resources\Resource;
  34. use Filament\Support\Enums\MaxWidth;
  35. use Filament\Tables;
  36. use Filament\Tables\Table;
  37. use Illuminate\Database\Eloquent\Collection;
  38. use Illuminate\Support\Facades\Auth;
  39. class EstimateResource extends Resource
  40. {
  41. protected static ?string $model = Estimate::class;
  42. public static function form(Form $form): Form
  43. {
  44. $company = Auth::user()->currentCompany;
  45. $settings = $company->defaultEstimate;
  46. return $form
  47. ->schema([
  48. DocumentHeaderSection::make('Estimate Header')
  49. ->defaultHeader($settings->header)
  50. ->defaultSubheader($settings->subheader),
  51. Forms\Components\Section::make('Estimate Details')
  52. ->schema([
  53. Forms\Components\Split::make([
  54. Forms\Components\Group::make([
  55. Forms\Components\Select::make('client_id')
  56. ->relationship('client', 'name')
  57. ->preload()
  58. ->searchable()
  59. ->required()
  60. ->live()
  61. ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
  62. if (! $state) {
  63. return;
  64. }
  65. $currencyCode = Client::find($state)?->currency_code;
  66. if ($currencyCode) {
  67. $set('currency_code', $currencyCode);
  68. }
  69. }),
  70. CreateCurrencySelect::make('currency_code'),
  71. ]),
  72. Forms\Components\Group::make([
  73. Forms\Components\TextInput::make('estimate_number')
  74. ->label('Estimate number')
  75. ->default(static fn () => Estimate::getNextDocumentNumber()),
  76. Forms\Components\TextInput::make('reference_number')
  77. ->label('Reference number'),
  78. Forms\Components\DatePicker::make('date')
  79. ->label('Estimate date')
  80. ->live()
  81. ->default(now())
  82. ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
  83. $date = $state;
  84. $expirationDate = $get('expiration_date');
  85. if ($date && $expirationDate && $date > $expirationDate) {
  86. $set('expiration_date', $date);
  87. }
  88. }),
  89. Forms\Components\DatePicker::make('expiration_date')
  90. ->label('Expiration date')
  91. ->default(function () use ($settings) {
  92. return now()->addDays($settings->payment_terms->getDays());
  93. })
  94. ->minDate(static function (Forms\Get $get) {
  95. return $get('date') ?? now();
  96. }),
  97. Forms\Components\Select::make('discount_method')
  98. ->label('Discount method')
  99. ->options(DocumentDiscountMethod::class)
  100. ->selectablePlaceholder(false)
  101. ->default(DocumentDiscountMethod::PerLineItem)
  102. ->afterStateUpdated(function ($state, Forms\Set $set) {
  103. $discountMethod = DocumentDiscountMethod::parse($state);
  104. if ($discountMethod->isPerDocument()) {
  105. $set('lineItems.*.salesDiscounts', []);
  106. }
  107. })
  108. ->live(),
  109. ])->grow(true),
  110. ])->from('md'),
  111. TableRepeater::make('lineItems')
  112. ->relationship()
  113. ->saveRelationshipsUsing(null)
  114. ->dehydrated(true)
  115. ->headers(function (Forms\Get $get) use ($settings) {
  116. $hasDiscounts = DocumentDiscountMethod::parse($get('discount_method'))->isPerLineItem();
  117. $headers = [
  118. Header::make($settings->resolveColumnLabel('item_name', 'Items'))
  119. ->width($hasDiscounts ? '15%' : '20%'),
  120. Header::make('Description')
  121. ->width($hasDiscounts ? '15%' : '20%'),
  122. Header::make($settings->resolveColumnLabel('unit_name', 'Quantity'))
  123. ->width('10%'),
  124. Header::make($settings->resolveColumnLabel('price_name', 'Price'))
  125. ->width('10%'),
  126. Header::make('Taxes')
  127. ->width($hasDiscounts ? '20%' : '30%'),
  128. ];
  129. if ($hasDiscounts) {
  130. $headers[] = Header::make('Discounts')->width('20%');
  131. }
  132. $headers[] = Header::make($settings->resolveColumnLabel('amount_name', 'Amount'))
  133. ->width('10%')
  134. ->align('right');
  135. return $headers;
  136. })
  137. ->schema([
  138. Forms\Components\Select::make('offering_id')
  139. ->relationship('sellableOffering', 'name')
  140. ->preload()
  141. ->searchable()
  142. ->required()
  143. ->live()
  144. ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state, ?DocumentLineItem $record) {
  145. $offeringId = $state;
  146. $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
  147. $isPerLineItem = $discountMethod->isPerLineItem();
  148. $existingTaxIds = [];
  149. $existingDiscountIds = [];
  150. if ($record) {
  151. $existingTaxIds = $record->salesTaxes()->pluck('adjustments.id')->toArray();
  152. if ($isPerLineItem) {
  153. $existingDiscountIds = $record->salesDiscounts()->pluck('adjustments.id')->toArray();
  154. }
  155. }
  156. $with = [
  157. 'salesTaxes' => static function ($query) use ($existingTaxIds) {
  158. $query->where(static function ($query) use ($existingTaxIds) {
  159. $query->where('status', AdjustmentStatus::Active)
  160. ->orWhereIn('adjustments.id', $existingTaxIds);
  161. });
  162. },
  163. ];
  164. if ($isPerLineItem) {
  165. $with['salesDiscounts'] = static function ($query) use ($existingDiscountIds) {
  166. $query->where(static function ($query) use ($existingDiscountIds) {
  167. $query->where('status', AdjustmentStatus::Active)
  168. ->orWhereIn('adjustments.id', $existingDiscountIds);
  169. });
  170. };
  171. }
  172. $offeringRecord = Offering::with($with)->find($offeringId);
  173. if (! $offeringRecord) {
  174. return;
  175. }
  176. $unitPrice = CurrencyConverter::convertToFloat($offeringRecord->price, $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency());
  177. $set('description', $offeringRecord->description);
  178. $set('unit_price', $unitPrice);
  179. $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
  180. if ($isPerLineItem) {
  181. $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
  182. }
  183. }),
  184. Forms\Components\TextInput::make('description'),
  185. Forms\Components\TextInput::make('quantity')
  186. ->required()
  187. ->numeric()
  188. ->live()
  189. ->maxValue(9999999999.99)
  190. ->default(1),
  191. Forms\Components\TextInput::make('unit_price')
  192. ->hiddenLabel()
  193. ->numeric()
  194. ->live()
  195. ->maxValue(9999999999.99)
  196. ->default(0),
  197. CreateAdjustmentSelect::make('salesTaxes')
  198. ->label('Taxes')
  199. ->category(AdjustmentCategory::Tax)
  200. ->type(AdjustmentType::Sales)
  201. ->adjustmentsRelationship('salesTaxes')
  202. ->saveRelationshipsUsing(null)
  203. ->dehydrated(true)
  204. ->preload()
  205. ->multiple()
  206. ->live()
  207. ->searchable(),
  208. CreateAdjustmentSelect::make('salesDiscounts')
  209. ->label('Discounts')
  210. ->category(AdjustmentCategory::Discount)
  211. ->type(AdjustmentType::Sales)
  212. ->adjustmentsRelationship('salesDiscounts')
  213. ->saveRelationshipsUsing(null)
  214. ->dehydrated(true)
  215. ->multiple()
  216. ->live()
  217. ->hidden(function (Forms\Get $get) {
  218. $discountMethod = DocumentDiscountMethod::parse($get('../../discount_method'));
  219. return $discountMethod->isPerDocument();
  220. })
  221. ->searchable(),
  222. Forms\Components\Placeholder::make('total')
  223. ->hiddenLabel()
  224. ->extraAttributes(['class' => 'text-left sm:text-right'])
  225. ->content(function (Forms\Get $get) {
  226. $quantity = max((float) ($get('quantity') ?? 0), 0);
  227. $unitPrice = max((float) ($get('unit_price') ?? 0), 0);
  228. $salesTaxes = $get('salesTaxes') ?? [];
  229. $salesDiscounts = $get('salesDiscounts') ?? [];
  230. $currencyCode = $get('../../currency_code') ?? CurrencyAccessor::getDefaultCurrency();
  231. $subtotal = $quantity * $unitPrice;
  232. $subtotalInCents = CurrencyConverter::convertToCents($subtotal, $currencyCode);
  233. $taxAmountInCents = Adjustment::whereIn('id', $salesTaxes)
  234. ->get()
  235. ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
  236. if ($adjustment->computation->isPercentage()) {
  237. return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
  238. } else {
  239. return $adjustment->getRawOriginal('rate');
  240. }
  241. });
  242. $discountAmountInCents = Adjustment::whereIn('id', $salesDiscounts)
  243. ->get()
  244. ->sum(function (Adjustment $adjustment) use ($subtotalInCents) {
  245. if ($adjustment->computation->isPercentage()) {
  246. return RateCalculator::calculatePercentage($subtotalInCents, $adjustment->getRawOriginal('rate'));
  247. } else {
  248. return $adjustment->getRawOriginal('rate');
  249. }
  250. });
  251. // Final total
  252. $totalInCents = $subtotalInCents + ($taxAmountInCents - $discountAmountInCents);
  253. return CurrencyConverter::formatCentsToMoney($totalInCents, $currencyCode);
  254. }),
  255. ]),
  256. DocumentTotals::make()
  257. ->type(DocumentType::Estimate),
  258. Forms\Components\Textarea::make('terms')
  259. ->default($settings->terms)
  260. ->columnSpanFull(),
  261. ]),
  262. DocumentFooterSection::make('Estimate Footer')
  263. ->defaultFooter($settings->footer),
  264. ]);
  265. }
  266. public static function table(Table $table): Table
  267. {
  268. return $table
  269. ->defaultSort('expiration_date')
  270. ->columns([
  271. Columns::id(),
  272. Tables\Columns\TextColumn::make('status')
  273. ->badge()
  274. ->searchable(),
  275. Tables\Columns\TextColumn::make('expiration_date')
  276. ->label('Expiration date')
  277. ->asRelativeDay()
  278. ->sortable(),
  279. Tables\Columns\TextColumn::make('date')
  280. ->date()
  281. ->sortable(),
  282. Tables\Columns\TextColumn::make('estimate_number')
  283. ->label('Number')
  284. ->searchable()
  285. ->sortable(),
  286. Tables\Columns\TextColumn::make('client.name')
  287. ->sortable()
  288. ->searchable()
  289. ->hiddenOn(EstimatesRelationManager::class),
  290. Tables\Columns\TextColumn::make('total')
  291. ->currencyWithConversion(static fn (Estimate $record) => $record->currency_code)
  292. ->sortable()
  293. ->alignEnd(),
  294. ])
  295. ->filters([
  296. Tables\Filters\SelectFilter::make('client')
  297. ->relationship('client', 'name')
  298. ->searchable()
  299. ->preload()
  300. ->hiddenOn(EstimatesRelationManager::class),
  301. Tables\Filters\SelectFilter::make('status')
  302. ->options(EstimateStatus::class)
  303. ->native(false),
  304. DateRangeFilter::make('date')
  305. ->fromLabel('From date')
  306. ->untilLabel('To date')
  307. ->indicatorLabel('Date'),
  308. DateRangeFilter::make('expiration_date')
  309. ->fromLabel('From expiration date')
  310. ->untilLabel('To expiration date')
  311. ->indicatorLabel('Due'),
  312. ])
  313. ->actions([
  314. Tables\Actions\ActionGroup::make([
  315. Tables\Actions\ActionGroup::make([
  316. Tables\Actions\EditAction::make()
  317. ->url(static fn (Estimate $record) => Pages\EditEstimate::getUrl(['record' => $record])),
  318. Tables\Actions\ViewAction::make()
  319. ->url(static fn (Estimate $record) => Pages\ViewEstimate::getUrl(['record' => $record])),
  320. Estimate::getReplicateAction(Tables\Actions\ReplicateAction::class),
  321. Estimate::getApproveDraftAction(Tables\Actions\Action::class),
  322. Estimate::getMarkAsSentAction(Tables\Actions\Action::class),
  323. Estimate::getMarkAsAcceptedAction(Tables\Actions\Action::class),
  324. Estimate::getMarkAsDeclinedAction(Tables\Actions\Action::class),
  325. Estimate::getConvertToInvoiceAction(Tables\Actions\Action::class),
  326. ])->dropdown(false),
  327. Tables\Actions\DeleteAction::make(),
  328. ]),
  329. ])
  330. ->bulkActions([
  331. Tables\Actions\BulkActionGroup::make([
  332. Tables\Actions\DeleteBulkAction::make(),
  333. ReplicateBulkAction::make()
  334. ->label('Replicate')
  335. ->modalWidth(MaxWidth::Large)
  336. ->modalDescription('Replicating estimates will also replicate their line items. Are you sure you want to proceed?')
  337. ->successNotificationTitle('Estimates replicated successfully')
  338. ->failureNotificationTitle('Failed to replicate estimates')
  339. ->databaseTransaction()
  340. ->deselectRecordsAfterCompletion()
  341. ->excludeAttributes([
  342. 'estimate_number',
  343. 'date',
  344. 'expiration_date',
  345. 'approved_at',
  346. 'accepted_at',
  347. 'converted_at',
  348. 'declined_at',
  349. 'last_sent_at',
  350. 'last_viewed_at',
  351. 'status',
  352. 'created_by',
  353. 'updated_by',
  354. 'created_at',
  355. 'updated_at',
  356. ])
  357. ->beforeReplicaSaved(function (Estimate $replica) {
  358. $replica->status = EstimateStatus::Draft;
  359. $replica->estimate_number = Estimate::getNextDocumentNumber();
  360. $replica->date = now();
  361. $replica->expiration_date = now()->addDays($replica->company->defaultInvoice->payment_terms->getDays());
  362. })
  363. ->withReplicatedRelationships(['lineItems'])
  364. ->withExcludedRelationshipAttributes('lineItems', [
  365. 'subtotal',
  366. 'total',
  367. 'created_by',
  368. 'updated_by',
  369. 'created_at',
  370. 'updated_at',
  371. ]),
  372. Tables\Actions\BulkAction::make('approveDrafts')
  373. ->label('Approve')
  374. ->icon('heroicon-o-check-circle')
  375. ->databaseTransaction()
  376. ->successNotificationTitle('Estimates approved')
  377. ->failureNotificationTitle('Failed to approve estimates')
  378. ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
  379. $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeApproved());
  380. if ($isInvalid) {
  381. Notification::make()
  382. ->title('Approval failed')
  383. ->body('Only draft estimates can be approved. Please adjust your selection and try again.')
  384. ->persistent()
  385. ->danger()
  386. ->send();
  387. $action->cancel(true);
  388. }
  389. })
  390. ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
  391. $records->each(function (Estimate $record) {
  392. $record->approveDraft();
  393. });
  394. $action->success();
  395. }),
  396. Tables\Actions\BulkAction::make('markAsSent')
  397. ->label('Mark as sent')
  398. ->icon('heroicon-o-paper-airplane')
  399. ->databaseTransaction()
  400. ->successNotificationTitle('Estimates sent')
  401. ->failureNotificationTitle('Failed to mark estimates as sent')
  402. ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
  403. $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsSent());
  404. if ($isInvalid) {
  405. Notification::make()
  406. ->title('Sending failed')
  407. ->body('Only unsent estimates can be marked as sent. Please adjust your selection and try again.')
  408. ->persistent()
  409. ->danger()
  410. ->send();
  411. $action->cancel(true);
  412. }
  413. })
  414. ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
  415. $records->each(function (Estimate $record) {
  416. $record->markAsSent();
  417. });
  418. $action->success();
  419. }),
  420. Tables\Actions\BulkAction::make('markAsAccepted')
  421. ->label('Mark as accepted')
  422. ->icon('heroicon-o-check-badge')
  423. ->databaseTransaction()
  424. ->successNotificationTitle('Estimates accepted')
  425. ->failureNotificationTitle('Failed to mark estimates as accepted')
  426. ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
  427. $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsAccepted());
  428. if ($isInvalid) {
  429. Notification::make()
  430. ->title('Acceptance failed')
  431. ->body('Only sent estimates that haven\'t been accepted can be marked as accepted. Please adjust your selection and try again.')
  432. ->persistent()
  433. ->danger()
  434. ->send();
  435. $action->cancel(true);
  436. }
  437. })
  438. ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
  439. $records->each(function (Estimate $record) {
  440. $record->markAsAccepted();
  441. });
  442. $action->success();
  443. }),
  444. Tables\Actions\BulkAction::make('markAsDeclined')
  445. ->label('Mark as declined')
  446. ->icon('heroicon-o-x-circle')
  447. ->requiresConfirmation()
  448. ->databaseTransaction()
  449. ->color('danger')
  450. ->modalHeading('Mark Estimates as Declined')
  451. ->modalDescription('Are you sure you want to mark the selected estimates as declined? This action cannot be undone.')
  452. ->successNotificationTitle('Estimates declined')
  453. ->failureNotificationTitle('Failed to mark estimates as declined')
  454. ->before(function (Collection $records, Tables\Actions\BulkAction $action) {
  455. $isInvalid = $records->contains(fn (Estimate $record) => ! $record->canBeMarkedAsDeclined());
  456. if ($isInvalid) {
  457. Notification::make()
  458. ->title('Declination failed')
  459. ->body('Only sent estimates that haven\'t been declined can be marked as declined. Please adjust your selection and try again.')
  460. ->persistent()
  461. ->danger()
  462. ->send();
  463. $action->cancel(true);
  464. }
  465. })
  466. ->action(function (Collection $records, Tables\Actions\BulkAction $action) {
  467. $records->each(function (Estimate $record) {
  468. $record->markAsDeclined();
  469. });
  470. $action->success();
  471. }),
  472. ]),
  473. ]);
  474. }
  475. public static function getRelations(): array
  476. {
  477. return [
  478. //
  479. ];
  480. }
  481. public static function getPages(): array
  482. {
  483. return [
  484. 'index' => Pages\ListEstimates::route('/'),
  485. 'create' => Pages\CreateEstimate::route('/create'),
  486. 'view' => Pages\ViewEstimate::route('/{record}'),
  487. 'edit' => Pages\EditEstimate::route('/{record}/edit'),
  488. ];
  489. }
  490. public static function getWidgets(): array
  491. {
  492. return [
  493. Widgets\EstimateOverview::class,
  494. ];
  495. }
  496. }