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.

DocumentResource.php 26KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <?php
  2. namespace App\Filament\Company\Resources\Accounting;
  3. use App\Enums\Accounting\AdjustmentCategory;
  4. use App\Filament\Company\Resources\Accounting\DocumentResource\Pages;
  5. use App\Models\Accounting\Adjustment;
  6. use App\Models\Accounting\Document;
  7. use App\Models\Accounting\DocumentLineItem;
  8. use App\Models\Common\Offering;
  9. use App\Utilities\Currency\CurrencyAccessor;
  10. use Awcodes\TableRepeater\Components\TableRepeater;
  11. use Awcodes\TableRepeater\Header;
  12. use Carbon\CarbonInterface;
  13. use Filament\Forms;
  14. use Filament\Forms\Components\FileUpload;
  15. use Filament\Forms\Form;
  16. use Filament\Resources\Resource;
  17. use Filament\Support\Enums\MaxWidth;
  18. use Filament\Tables;
  19. use Filament\Tables\Table;
  20. use Illuminate\Database\Eloquent\Model;
  21. use Illuminate\Support\Carbon;
  22. use Illuminate\Support\Facades\Auth;
  23. use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
  24. class DocumentResource extends Resource
  25. {
  26. protected static ?string $model = Document::class;
  27. protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
  28. public static function form(Form $form): Form
  29. {
  30. $company = Auth::user()->currentCompany;
  31. return $form
  32. ->schema([
  33. Forms\Components\Section::make('Invoice Header')
  34. ->collapsible()
  35. ->schema([
  36. Forms\Components\Split::make([
  37. Forms\Components\Group::make([
  38. FileUpload::make('logo')
  39. ->openable()
  40. ->maxSize(1024)
  41. ->localizeLabel()
  42. ->visibility('public')
  43. ->disk('public')
  44. ->directory('logos/document')
  45. ->imageResizeMode('contain')
  46. ->imageCropAspectRatio('3:2')
  47. ->panelAspectRatio('3:2')
  48. ->maxWidth(MaxWidth::ExtraSmall)
  49. ->panelLayout('integrated')
  50. ->removeUploadedFileButtonPosition('center bottom')
  51. ->uploadButtonPosition('center bottom')
  52. ->uploadProgressIndicatorPosition('center bottom')
  53. ->getUploadedFileNameForStorageUsing(
  54. static fn (TemporaryUploadedFile $file): string => (string) str($file->getClientOriginalName())
  55. ->prepend(Auth::user()->currentCompany->id . '_'),
  56. )
  57. ->acceptedFileTypes(['image/png', 'image/jpeg', 'image/gif']),
  58. ]),
  59. Forms\Components\Group::make([
  60. Forms\Components\TextInput::make('header')
  61. ->default(fn () => $company->defaultInvoice->header),
  62. Forms\Components\TextInput::make('subheader')
  63. ->default(fn () => $company->defaultInvoice->subheader),
  64. Forms\Components\View::make('filament.forms.components.company-info')
  65. ->viewData([
  66. 'company_name' => $company->name,
  67. 'company_address' => $company->profile->address,
  68. 'company_city' => $company->profile->city?->name,
  69. 'company_state' => $company->profile->state?->name,
  70. 'company_zip' => $company->profile->zip_code,
  71. 'company_country' => $company->profile->state?->country->name,
  72. ]),
  73. ])->grow(true),
  74. ])->from('md'),
  75. ]),
  76. Forms\Components\Section::make('Invoice Details')
  77. ->schema([
  78. Forms\Components\Split::make([
  79. Forms\Components\Group::make([
  80. Forms\Components\Select::make('client_id')
  81. ->relationship('client', 'name')
  82. ->preload()
  83. ->searchable()
  84. ->required(),
  85. ]),
  86. Forms\Components\Group::make([
  87. Forms\Components\TextInput::make('document_number')
  88. ->label('Invoice Number')
  89. ->default(fn () => Document::getNextDocumentNumber()),
  90. Forms\Components\TextInput::make('order_number')
  91. ->label('P.O/S.O Number'),
  92. Forms\Components\DatePicker::make('date')
  93. ->label('Invoice Date')
  94. ->default(now()),
  95. Forms\Components\DatePicker::make('due_date')
  96. ->label('Payment Due')
  97. ->default(function () use ($company) {
  98. return now()->addDays($company->defaultInvoice->payment_terms->getDays());
  99. }),
  100. ])->grow(true),
  101. ])->from('md'),
  102. TableRepeater::make('lineItems')
  103. ->relationship()
  104. ->saveRelationshipsUsing(function (TableRepeater $component, Forms\Contracts\HasForms $livewire, ?array $state) {
  105. if (! is_array($state)) {
  106. $state = [];
  107. }
  108. $relationship = $component->getRelationship();
  109. $existingRecords = $component->getCachedExistingRecords();
  110. $recordsToDelete = [];
  111. foreach ($existingRecords->pluck($relationship->getRelated()->getKeyName()) as $keyToCheckForDeletion) {
  112. if (array_key_exists("record-{$keyToCheckForDeletion}", $state)) {
  113. continue;
  114. }
  115. $recordsToDelete[] = $keyToCheckForDeletion;
  116. $existingRecords->forget("record-{$keyToCheckForDeletion}");
  117. }
  118. $relationship
  119. ->whereKey($recordsToDelete)
  120. ->get()
  121. ->each(static fn (Model $record) => $record->delete());
  122. $childComponentContainers = $component->getChildComponentContainers(
  123. withHidden: $component->shouldSaveRelationshipsWhenHidden(),
  124. );
  125. $itemOrder = 1;
  126. $orderColumn = $component->getOrderColumn();
  127. $translatableContentDriver = $livewire->makeFilamentTranslatableContentDriver();
  128. foreach ($childComponentContainers as $itemKey => $item) {
  129. $itemData = $item->getState(shouldCallHooksBefore: false);
  130. if ($orderColumn) {
  131. $itemData[$orderColumn] = $itemOrder;
  132. $itemOrder++;
  133. }
  134. if ($record = ($existingRecords[$itemKey] ?? null)) {
  135. $itemData = $component->mutateRelationshipDataBeforeSave($itemData, record: $record);
  136. if ($itemData === null) {
  137. continue;
  138. }
  139. $translatableContentDriver ?
  140. $translatableContentDriver->updateRecord($record, $itemData) :
  141. $record->fill($itemData)->save();
  142. continue;
  143. }
  144. $relatedModel = $component->getRelatedModel();
  145. $itemData = $component->mutateRelationshipDataBeforeCreate($itemData);
  146. if ($itemData === null) {
  147. continue;
  148. }
  149. if ($translatableContentDriver) {
  150. $record = $translatableContentDriver->makeRecord($relatedModel, $itemData);
  151. } else {
  152. $record = new $relatedModel;
  153. $record->fill($itemData);
  154. }
  155. $record = $relationship->save($record);
  156. $item->model($record)->saveRelationships();
  157. $existingRecords->push($record);
  158. }
  159. $component->getRecord()->setRelation($component->getRelationshipName(), $existingRecords);
  160. /** @var Document $document */
  161. $document = $component->getRecord();
  162. // Recalculate totals for line items
  163. $document->lineItems()->each(function (DocumentLineItem $lineItem) {
  164. $lineItem->updateQuietly([
  165. 'tax_total' => $lineItem->calculateTaxTotal()->getAmount(),
  166. 'discount_total' => $lineItem->calculateDiscountTotal()->getAmount(),
  167. ]);
  168. });
  169. $subtotal = $document->lineItems()->sum('subtotal') / 100;
  170. $taxTotal = $document->lineItems()->sum('tax_total') / 100;
  171. $discountTotal = $document->lineItems()->sum('discount_total') / 100;
  172. $grandTotal = $subtotal + $taxTotal - $discountTotal;
  173. $document->updateQuietly([
  174. 'subtotal' => $subtotal,
  175. 'tax_total' => $taxTotal,
  176. 'discount_total' => $discountTotal,
  177. 'total' => $grandTotal,
  178. ]);
  179. })
  180. ->headers([
  181. Header::make('Items')->width('15%'),
  182. Header::make('Description')->width('25%'),
  183. Header::make('Quantity')->width('10%'),
  184. Header::make('Price')->width('10%'),
  185. Header::make('Taxes')->width('15%'),
  186. Header::make('Discounts')->width('15%'),
  187. Header::make('Amount')->width('10%')->align('right'),
  188. ])
  189. ->live()
  190. ->schema([
  191. Forms\Components\Select::make('offering_id')
  192. ->relationship('offering', 'name')
  193. ->preload()
  194. ->searchable()
  195. ->required()
  196. ->afterStateUpdated(function (Forms\Set $set, Forms\Get $get, $state) {
  197. $offeringId = $state;
  198. $offeringRecord = Offering::with('salesTaxes')->find($offeringId);
  199. if ($offeringRecord) {
  200. $set('description', $offeringRecord->description);
  201. $set('unit_price', $offeringRecord->price);
  202. $set('salesTaxes', $offeringRecord->salesTaxes->pluck('id')->toArray());
  203. $set('salesDiscounts', $offeringRecord->salesDiscounts->pluck('id')->toArray());
  204. }
  205. }),
  206. Forms\Components\TextInput::make('description'),
  207. Forms\Components\TextInput::make('quantity')
  208. ->required()
  209. ->numeric()
  210. ->default(1),
  211. Forms\Components\TextInput::make('unit_price')
  212. ->hiddenLabel()
  213. ->numeric()
  214. ->default(0),
  215. Forms\Components\Select::make('salesTaxes')
  216. ->relationship('salesTaxes', 'name')
  217. ->preload()
  218. ->multiple()
  219. ->searchable(),
  220. Forms\Components\Select::make('salesDiscounts')
  221. ->relationship('salesDiscounts', 'name')
  222. ->preload()
  223. ->multiple()
  224. ->searchable(),
  225. Forms\Components\Placeholder::make('total')
  226. ->hiddenLabel()
  227. ->content(function (Forms\Get $get) {
  228. $quantity = $get('quantity') ?? 0;
  229. $unitPrice = $get('unit_price') ?? 0;
  230. $salesTaxes = $get('salesTaxes') ?? [];
  231. $salesDiscounts = $get('salesDiscounts') ?? [];
  232. // Base total (subtotal)
  233. $subtotal = $quantity * $unitPrice;
  234. // Calculate tax amount based on subtotal
  235. $taxAmount = 0;
  236. if (! empty($salesTaxes)) {
  237. $taxRates = Adjustment::whereIn('id', $salesTaxes)->pluck('rate');
  238. $taxAmount = collect($taxRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
  239. }
  240. // Calculate discount amount based on subtotal
  241. $discountAmount = 0;
  242. if (! empty($salesDiscounts)) {
  243. $discountRates = Adjustment::whereIn('id', $salesDiscounts)->pluck('rate');
  244. $discountAmount = collect($discountRates)->sum(fn ($rate) => $subtotal * ($rate / 100));
  245. }
  246. // Final total
  247. $total = $subtotal + ($taxAmount - $discountAmount);
  248. return money($total, CurrencyAccessor::getDefaultCurrency(), true)->format();
  249. }),
  250. ]),
  251. Forms\Components\Grid::make(6)
  252. ->schema([
  253. Forms\Components\ViewField::make('totals')
  254. ->columnStart(5)
  255. ->columnSpan(2)
  256. ->view('filament.forms.components.invoice-totals'),
  257. ]),
  258. // Forms\Components\Repeater::make('lineItems')
  259. // ->relationship()
  260. // ->columns(8)
  261. // ->schema([
  262. // Forms\Components\Select::make('offering_id')
  263. // ->relationship('offering', 'name')
  264. // ->preload()
  265. // ->columnSpan(2)
  266. // ->searchable()
  267. // ->required()
  268. // ->live()
  269. // ->afterStateUpdated(function (Forms\Set $set, $state) {
  270. // $offeringId = $state;
  271. // $offeringRecord = Offering::with('salesTaxes')->find($offeringId);
  272. //
  273. // if ($offeringRecord) {
  274. // $set('description', $offeringRecord->description);
  275. // $set('unit_price', $offeringRecord->price);
  276. // $set('total', $offeringRecord->price);
  277. //
  278. // $salesTaxes = $offeringRecord->salesTaxes->map(function ($tax) {
  279. // return [
  280. // 'id' => $tax->id,
  281. // 'amount' => null, // Amount will be calculated dynamically
  282. // ];
  283. // })->toArray();
  284. //
  285. // $set('taxes', $salesTaxes);
  286. // }
  287. // }),
  288. // Forms\Components\TextInput::make('description')
  289. // ->columnSpan(3)
  290. // ->required(),
  291. // Forms\Components\TextInput::make('quantity')
  292. // ->required()
  293. // ->numeric()
  294. // ->live()
  295. // ->default(1),
  296. // Forms\Components\TextInput::make('unit_price')
  297. // ->live()
  298. // ->numeric()
  299. // ->default(0),
  300. // Forms\Components\Placeholder::make('total')
  301. // ->content(function (Forms\Get $get) {
  302. // $quantity = $get('quantity');
  303. // $unitPrice = $get('unit_price');
  304. //
  305. // if ($quantity && $unitPrice) {
  306. // return $quantity * $unitPrice;
  307. // }
  308. // }),
  309. // TableRepeater::make('taxes')
  310. // ->relationship()
  311. // ->columnSpanFull()
  312. // ->columnStart(6)
  313. // ->headers([
  314. // Header::make('')->width('200px'),
  315. // Header::make('')->width('50px')->align('right'),
  316. // ])
  317. // ->defaultItems(0)
  318. // ->schema([
  319. // Forms\Components\Select::make('id') // The ID of the adjustment being attached.
  320. // ->label('Tax Adjustment')
  321. // ->options(
  322. // Adjustment::query()
  323. // ->where('category', AdjustmentCategory::Tax)
  324. // ->pluck('name', 'id')
  325. // )
  326. // ->preload()
  327. // ->searchable()
  328. // ->required()
  329. // ->live(),
  330. // Forms\Components\Placeholder::make('amount')
  331. // ->hiddenLabel()
  332. // ->content(function (Forms\Get $get) {
  333. // $quantity = $get('../../quantity') ?? 0; // Get parent quantity
  334. // $unitPrice = $get('../../unit_price') ?? 0; // Get parent unit price
  335. // $rate = Adjustment::find($get('id'))->rate ?? 0;
  336. //
  337. // $total = $quantity * $unitPrice;
  338. //
  339. // return $total * ($rate / 100);
  340. // }),
  341. // ]),
  342. // ]),
  343. Forms\Components\Textarea::make('terms')
  344. ->columnSpanFull(),
  345. ]),
  346. Forms\Components\Section::make('Invoice Footer')
  347. ->collapsible()
  348. ->schema([
  349. Forms\Components\Textarea::make('footer')
  350. ->columnSpanFull(),
  351. ]),
  352. ]);
  353. }
  354. public static function table(Table $table): Table
  355. {
  356. return $table
  357. ->columns([
  358. Tables\Columns\TextColumn::make('status')
  359. ->badge()
  360. ->searchable(),
  361. Tables\Columns\TextColumn::make('due_date')
  362. ->label('Due')
  363. ->formatStateUsing(function (Tables\Columns\TextColumn $column, mixed $state) {
  364. if (blank($state)) {
  365. return null;
  366. }
  367. $date = Carbon::parse($state)
  368. ->setTimezone($timezone ?? $column->getTimezone());
  369. if ($date->isToday()) {
  370. return 'Today';
  371. }
  372. return $date->diffForHumans([
  373. 'options' => CarbonInterface::ONE_DAY_WORDS,
  374. ]);
  375. })
  376. ->sortable(),
  377. Tables\Columns\TextColumn::make('date')
  378. ->date()
  379. ->sortable(),
  380. Tables\Columns\TextColumn::make('document_number')
  381. ->label('Number')
  382. ->searchable(),
  383. Tables\Columns\TextColumn::make('client.name')
  384. ->numeric()
  385. ->sortable(),
  386. Tables\Columns\TextColumn::make('total')
  387. ->currency(),
  388. Tables\Columns\TextColumn::make('amount_paid')
  389. ->label('Amount Paid')
  390. ->currency(),
  391. Tables\Columns\TextColumn::make('amount_due')
  392. ->label('Amount Due')
  393. ->currency(),
  394. ])
  395. ->filters([
  396. //
  397. ])
  398. ->actions([
  399. Tables\Actions\EditAction::make(),
  400. ])
  401. ->bulkActions([
  402. Tables\Actions\BulkActionGroup::make([
  403. Tables\Actions\DeleteBulkAction::make(),
  404. ]),
  405. ]);
  406. }
  407. public static function getRelations(): array
  408. {
  409. return [
  410. //
  411. ];
  412. }
  413. public static function getPages(): array
  414. {
  415. return [
  416. 'index' => Pages\ListDocuments::route('/'),
  417. 'create' => Pages\CreateDocument::route('/create'),
  418. 'edit' => Pages\EditDocument::route('/{record}/edit'),
  419. ];
  420. }
  421. }