299 lines
14 KiB
PHP
299 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Filament\Pages\Transactions;
|
|
|
|
use App\Services\CustomerService;
|
|
use App\Services\DriverService;
|
|
use App\Services\OrderService;
|
|
use App\Services\ProductService;
|
|
use BackedEnum;
|
|
use Filament\Actions\Action;
|
|
use Filament\Actions\CreateAction;
|
|
use Filament\Forms\Components\DatePicker;
|
|
use Filament\Forms\Components\Repeater;
|
|
use Filament\Forms\Components\Select;
|
|
use Filament\Forms\Components\Textarea;
|
|
use Filament\Forms\Components\TextInput;
|
|
use Filament\Notifications\Notification;
|
|
use Filament\Pages\Page;
|
|
use Filament\Schemas\Components\Component;
|
|
use Filament\Schemas\Components\EmbeddedTable;
|
|
use Filament\Schemas\Schema;
|
|
use Filament\Support\Enums\FontWeight;
|
|
use Filament\Tables\Columns\TextColumn;
|
|
use Filament\Tables\Concerns\InteractsWithTable;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use Filament\Tables\Filters\Filter;
|
|
use Filament\Tables\Table;
|
|
use Illuminate\Auth\Access\AuthorizationException;
|
|
use Illuminate\Contracts\Support\Htmlable;
|
|
use Illuminate\Validation\ValidationException;
|
|
use UnitEnum;
|
|
|
|
class Orders extends Page implements HasTable
|
|
{
|
|
use InteractsWithTable;
|
|
|
|
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clipboard-document-list';
|
|
|
|
protected static string|UnitEnum|null $navigationGroup = 'Transaksi';
|
|
|
|
protected static ?int $navigationSort = 0;
|
|
|
|
protected static ?string $navigationLabel = 'Pesanan';
|
|
|
|
protected static ?string $slug = 'transactions/orders';
|
|
|
|
protected string $view = 'filament.pages.blank';
|
|
|
|
public function getTitle(): string|Htmlable
|
|
{
|
|
return 'Pesanan';
|
|
}
|
|
|
|
public function table(Table $table): Table
|
|
{
|
|
/** @var OrderService $service */
|
|
$service = app(OrderService::class);
|
|
|
|
return $table
|
|
->records(fn (?string $search, ?string $sortColumn, ?string $sortDirection, int|string $page, int|string $recordsPerPage, ?array $filters) => $service->paginate(
|
|
search: $search,
|
|
sortColumn: $sortColumn,
|
|
sortDirection: $sortDirection,
|
|
page: (int) $page,
|
|
perPage: (int) $recordsPerPage,
|
|
filters: ['deleted_at' => null, ...$this->statusFilter($filters)],
|
|
))
|
|
->columns([
|
|
TextColumn::make('order_number')->label('No. Order')->badge()->weight(FontWeight::SemiBold)->sortable(),
|
|
TextColumn::make('customers.name')->label('Pelanggan'),
|
|
TextColumn::make('order_date')->label('Tgl Order')->date('d M Y')->sortable(),
|
|
TextColumn::make('drivers.name')->label('Pengemudi')->placeholder('—'),
|
|
TextColumn::make('total')
|
|
->label('Total')
|
|
->state(fn (array $record): int => collect($record['order_items'] ?? [])->sum(fn ($item) => (int) ($item['subtotal'] ?? 0)))
|
|
->numeric()->prefix('Rp '),
|
|
TextColumn::make('payment_status')
|
|
->label('Bayar')
|
|
->badge()
|
|
->color(fn (string $state): string => match ($state) {
|
|
'paid' => 'success',
|
|
'partial' => 'warning',
|
|
default => 'gray',
|
|
}),
|
|
TextColumn::make('order_status')
|
|
->label('Status')
|
|
->badge()
|
|
->color(fn (string $state): string => match ($state) {
|
|
OrderService::STATUS_PENDING => 'gray',
|
|
OrderService::STATUS_PROCESSING => 'info',
|
|
OrderService::STATUS_DELIVERING => 'warning',
|
|
OrderService::STATUS_COMPLETED => 'success',
|
|
OrderService::STATUS_CANCELLED => 'danger',
|
|
default => 'gray',
|
|
})
|
|
->formatStateUsing(fn (string $state): string => match ($state) {
|
|
OrderService::STATUS_PENDING => 'Pending',
|
|
OrderService::STATUS_PROCESSING => 'Diproses',
|
|
OrderService::STATUS_DELIVERING => 'Dikirim',
|
|
OrderService::STATUS_COMPLETED => 'Selesai',
|
|
OrderService::STATUS_CANCELLED => 'Batal',
|
|
default => $state,
|
|
})
|
|
->sortable(),
|
|
])
|
|
->filters([
|
|
Filter::make('order_status')
|
|
->schema([
|
|
Select::make('value')->label('Status')->options([
|
|
OrderService::STATUS_PENDING => 'Pending',
|
|
OrderService::STATUS_PROCESSING => 'Diproses',
|
|
OrderService::STATUS_DELIVERING => 'Dikirim',
|
|
OrderService::STATUS_COMPLETED => 'Selesai',
|
|
OrderService::STATUS_CANCELLED => 'Batal',
|
|
]),
|
|
]),
|
|
])
|
|
->recordActions([
|
|
Action::make('detail')
|
|
->label('Detail')
|
|
->icon('heroicon-o-eye')
|
|
->modalDescription(fn (array $record): string => $this->detailSummary($record))
|
|
->modalSubmitAction(false),
|
|
|
|
Action::make('proses')
|
|
->label('Proses')
|
|
->color('info')
|
|
->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_PENDING)
|
|
->requiresConfirmation()
|
|
->action(function (array $record) use ($service): void {
|
|
$this->runTransition($service, $record['id'], OrderService::STATUS_PROCESSING);
|
|
}),
|
|
|
|
Action::make('kirim')
|
|
->label('Kirim')
|
|
->color('warning')
|
|
->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_PROCESSING)
|
|
->schema(fn (Schema $schema): Schema => $schema->components([
|
|
Select::make('driver_id')->label('Pengemudi')->options(fn (): array => app(DriverService::class)->optionsForSelect())->required(),
|
|
]))
|
|
->action(function (array $data, array $record) use ($service): void {
|
|
$service->updateDetails($record['id'], ['driver_id' => $data['driver_id']], auth()->user());
|
|
$this->runTransition($service, $record['id'], OrderService::STATUS_DELIVERING);
|
|
}),
|
|
|
|
Action::make('selesai')
|
|
->label('Selesaikan')
|
|
->color('success')
|
|
->icon('heroicon-o-check-circle')
|
|
->visible(fn (array $record): bool => ($record['order_status'] ?? '') === OrderService::STATUS_DELIVERING)
|
|
->requiresConfirmation()
|
|
->modalDescription('Stok akan dikurangi otomatis dan tidak dapat diulang. Lanjutkan?')
|
|
->action(function (array $record) use ($service): void {
|
|
$this->runTransition($service, $record['id'], OrderService::STATUS_COMPLETED);
|
|
}),
|
|
|
|
Action::make('batalkan')
|
|
->label('Batalkan')
|
|
->color('danger')
|
|
->visible(fn (array $record): bool => $service->canCancel($record, auth()->user()))
|
|
->schema(fn (Schema $schema): Schema => $schema->components([
|
|
Textarea::make('reason')->label('Alasan Pembatalan')->required(),
|
|
]))
|
|
->action(function (array $data, array $record) use ($service): void {
|
|
$this->runTransition($service, $record['id'], OrderService::STATUS_CANCELLED, $data['reason']);
|
|
}),
|
|
|
|
Action::make('surat_jalan')
|
|
->label('Surat Jalan')
|
|
->icon('heroicon-o-printer')
|
|
->url(fn (array $record): string => '/orders/'.$record['id'].'/delivery-note')
|
|
->openUrlInNewTab()
|
|
->visible(fn (array $record): bool => in_array($record['order_status'], [OrderService::STATUS_DELIVERING, OrderService::STATUS_COMPLETED], true)),
|
|
|
|
Action::make('hapus')
|
|
->label('Hapus')
|
|
->color('danger')
|
|
->icon('heroicon-o-trash')
|
|
->visible(fn (array $record): bool => auth()->user()?->isAdmin() ?? false)
|
|
->requiresConfirmation()
|
|
->modalDescription('Order akan dihapus (soft delete). Lanjutkan?')
|
|
->action(function (array $record) use ($service): void {
|
|
$service->softDelete($record['id'], auth()->user());
|
|
$this->notifySuccess('Order dihapus.');
|
|
}),
|
|
])
|
|
->headerActions([
|
|
CreateAction::make()
|
|
->label('Buat Pesanan')
|
|
->schema(fn (Schema $schema): Schema => $schema->components(self::orderForm()))
|
|
->using(function (array $data): void {
|
|
app(OrderService::class)->createOrder($data, $data['items'] ?? [], auth()->user());
|
|
$this->notifySuccess('Pesanan dibuat.');
|
|
}),
|
|
])
|
|
->paginated([10, 25, 50]);
|
|
}
|
|
|
|
/**
|
|
* @return list<Component>
|
|
*/
|
|
private static function orderForm(): array
|
|
{
|
|
return [
|
|
Select::make('customer_id')->label('Pelanggan')->options(fn (): array => app(CustomerService::class)->optionsForSelect())->required()->searchable(),
|
|
DatePicker::make('order_date')->label('Tgl Order')->default(now()->toDateString())->required(),
|
|
DatePicker::make('delivery_date')->label('Tgl Kirim'),
|
|
Select::make('payment_method')->label('Metode Bayar')->options(['cash' => 'Cash', 'transfer' => 'Transfer', 'qris' => 'QRIS'])->default('cash')->required(),
|
|
Select::make('payment_status')->label('Status Bayar')->options(['unpaid' => 'Belum Bayar', 'partial' => 'DP', 'paid' => 'Lunas'])->default('unpaid')->required(),
|
|
Repeater::make('items')
|
|
->label('Item Pesanan')
|
|
->schema([
|
|
Select::make('product_id')
|
|
->label('Produk')
|
|
->options(fn (): array => app(ProductService::class)->optionsForSelect())
|
|
->required()
|
|
->searchable()
|
|
->live()
|
|
->afterStateUpdated(function (?string $state, callable $set): void {
|
|
if ($state !== null && $state !== '') {
|
|
$product = app(ProductService::class)->find($state);
|
|
$set('unit_price', (int) ($product['cost_price'] ?? 0));
|
|
}
|
|
}),
|
|
TextInput::make('quantity')->label('Qty')->numeric()->minValue(1)->required(),
|
|
TextInput::make('unit_price')->label('Harga/Satuan')->numeric()->minValue(0)->required(),
|
|
])
|
|
->columns(3)
|
|
->defaultItems(1)
|
|
->minItems(1)
|
|
->required()
|
|
->columnSpanFull(),
|
|
Textarea::make('notes')->label('Catatan')->columnSpanFull(),
|
|
];
|
|
}
|
|
|
|
private function runTransition(OrderService $service, string $orderId, string $to, ?string $notes = null): void
|
|
{
|
|
try {
|
|
$service->transition($orderId, $to, $notes, auth()->user());
|
|
} catch (ValidationException $exception) {
|
|
Notification::make()->title('Gagal')->body(collect($exception->errors())->flatten()->implode(' '))->danger()->send();
|
|
|
|
return;
|
|
} catch (AuthorizationException) {
|
|
Notification::make()->title('Akses ditolak')->body('Hanya admin atau operator pembuat yang dapat membatalkan order ini.')->danger()->send();
|
|
|
|
return;
|
|
}
|
|
|
|
$this->notifySuccess('Status order diperbarui.');
|
|
}
|
|
|
|
private function detailSummary(array $record): string
|
|
{
|
|
$lines = ["No: {$record['order_number']}"];
|
|
|
|
foreach ($record['order_items'] ?? [] as $item) {
|
|
$lines[] = sprintf('- %s x%d @ Rp%s = Rp%s',
|
|
data_get($item, 'products.name', '?'),
|
|
$item['quantity'],
|
|
number_format((int) $item['unit_price'], 0, ',', '.'),
|
|
number_format((int) $item['subtotal'], 0, ',', '.'),
|
|
);
|
|
}
|
|
|
|
$total = collect($record['order_items'] ?? [])->sum(fn ($item) => (int) ($item['subtotal'] ?? 0));
|
|
|
|
if (($record['survey_status'] ?? null) !== null) {
|
|
$lines[] = 'Survei: '.$record['survey_status'];
|
|
}
|
|
|
|
$lines[] = 'Total: Rp '.number_format($total, 0, ',', '.');
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/** @return array<string, mixed> */
|
|
private function statusFilter(?array $filters): array
|
|
{
|
|
$status = $filters['order_status']['value'] ?? $filters['order_status'] ?? null;
|
|
|
|
return is_array($status) ? [] : (filled($status) ? ['order_status' => $status] : []);
|
|
}
|
|
|
|
private function notifySuccess(string $message): void
|
|
{
|
|
Notification::make()->title('Berhasil')->body($message)->success()->send();
|
|
}
|
|
|
|
public function content(Schema $schema): Schema
|
|
{
|
|
return $schema
|
|
->components([
|
|
EmbeddedTable::make(),
|
|
]);
|
|
}
|
|
}
|