feat: Implement Feed Purchase management module with resource, pages, and cart functionality

This commit is contained in:
Yoga Pangestu 2026-02-17 19:38:06 +07:00
parent 6e3f685f06
commit 56038a3bf7
13 changed files with 704 additions and 0 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases;
use App\Filament\Resources\Manage\FeedPurchases\Pages\CreateFeedPurchase;
use App\Filament\Resources\Manage\FeedPurchases\Pages\ListFeedPurchases;
use App\Filament\Resources\Manage\FeedPurchases\Schemas\FeedPurchaseForm;
use App\Filament\Resources\Manage\FeedPurchases\Tables\FeedPurchasesTable;
use App\Models\FeedPurchase;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use UnitEnum;
class FeedPurchaseResource extends Resource
{
protected static ?string $model = FeedPurchase::class;
protected static string|UnitEnum|null $navigationGroup = 'Kelola';
protected static string|BackedEnum|null $navigationIcon = Heroicon::Banknotes;
protected static ?string $navigationLabel = 'Belanja Pakan';
protected static ?int $navigationSort = 1;
protected static ?string $recordTitleAttribute = 'reference_number';
protected static ?string $slug = 'manage/feed-purchases';
public static function form(Schema $schema): Schema
{
return FeedPurchaseForm::configure($schema);
}
public static function table(Table $table): Table
{
return FeedPurchasesTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListFeedPurchases::route('/'),
'create' => CreateFeedPurchase::route('/create'),
];
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases\Pages;
use App\Filament\Actions\BackAction;
use App\Filament\Resources\Manage\FeedPurchases\FeedPurchaseResource;
use App\Filament\Resources\Manage\FeedPurchases\Traits\HasCart;
use App\Models\FeedPurchaseItem;
use Filament\Resources\Pages\CreateRecord;
use Livewire\Attributes\On;
class CreateFeedPurchase extends CreateRecord
{
use HasCart;
protected static string $resource = FeedPurchaseResource::class;
protected ?string $heading = 'Tambah Belanja Pakan';
protected static ?string $title = 'Tambah Belanja Pakan';
#[On('cart-updated')]
public function onCartUpdated(): void
{
$this->syncTotalPriceFromCart();
}
protected function syncTotalPriceFromCart(): void
{
$total = (int) FeedPurchaseItem::whereNull('feed_purchase_id')->sum('subtotal');
$this->form->fill([
...$this->form->getState(),
'total_price' => $total,
]);
}
protected function getHeaderActions(): array
{
return [
BackAction::make()
->url(ListFeedPurchases::getUrl()),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
protected function mutateFormDataBeforeCreate(array $data): array
{
$total = FeedPurchaseItem::whereNull('feed_purchase_id')->sum('subtotal');
$data['total_price'] = $total;
return $data;
}
protected function afterCreate(): void
{
FeedPurchaseItem::whereNull('feed_purchase_id')
->update(['feed_purchase_id' => $this->record->id]);
$this->record->updateFeedStock();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases\Pages;
use App\Filament\Resources\Manage\FeedPurchases\FeedPurchaseResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListFeedPurchases extends ListRecords
{
protected static string $resource = FeedPurchaseResource::class;
protected static ?string $title = 'Belanja Pakan';
protected function getHeaderActions(): array
{
return [
CreateAction::make()
->label('Tambah'),
];
}
}

View File

@ -0,0 +1,111 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases\Schemas;
use App\Models\Feed;
use App\Models\FeedPurchaseItem;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\View;
use Filament\Schemas\Schema;
class FeedPurchaseForm
{
public static function configure(Schema $schema): Schema
{
$feeds = Feed::with('unit')
->latest()
->get()
->map(function ($feed) {
$feed->image = $feed->getFirstMediaUrl('feeds');
return $feed;
});
$cartItems = FeedPurchaseItem::with('feed')
->whereNull('feed_purchase_id')
->latest()
->get()
->map(function ($cartItem) {
$cartItem->subtotal = $cartItem->unit_price * $cartItem->quantity;
$cartItem->feedImage = $cartItem->feed?->getFirstMediaUrl('feeds');
return $cartItem;
});
return $schema
->components([
Section::make('Informasi Belanja')
->schema([
Grid::make(3)
->schema([
TextInput::make('reference_number')
->label('Nomor Referensi')
->placeholder('...')
->nullable()
->autocomplete(false)
->autofocus()
->helperText('Silakan masukan nomor belanja, invoice atau lainnya.'),
DatePicker::make('purchase_date')
->label('Tanggal Belanja')
->default(now())
->required()
->native(false)
->displayFormat('l, d F Y'),
TextInput::make('total_price')
->label('Total Harga')
->placeholder('0')
->readOnly()
->live()
->currencyMask(thousandSeparator: '.', decimalSeparator: ',', precision: 0)
->prefix('Rp')
->required()
->dehydrateStateUsing(fn ($state) => (int) str()->replace(',', '', (string) ($state ?? 0)))
->default(fn () => (int) FeedPurchaseItem::whereNull('feed_purchase_id')->sum('subtotal')),
Textarea::make('notes')
->label('Catatan')
->placeholder('...')
->columnSpanFull(),
]),
])
->collapsible(),
Grid::make([
'default' => 1,
'lg' => 3,
])
->schema([
Section::make('Daftar Pakan')
->schema([
View::make('filament.resources.manage.feed-purchases.feed-items-grid')
->viewData([
'feeds' => $feeds,
]),
])
->columnSpan([
'default' => 1,
'lg' => 2,
]),
Section::make('Keranjang')
->schema([
View::make('filament.resources.manage.feed-purchases.cart')
->viewData([
'cartItems' => $cartItems,
]),
])
->columnSpan([
'default' => 1,
'lg' => 1,
]),
]),
])
->columns(1);
}
}

View File

@ -0,0 +1,99 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases\Tables;
use App\Filament\Actions\Cheerful\DeleteAction;
use App\Filament\Actions\Cheerful\ForceDeleteAction;
use App\Filament\Actions\Cheerful\RestoreAction;
use App\Filament\Actions\DefaultBulkActions;
use Filament\Actions\BulkActionGroup;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TrashedFilter;
use Filament\Tables\Table;
class FeedPurchasesTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('reference_number')
->label('No. Referensi')
->searchable()
->sortable(),
TextColumn::make('purchase_date')
->label('Tanggal Belanja')
->date('l, d F Y')
->sortable(),
TextColumn::make('items_summary')
->label('Item Pakan')
->getStateUsing(function ($record): array {
return $record->items
->map(function ($item) {
$feedName = $item->feed?->name ?? '-';
$feedUnit = $item->feed?->unit?->name ?? '-';
$quantity = number_format($item->quantity, 0, ',', '.');
$subtotal = $item->formatted_subtotal;
return sprintf(
'<div class="flex items-center justify-between gap-1 p-1">
<div class="flex flex-col">
<span class="font-semibold text-gray-900 dark:text-gray-100">
%s
</span>
<span class="text-xs text-gray-500">
x%s %s
</span>
</div>
<div class="text-sm whitespace-nowrap">
Rp %s
</div>
</div>',
e($feedName),
$quantity,
$feedUnit,
$subtotal,
);
})
->toArray();
})
->listWithLineBreaks()
->html()
->toggleable(),
TextColumn::make('total_price')
->label('Total Harga')
->money('IDR', decimalPlaces: 0)
->sortable(),
TextColumn::make('created_at')
->label('Dibuat')
->dateTime('d/m/Y H:i')
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
TrashedFilter::make()
->native(false),
])
->recordActions([
DeleteAction::make(),
ForceDeleteAction::make(),
RestoreAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
...DefaultBulkActions::make('Belanja Pakan'),
]),
])
->emptyStateIcon(Heroicon::Banknotes)
->emptyStateDescription('Setelah Anda membuat data pertama, maka akan muncul disini.')
->defaultSort('created_at', 'desc')
->deferFilters(false);
}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Filament\Resources\Manage\FeedPurchases\Traits;
use App\Models\Feed;
use App\Models\FeedPurchaseItem;
trait HasCart
{
public array $cart = [];
public function loadCartFromDb(): void
{
$this->cart = FeedPurchaseItem::whereNull('feed_purchase_id')
->get()
->pluck('quantity', 'feed_id')
->map(fn ($qty) => ['qty' => (int) $qty])
->toArray();
}
public function addFeed(int $feedId): void
{
$feed = Feed::find($feedId);
if (! $feed) {
return;
}
$item = FeedPurchaseItem::whereNull('feed_purchase_id')
->where('feed_id', $feedId)
->first();
if ($item) {
$item->increment('quantity', 1);
$item->update(['subtotal' => $item->quantity * $item->unit_price]);
} else {
FeedPurchaseItem::create([
'feed_purchase_id' => null,
'feed_id' => $feedId,
'quantity' => 1,
'unit_price' => $feed->price,
'subtotal' => $feed->price,
]);
}
$this->loadCartFromDb();
$this->dispatch('cart-updated');
}
public function decreaseQty(int $feedId): void
{
$item = FeedPurchaseItem::whereNull('feed_purchase_id')
->where('feed_id', $feedId)
->first();
if ($item) {
if ($item->quantity > 1) {
$item->decrement('quantity', 1);
$item->update(['subtotal' => $item->quantity * $item->unit_price]);
} else {
$item->delete();
}
$this->loadCartFromDb();
$this->dispatch('cart-updated');
}
}
public function removeCartItem(int $itemId): void
{
FeedPurchaseItem::where('id', $itemId)->whereNull('feed_purchase_id')->delete();
$this->loadCartFromDb();
$this->dispatch('cart-updated');
}
}

View File

@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\FeedStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
@ -25,6 +26,13 @@ protected function casts(): array
];
}
protected function formattedPrice(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->price, 0, ',', '.'),
);
}
public function unit(): BelongsTo
{
return $this->belongsTo(Unit::class);

View File

@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class FeedPurchase extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected $casts = [
'purchase_date' => 'date',
'total_price' => 'integer',
];
protected static function boot()
{
parent::boot();
static::deleted(function ($feedPurchase) {
$feedPurchase->revertFeedStock();
});
static::forceDeleted(function ($feedPurchase) {
$feedPurchase->revertFeedStock();
});
static::restored(function ($feedPurchase) {
$feedPurchase->updateFeedStock();
});
}
public function items(): HasMany
{
return $this->hasMany(FeedPurchaseItem::class);
}
public function updateFeedStock(): void
{
foreach ($this->items as $item) {
$feed = $item->feed;
if ($feed) {
$feed->increment('stock', $item->quantity);
}
}
}
public function revertFeedStock(): void
{
foreach ($this->items as $item) {
$feed = $item->feed;
if ($feed) {
$feed->decrement('stock', $item->quantity);
}
}
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class FeedPurchaseItem extends Model
{
use SoftDeletes;
protected $guarded = ['id'];
protected $casts = [
'quantity' => 'integer',
'unit_price' => 'integer',
'subtotal' => 'integer',
];
protected function formattedUnitPrice(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->unit_price, 0, ',', '.'),
);
}
protected function formattedSubtotal(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->subtotal, 0, ',', '.'),
);
}
public function purchase(): BelongsTo
{
return $this->belongsTo(FeedPurchase::class, 'feed_purchase_id');
}
public function feed(): BelongsTo
{
return $this->belongsTo(Feed::class);
}
}

View File

@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('feed_purchases', function (Blueprint $table) {
$table->id();
$table->string('reference_number', 50)->nullable();
$table->date('purchase_date');
$table->unsignedInteger('total_price');
$table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('feed_purchases');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('feed_purchase_items', function (Blueprint $table) {
$table->id();
$table->foreignId('feed_purchase_id')->nullable()->constrained()->cascadeOnDelete();
$table->foreignId('feed_id')->constrained()->cascadeOnDelete();
$table->unsignedInteger('quantity');
$table->unsignedInteger('unit_price');
$table->unsignedInteger('subtotal');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->nullable()->useCurrentOnUpdate();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('feed_purchase_items');
}
};

View File

@ -0,0 +1,69 @@
<div>
<!-- Cart Items List -->
<div class="flex-1 overflow-y-auto space-y-3 custom-scrollbar" style="max-height: 500px; min-height: 300px;">
@forelse ($cartItems as $cartItem)
<div
class="group relative flex flex-col gap-3 p-4 bg-white dark:bg-gray-800 rounded-xl border border-gray-100 dark:border-gray-700 shadow-sm hover:shadow-md hover:border-primary-100 dark:hover:border-primary-900/50 transition-all duration-300">
<div class="flex justify-between items-start gap-4">
<div class="flex items-start gap-3">
<!-- Icon/Image Placeholder -->
<div
class="flex-shrink-0 w-10 h-10 flex items-center justify-center bg-gray-50 dark:bg-gray-700 text-gray-400 dark:text-gray-500 rounded-lg group-hover:bg-primary-50 group-hover:text-primary-500 dark:group-hover:bg-primary-900/20 transition-colors">
<img src="{{ $cartItem->feedImage }}" alt="{{ $cartItem->feed?->name }}"
class="w-full h-full object-cover rounded-lg">
</div>
<div>
<h3
class="font-bold text-gray-900 dark:text-white text-sm leading-tight group-hover:text-primary-600 transition-colors">
{{ $cartItem->feed?->name }}
</h3>
<div class="flex items-center gap-2 mt-1">
<span
class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded">
Rp {{ $cartItem->formatted_unit_price }}
</span>
</div>
</div>
</div>
<button wire:click="removeCartItem({{ $cartItem['id'] }})" type="button"
class="text-gray-400 hover:text-red-500 bg-transparent hover:bg-red-50 dark:hover:bg-red-900/20 p-2 rounded-lg transition-all duration-200"
title="Hapus Item">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
<!-- Footer: Calculation & Subtotal -->
<div
class="flex items-center justify-between pt-3 border-t border-dashed border-gray-100 dark:border-gray-700 mt-1">
<div class="text-xs font-medium text-gray-500 dark:text-gray-400">
<span class="text-gray-800 dark:text-gray-200 font-bold">{{ $cartItem->quantity }}</span>
{{ $cartItem->feed?->unit?->alias ?? 'Unit' }}
</div>
<div class="text-primary-600 dark:text-primary-400 font-bold text-sm">
Rp {{ $cartItem->formatted_subtotal }}
</div>
</div>
</div>
@empty
<div class="flex flex-col items-center justify-center h-full py-12 text-center opacity-60">
<div class="w-20 h-20 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mb-4">
<svg class="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h3 class="text-gray-900 dark:text-white font-semibold text-base">Keranjang Kosong</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1 px-8">Pilih pakan di sebelah kiri untuk
menambahkan ke keranjang.</p>
</div>
@endforelse
</div>
</div>

View File

@ -0,0 +1,44 @@
<div class="grid grid-cols-2 md:grid-cols-3 gap-5">
@foreach ($feeds as $feed)
<div class="border rounded-xl bg-white shadow-sm hover:shadow-md transition overflow-hidden flex flex-col">
<img src="{{ $feed->image }}" class="w-full h-40 object-cover">
<div class="p-4 flex flex-col flex-1">
<div class="text-xs text-gray-400">
{{ $feed->unit?->name ?? '-' }}
</div>
<div class="font-semibold">
{{ $feed->name }}
</div>
<div class="font-bold text-lg mt-1">
Rp {{ $feed->formatted_price }}
</div>
<div class="flex items-center justify-end gap-3 mt-auto">
<a href="javascript:void(0)" wire:click="decreaseQty({{ $feed->id }})"
class="w-8 h-8 rounded-full border border-gray-200 flex items-center justify-center text-lg hover:bg-gray-50 transition-colors">
-
</a>
<span class="text-sm font-bold w-6 text-center">
{{ $this->cart[$feed->id]['qty'] ?? 0 }}
</span>
<a href="javascript:void(0)" wire:click="addFeed({{ $feed->id }})"
class="w-8 h-8 rounded-full flex items-center justify-center text-white text-lg transition-colors bg-teal-500 hover:bg-teal-600">
+
</a>
</div>
</div>
</div>
@endforeach
</div>