feat: implement full order management system including CRUD operations, inventory tracking, and payment processing
This commit is contained in:
parent
a121e7b52d
commit
75450301bb
29
app/Enums/OrderChannel.php
Normal file
29
app/Enums/OrderChannel.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum OrderChannel: string
|
||||
{
|
||||
case TIKTOK = 'tiktok';
|
||||
case SHOPEE = 'shopee';
|
||||
case TOKOPEDIA = 'tokopedia';
|
||||
case LAZADA = 'lazada';
|
||||
case FACEBOOK = 'facebook';
|
||||
case WEBSITE = 'website';
|
||||
case STORE = 'store';
|
||||
case OTHER = 'other';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TIKTOK => 'Tiktok',
|
||||
self::SHOPEE => 'Shopee',
|
||||
self::TOKOPEDIA => 'Tokopedia',
|
||||
self::LAZADA => 'Lazada',
|
||||
self::FACEBOOK => 'Facebook',
|
||||
self::WEBSITE => 'Website',
|
||||
self::STORE => 'Toko',
|
||||
self::OTHER => 'Lainnya',
|
||||
};
|
||||
}
|
||||
}
|
||||
23
app/Enums/OrderStatus.php
Normal file
23
app/Enums/OrderStatus.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum OrderStatus: string
|
||||
{
|
||||
case PENDING = 'pending';
|
||||
case PROCESSING = 'processing';
|
||||
case SHIPPED = 'shipped';
|
||||
case DELIVERED = 'delivered';
|
||||
case CANCELLED = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::PENDING => 'Menunggu',
|
||||
self::PROCESSING => 'Proses',
|
||||
self::SHIPPED => 'Dikirim',
|
||||
self::DELIVERED => 'Selesai',
|
||||
self::CANCELLED => 'Gagal',
|
||||
};
|
||||
}
|
||||
}
|
||||
21
app/Enums/PaymentMethod.php
Normal file
21
app/Enums/PaymentMethod.php
Normal file
@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum PaymentMethod: string
|
||||
{
|
||||
case CASH = 'cash';
|
||||
case TRANSFER = 'transfer';
|
||||
case E_WALLET = 'e_wallet';
|
||||
case QRIS = 'qris';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CASH => 'Tunai',
|
||||
self::TRANSFER => 'Transfer',
|
||||
self::E_WALLET => 'E-Wallet',
|
||||
self::QRIS => 'QRIS',
|
||||
};
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Admin/Manage/OrderCartController.php
Normal file
74
app/Http/Controllers/Admin/Manage/OrderCartController.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\Order\AddToCartRequest;
|
||||
use App\Models\OrderItem;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class OrderCartController extends Controller
|
||||
{
|
||||
public function addToCart(AddToCartRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
$item = OrderItem::where('user_id', auth()->id())
|
||||
->whereNull('order_id')
|
||||
->where('product_id', $validated['product_id'])
|
||||
->where('price_type', $validated['price_type'])
|
||||
->first();
|
||||
|
||||
if ($item) {
|
||||
$newQty = $item->qty + $validated['qty'];
|
||||
|
||||
if ($newQty <= 0) {
|
||||
$item->delete();
|
||||
} else {
|
||||
$item->update([
|
||||
'qty' => $newQty,
|
||||
'total' => $newQty * $item->price,
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
if ($validated['qty'] > 0) {
|
||||
OrderItem::create([
|
||||
'user_id' => auth()->id(),
|
||||
'order_id' => null,
|
||||
'product_id' => $validated['product_id'],
|
||||
'qty' => $validated['qty'],
|
||||
'price' => $validated['price'],
|
||||
'total' => $validated['qty'] * $validated['price'],
|
||||
'price_type' => $validated['price_type'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function removeFromCart(OrderItem $orderItem): RedirectResponse
|
||||
{
|
||||
if ($orderItem->user_id === auth()->id() && $orderItem->order_id === null) {
|
||||
$orderItem->delete();
|
||||
}
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
public function updateCartItem(AddToCartRequest $request, OrderItem $orderItem): RedirectResponse
|
||||
{
|
||||
if ($orderItem->user_id !== auth()->id() || $orderItem->order_id !== null) {
|
||||
return redirect()->back();
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
|
||||
$orderItem->update([
|
||||
'qty' => $validated['qty'],
|
||||
'total' => $validated['qty'] * $orderItem->price,
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
}
|
||||
}
|
||||
183
app/Http/Controllers/Admin/Manage/OrderController.php
Normal file
183
app/Http/Controllers/Admin/Manage/OrderController.php
Normal file
@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use App\Enums\PriceType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\Order\OrderRequest;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class OrderController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/order/index', [
|
||||
'orders' => Order::with(['items.product'])->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/order/create', [
|
||||
'products' => Product::with(['prices', 'categories'])->active()->get(),
|
||||
'cartItems' => OrderItem::with(['product.prices', 'product.categories'])
|
||||
->where('user_id', auth()->id())
|
||||
->whereNull('order_id')
|
||||
->latest()
|
||||
->get(),
|
||||
'orderStatus' => collect(OrderStatus::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'orderChannels' => collect(OrderChannel::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'paymentMethods' => collect(PaymentMethod::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'priceTypes' => collect(PriceType::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(OrderRequest $request): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($validated) {
|
||||
$totalItemsPrice = collect($validated['items'])->sum('total');
|
||||
$hpp = collect($validated['items'])->sum(function ($item) {
|
||||
$product = Product::find($item['product_id']);
|
||||
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
|
||||
|
||||
return $purchasePrice * $item['qty'];
|
||||
});
|
||||
|
||||
$order = Order::create([
|
||||
'user_id' => auth()->id(),
|
||||
'invoice_number' => $validated['invoice_number'] ?? 'INV-'.now()->format('YmdHis').'-'.strtoupper(fake()->bothify('??##')),
|
||||
'customer_name' => $validated['customer_name'],
|
||||
'hpp' => $hpp,
|
||||
'discount' => $validated['discount'],
|
||||
'payment' => $validated['payment'],
|
||||
'total' => $totalItemsPrice - $validated['discount'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'order_status' => $validated['order_status'],
|
||||
'order_channel' => $validated['order_channel'],
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
OrderItem::create([
|
||||
'user_id' => auth()->id(),
|
||||
'order_id' => $order->id,
|
||||
'product_id' => $item['product_id'],
|
||||
'price' => $item['price'],
|
||||
'qty' => $item['qty'],
|
||||
'total' => $item['total'],
|
||||
'price_type' => $item['price_type'],
|
||||
]);
|
||||
|
||||
Product::find($item['product_id'])->decrement('stock', $item['qty']);
|
||||
}
|
||||
|
||||
OrderItem::where('user_id', auth()->id())
|
||||
->whereNull('order_id')
|
||||
->delete();
|
||||
});
|
||||
|
||||
return redirect()->route('order.index')->with('success', 'Pesanan berhasil disimpan');
|
||||
}
|
||||
|
||||
public function edit(Order $order): Response
|
||||
{
|
||||
$order->load(['items.product']);
|
||||
|
||||
return Inertia::render('admin/manage/order/edit', [
|
||||
'order' => $order,
|
||||
'products' => Product::with(['prices', 'categories'])->active()->get(),
|
||||
'orderStatus' => collect(OrderStatus::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'orderChannels' => collect(OrderChannel::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'paymentMethods' => collect(PaymentMethod::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
'priceTypes' => collect(PriceType::cases())->map(fn ($case) => ['value' => $case->value, 'label' => $case->label()]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(OrderRequest $request, Order $order): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
|
||||
DB::transaction(function () use ($validated, $order) {
|
||||
$totalItemsPrice = collect($validated['items'])->sum(fn ($item) => $item['qty'] * $item['price']);
|
||||
$hpp = collect($validated['items'])->sum(function ($item) {
|
||||
$product = Product::find($item['product_id']);
|
||||
$purchasePrice = $product->prices()->where('price_type', PriceType::PURCHASE)->first()?->price ?? 0;
|
||||
|
||||
return $purchasePrice * $item['qty'];
|
||||
});
|
||||
|
||||
// Restore stock for old items
|
||||
foreach ($order->items as $item) {
|
||||
$item->product->increment('stock', $item->qty);
|
||||
}
|
||||
|
||||
$order->items()->delete();
|
||||
|
||||
$order->update([
|
||||
'customer_name' => $validated['customer_name'],
|
||||
'hpp' => $hpp,
|
||||
'discount' => $validated['discount'],
|
||||
'payment' => $validated['payment'],
|
||||
'total' => $totalItemsPrice - $validated['discount'],
|
||||
'payment_method' => $validated['payment_method'],
|
||||
'order_status' => $validated['order_status'],
|
||||
'order_channel' => $validated['order_channel'],
|
||||
]);
|
||||
|
||||
foreach ($validated['items'] as $item) {
|
||||
$order->items()->create([
|
||||
'user_id' => auth()->id(),
|
||||
'product_id' => $item['product_id'],
|
||||
'price' => $item['price'],
|
||||
'qty' => $item['qty'],
|
||||
'total' => $item['total'],
|
||||
'price_type' => $item['price_type'],
|
||||
]);
|
||||
|
||||
Product::find($item['product_id'])->decrement('stock', $item['qty']);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('order.index')->with('success', 'Pesanan berhasil diperbarui');
|
||||
}
|
||||
|
||||
public function destroy(Order $order): RedirectResponse
|
||||
{
|
||||
DB::transaction(function () use ($order) {
|
||||
foreach ($order->items as $item) {
|
||||
$item->product->increment('stock', $item->qty);
|
||||
}
|
||||
$order->delete();
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Pesanan berhasil dihapus');
|
||||
}
|
||||
|
||||
public function bulkDestroy(Request $request): RedirectResponse
|
||||
{
|
||||
$ids = $request->input('ids');
|
||||
|
||||
DB::transaction(function () use ($ids) {
|
||||
$orders = Order::with('items')->whereIn('id', $ids)->get();
|
||||
foreach ($orders as $order) {
|
||||
foreach ($order->items as $item) {
|
||||
$item->product->increment('stock', $item->qty);
|
||||
}
|
||||
$order->delete();
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', 'Pesanan terpilih berhasil dihapus');
|
||||
}
|
||||
}
|
||||
44
app/Http/Requests/Admin/Manage/Order/AddToCartRequest.php
Normal file
44
app/Http/Requests/Admin/Manage/Order/AddToCartRequest.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage\Order;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AddToCartRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'product_id' => [
|
||||
Rule::requiredIf($this->isMethod('post')),
|
||||
Rule::exists('products', 'id'),
|
||||
],
|
||||
'qty' => ['required', 'integer'],
|
||||
'price' => [
|
||||
Rule::requiredIf($this->isMethod('post')),
|
||||
'integer',
|
||||
'min:0',
|
||||
],
|
||||
'price_type' => [
|
||||
Rule::requiredIf($this->isMethod('post')),
|
||||
Rule::enum(PriceType::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
46
app/Http/Requests/Admin/Manage/Order/OrderRequest.php
Normal file
46
app/Http/Requests/Admin/Manage/Order/OrderRequest.php
Normal file
@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage\Order;
|
||||
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class OrderRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'invoice_number' => ['nullable', 'string', 'max:30'],
|
||||
'customer_name' => ['required', 'string', 'max:100'],
|
||||
'discount' => ['required', 'integer', 'min:0'],
|
||||
'payment' => ['required', 'integer', 'min:0'],
|
||||
'payment_method' => ['required', Rule::enum(PaymentMethod::class)],
|
||||
'order_status' => ['required', Rule::enum(OrderStatus::class)],
|
||||
'order_channel' => ['required', Rule::enum(OrderChannel::class)],
|
||||
'items' => ['required', 'array', 'min:1'],
|
||||
'items.*.product_id' => ['required', Rule::exists('products', 'id')->whereNull('deleted_at')],
|
||||
'items.*.qty' => ['required', 'integer', 'min:1'],
|
||||
'items.*.price' => ['required', 'integer', 'min:0'],
|
||||
'items.*.total' => ['required', 'integer', 'min:0'],
|
||||
'items.*.price_type' => ['required', Rule::enum(PriceType::class)],
|
||||
];
|
||||
}
|
||||
}
|
||||
128
app/Models/Order.php
Normal file
128
app/Models/Order.php
Normal file
@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['hpp_formatted', 'discount_formatted', 'payment_formatted', 'total_formatted'])]
|
||||
class Order extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'hpp' => 'integer',
|
||||
'discount' => 'integer',
|
||||
'payment' => 'integer',
|
||||
'total' => 'integer',
|
||||
'payment_method' => PaymentMethod::class,
|
||||
'order_status' => OrderStatus::class,
|
||||
'order_channel' => OrderChannel::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function hppFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->hpp, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function discountFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function paymentFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->payment, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function totalFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['invoice_number', 'customer_name', 'hpp', 'discount', 'payment', 'total', 'payment_method', 'order_status', 'order_channel'])
|
||||
->logOnlyDirty()
|
||||
->useLogName('Pesanan');
|
||||
}
|
||||
|
||||
public function tapActivity(Activity $activity, string $eventName)
|
||||
{
|
||||
$activity->description = match ($eventName) {
|
||||
'created' => 'TAMBAH',
|
||||
'updated' => 'UBAH',
|
||||
'deleted' => 'HAPUS',
|
||||
default => $activity->description,
|
||||
};
|
||||
|
||||
if (isset($activity->properties['attributes'])) {
|
||||
$attributeMap = [
|
||||
'invoice_number' => 'Nomor Invoice',
|
||||
'customer_name' => 'Nama Pelanggan',
|
||||
'hpp' => 'Modal',
|
||||
'discount' => 'Diskon',
|
||||
'payment' => 'Bayar',
|
||||
'total' => 'Total',
|
||||
'payment_method' => 'Metode Pembayaran',
|
||||
'order_status' => 'Status Pesanan',
|
||||
'order_channel' => 'Saluran Pesanan',
|
||||
];
|
||||
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
$localizeValues = function ($attrs) use ($attributeMap) {
|
||||
$newAttrs = [];
|
||||
foreach ($attrs as $key => $value) {
|
||||
$label = $attributeMap[$key] ?? $key;
|
||||
$newAttrs[$label] = $value;
|
||||
}
|
||||
|
||||
return $newAttrs;
|
||||
};
|
||||
|
||||
$properties['attributes'] = $localizeValues($properties['attributes']);
|
||||
|
||||
if (isset($properties['old'])) {
|
||||
$properties['old'] = $localizeValues($properties['old']);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
108
app/Models/OrderItem.php
Normal file
108
app/Models/OrderItem.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Database\Eloquent\Attributes\Appends;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
#[Appends(['price_formatted', 'total_formatted'])]
|
||||
class OrderItem extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'price' => 'integer',
|
||||
'qty' => 'integer',
|
||||
'total' => 'integer',
|
||||
'price_type' => PriceType::class,
|
||||
];
|
||||
}
|
||||
|
||||
protected function priceFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->price, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function totalFormatted(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'),
|
||||
);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function order(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Order::class);
|
||||
}
|
||||
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logOnly(['price', 'qty', 'total', 'price_type'])
|
||||
->logOnlyDirty()
|
||||
->useLogName('Item Pesanan');
|
||||
}
|
||||
|
||||
public function tapActivity(Activity $activity, string $eventName)
|
||||
{
|
||||
$activity->description = match ($eventName) {
|
||||
'created' => 'TAMBAH',
|
||||
'updated' => 'UBAH',
|
||||
'deleted' => 'HAPUS',
|
||||
default => $activity->description,
|
||||
};
|
||||
|
||||
if (isset($activity->properties['attributes'])) {
|
||||
$attributeMap = [
|
||||
'price' => 'Harga',
|
||||
'qty' => 'Jumlah',
|
||||
'total' => 'Total',
|
||||
'price_type' => 'Jenis Harga',
|
||||
];
|
||||
|
||||
$properties = $activity->properties->toArray();
|
||||
|
||||
$localizeValues = function ($attrs) use ($attributeMap) {
|
||||
$newAttrs = [];
|
||||
foreach ($attrs as $key => $value) {
|
||||
$label = $attributeMap[$key] ?? $key;
|
||||
$newAttrs[$label] = $value;
|
||||
}
|
||||
|
||||
return $newAttrs;
|
||||
};
|
||||
|
||||
$properties['attributes'] = $localizeValues($properties['attributes']);
|
||||
|
||||
if (isset($properties['old'])) {
|
||||
$properties['old'] = $localizeValues($properties['old']);
|
||||
}
|
||||
|
||||
$activity->properties = collect($properties);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -88,6 +88,16 @@ public function prices(): HasMany
|
||||
return $this->hasMany(ProductPrice::class);
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function purchaseItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseItem::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
|
||||
@ -45,6 +45,21 @@ public function expenses(): HasMany
|
||||
return $this->hasMany(Expense::class);
|
||||
}
|
||||
|
||||
public function orders(): HasMany
|
||||
{
|
||||
return $this->hasMany(Order::class);
|
||||
}
|
||||
|
||||
public function orderItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(OrderItem::class);
|
||||
}
|
||||
|
||||
public function purchaseItems(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseItem::class);
|
||||
}
|
||||
|
||||
public function payrolls(): HasMany
|
||||
{
|
||||
return $this->hasMany(Payroll::class);
|
||||
|
||||
39
database/factories/OrderFactory.php
Normal file
39
database/factories/OrderFactory.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use App\Models\Order;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Order>
|
||||
*/
|
||||
class OrderFactory extends Factory
|
||||
{
|
||||
protected $model = Order::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => User::inRandomOrder()->first()?->id ?? User::factory(),
|
||||
'invoice_number' => 'INV-'.now()->format('YmdHis').'-'.strtoupper($this->faker->bothify('??##')),
|
||||
'customer_name' => $this->faker->name(),
|
||||
'hpp' => 0,
|
||||
'discount' => 0,
|
||||
'payment' => 0,
|
||||
'total' => 0,
|
||||
'payment_method' => $this->faker->randomElement(PaymentMethod::cases()),
|
||||
'order_status' => $this->faker->randomElement(OrderStatus::cases()),
|
||||
'order_channel' => $this->faker->randomElement(OrderChannel::cases()),
|
||||
];
|
||||
}
|
||||
}
|
||||
39
database/factories/OrderItemFactory.php
Normal file
39
database/factories/OrderItemFactory.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<OrderItem>
|
||||
*/
|
||||
class OrderItemFactory extends Factory
|
||||
{
|
||||
protected $model = OrderItem::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$qty = $this->faker->numberBetween(1, 10);
|
||||
$price = $this->faker->numberBetween(50000, 200000);
|
||||
|
||||
return [
|
||||
'user_id' => User::inRandomOrder()->first()?->id ?? User::factory(),
|
||||
'order_id' => Order::factory(),
|
||||
'product_id' => Product::inRandomOrder()->first()?->id ?? Product::factory(),
|
||||
'price' => $price,
|
||||
'qty' => $qty,
|
||||
'total' => $price * $qty,
|
||||
'price_type' => $this->faker->randomElement(PriceType::cases()),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\OrderChannel;
|
||||
use App\Enums\OrderStatus;
|
||||
use App\Enums\PaymentMethod;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('invoice_number', 30);
|
||||
$table->string('customer_name', 100);
|
||||
$table->unsignedInteger('hpp');
|
||||
$table->unsignedInteger('discount');
|
||||
$table->unsignedInteger('payment');
|
||||
$table->unsignedInteger('total');
|
||||
$table->enum('payment_method', PaymentMethod::cases());
|
||||
$table->enum('order_status', OrderStatus::cases());
|
||||
$table->enum('order_channel', OrderChannel::cases());
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrentOnUpdate()->nullable();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('orders');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\PriceType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('order_id')->nullable()->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('product_id')->constrained()->cascadeOnDelete();
|
||||
$table->unsignedInteger('price');
|
||||
$table->unsignedInteger('qty');
|
||||
$table->unsignedInteger('total');
|
||||
$table->enum('price_type', PriceType::cases());
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
$table->timestamp('updated_at')->useCurrentOnUpdate()->nullable();
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('order_items');
|
||||
}
|
||||
};
|
||||
@ -19,6 +19,7 @@ public function run(): void
|
||||
ProductSeeder::class,
|
||||
ExpenseSeeder::class,
|
||||
PurchaseSeeder::class,
|
||||
OrderSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
64
database/seeders/OrderSeeder.php
Normal file
64
database/seeders/OrderSeeder.php
Normal file
@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\Product;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class OrderSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$users = User::all();
|
||||
$products = Product::all();
|
||||
|
||||
if ($users->isEmpty() || $products->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Order::factory(20)->create()->each(function ($order) use ($users, $products) {
|
||||
$itemsCount = rand(1, 4);
|
||||
$totalOrderPrice = 0;
|
||||
$totalOrderHpp = 0;
|
||||
|
||||
for ($i = 0; $i < $itemsCount; $i++) {
|
||||
$product = $products->random();
|
||||
$qty = rand(1, 5);
|
||||
$price = rand(100000, 300000);
|
||||
$itemTotal = $qty * $price;
|
||||
|
||||
// Assuming hpp is 70% of price for simulation
|
||||
$hpp = intval($price * 0.7);
|
||||
$totalOrderHpp += ($hpp * $qty);
|
||||
|
||||
OrderItem::factory()->create([
|
||||
'order_id' => $order->id,
|
||||
'user_id' => $users->random()->id,
|
||||
'product_id' => $product->id,
|
||||
'qty' => $qty,
|
||||
'price' => $price,
|
||||
'total' => $itemTotal,
|
||||
]);
|
||||
|
||||
$product->decrement('stock', $qty);
|
||||
$totalOrderPrice += $itemTotal;
|
||||
}
|
||||
|
||||
$discount = rand(0, 1) ? rand(5000, 20000) : 0;
|
||||
$finalTotal = max(0, $totalOrderPrice - $discount);
|
||||
|
||||
$order->update([
|
||||
'hpp' => $totalOrderHpp,
|
||||
'discount' => $discount,
|
||||
'total' => $finalTotal,
|
||||
'payment' => $finalTotal, // Assume fully paid
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingCart, User, Wallet } from 'lucide-react';
|
||||
import { Boxes, DollarSign, History, LayoutGrid, List, ScrollText, Settings, ShoppingBag, ShoppingCart, User, Wallet } from 'lucide-react';
|
||||
import AppLogo from '@/components/app-logo';
|
||||
import { NavMain } from '@/components/nav-main';
|
||||
import {
|
||||
@ -20,6 +20,7 @@ import payroll from '@/routes/payroll';
|
||||
import user from '@/routes/user';
|
||||
import system from '@/routes/system';
|
||||
import purchase from '@/routes/purchase';
|
||||
import order from '@/routes/order';
|
||||
|
||||
const mainNavItems: NavItem[] = [
|
||||
{
|
||||
@ -48,6 +49,11 @@ const masterNavItems: NavItem[] = [
|
||||
];
|
||||
|
||||
const manageNavItems: NavItem[] = [
|
||||
{
|
||||
title: 'Pesanan',
|
||||
href: order.index().url,
|
||||
icon: ShoppingBag,
|
||||
},
|
||||
{
|
||||
title: 'Belanja',
|
||||
href: purchase.index().url,
|
||||
|
||||
632
resources/js/pages/admin/manage/order/create.tsx
Normal file
632
resources/js/pages/admin/manage/order/create.tsx
Normal file
@ -0,0 +1,632 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Product, ProductPrice } from '@/types';
|
||||
import { OrderItem } from '@/types/order';
|
||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
|
||||
type CartItem = OrderItem & { id: number };
|
||||
type EnumOption = { value: string, label: string };
|
||||
|
||||
export default function OrderCreate({ products, cartItems, orderStatus, orderChannels, paymentMethods, priceTypes }: {
|
||||
products: Product[],
|
||||
cartItems: CartItem[],
|
||||
orderStatus: EnumOption[],
|
||||
orderChannels: EnumOption[],
|
||||
paymentMethods: EnumOption[],
|
||||
priceTypes: EnumOption[]
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [qtyDialogIndex, setQtyDialogIndex] = useState<number | null>(null);
|
||||
const [qtyInputValue, setQtyInputValue] = useState('');
|
||||
|
||||
// Default price type for the catalog
|
||||
const [globalPriceType, setGlobalPriceType] = useState<string>('retail');
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
const { data, setData, post, processing, errors, transform } = useForm({
|
||||
invoice_number: '',
|
||||
customer_name: '',
|
||||
discount: '',
|
||||
payment: '',
|
||||
payment_method: 'cash',
|
||||
order_status: 'delivered',
|
||||
order_channel: 'store',
|
||||
items: [] as any[],
|
||||
});
|
||||
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const getCartItem = (productId: number, priceType: string) =>
|
||||
cartItems.find(item => item.product_id === productId && item.price_type === priceType);
|
||||
|
||||
const getProductPrice = (product: Product, priceType: string) => {
|
||||
return product.prices?.find(p => p.price_type === priceType)?.price || 0;
|
||||
};
|
||||
|
||||
const addToCart = (product: Product, priceType: string) => {
|
||||
const price = getProductPrice(product, priceType);
|
||||
router.post(orderRoutes.addToCart().url, {
|
||||
product_id: product.id,
|
||||
qty: 1,
|
||||
price: price,
|
||||
price_type: priceType
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const decreaseQuantity = (item: CartItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
router.post(orderRoutes.addToCart().url, {
|
||||
product_id: item.product_id,
|
||||
qty: -1,
|
||||
price: item.price,
|
||||
price_type: item.price_type
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const increaseQuantity = (item: CartItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
router.post(orderRoutes.addToCart().url, {
|
||||
product_id: item.product_id,
|
||||
qty: 1,
|
||||
price: item.price,
|
||||
price_type: item.price_type
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromCart = (itemId: number) => {
|
||||
router.delete(orderRoutes.removeFromCart(itemId).url, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCartQuantity = (itemId: number, qty: number) => {
|
||||
if (qty < 1) return;
|
||||
router.patch(orderRoutes.updateCartItem(itemId).url, {
|
||||
qty
|
||||
}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openQtyDialog = (item: CartItem, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQtyInputValue(String(item.qty));
|
||||
setQtyDialogIndex(item.id);
|
||||
};
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
|
||||
updateCartQuantity(qtyDialogIndex, val);
|
||||
}
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (cartItems.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
return;
|
||||
}
|
||||
|
||||
transform((data) => ({
|
||||
...data,
|
||||
items: cartItems.map(item => ({
|
||||
product_id: item.product_id,
|
||||
qty: item.qty,
|
||||
price: item.price,
|
||||
total: item.total,
|
||||
price_type: item.price_type
|
||||
}))
|
||||
}));
|
||||
|
||||
post(orderRoutes.store().url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const subtotal = useMemo(() => {
|
||||
return cartItems.reduce((acc, item) => acc + item.total, 0);
|
||||
}, [cartItems]);
|
||||
|
||||
const total = subtotal - data.discount;
|
||||
const change = data.payment - total;
|
||||
const cartTotalItems = cartItems.reduce((a, i) => a + i.qty, 0);
|
||||
|
||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||
const option = priceTypes.find(opt => opt.value === type);
|
||||
return option ? option.label : type;
|
||||
};
|
||||
|
||||
const CartFormContent = () => (
|
||||
<div className="flex flex-col h-full">
|
||||
<CardHeader className="border-b py-4 flex-row items-center justify-between shrink-0">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Keranjang
|
||||
</CardTitle>
|
||||
<Badge className="rounded-full font-bold">
|
||||
{cartTotalItems} pcs
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<CardContent className="p-0">
|
||||
{cartItems.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Keranjang masih kosong</p>
|
||||
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{cartItems.map((item) => (
|
||||
<div key={item.id} className="p-4 space-y-2.5">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-11 w-11 rounded-md bg-muted overflow-hidden shrink-0 border">
|
||||
{item.product?.thumbnail_url ? (
|
||||
<img src={item.product.thumbnail_url} className="h-full w-full object-cover" alt={item.product.name} />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ImagePlus className="h-4 w-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold leading-tight line-clamp-1">{item.product?.name}</h4>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4 bg-primary/5 border-primary/20 text-primary">
|
||||
<PriceTypeLabel type={item.price_type} />
|
||||
</Badge>
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Rp {item.total.toLocaleString('id-ID')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => removeFromCart(item.id)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center bg-muted/50 rounded-lg border px-1">
|
||||
<button type="button" onClick={(e) => decreaseQuantity(item, e)} className="p-1 hover:text-primary"><Minus className="h-3 w-3" /></button>
|
||||
<span className="px-2 font-medium text-foreground">{item.qty}</span>
|
||||
<button type="button" onClick={(e) => increaseQuantity(item, e)} className="p-1 hover:text-primary"><Plus className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<span>×</span>
|
||||
<span className="font-mono">
|
||||
Rp {item.price.toLocaleString('id-ID')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<Label htmlFor='customer_name' className="text-xs text-muted-foreground" required>Pelanggan</Label>
|
||||
<Input id="customer_name" className="h-8 text-xs" value={data.customer_name} onChange={e => setData('customer_name', e.target.value)} placeholder="Nama Pelanggan" />
|
||||
{errors.customer_name && <p className="text-xs text-red-500">{errors.customer_name}</p>}
|
||||
</Field>
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Channel</Label>
|
||||
<Select value={data.order_channel} onValueChange={val => setData('order_channel', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderChannels.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.order_channel && <p className="text-xs text-red-500">{errors.order_channel}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Metode Bayar</Label>
|
||||
<Select value={data.payment_method} onValueChange={val => setData('payment_method', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentMethods.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.payment_method && <p className="text-xs text-red-500">{errors.payment_method}</p>}
|
||||
</Field>
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Status</Label>
|
||||
<Select value={data.order_status} onValueChange={val => setData('order_status', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderStatus.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.order_status && <p className="text-xs text-red-500">{errors.order_status}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-2 border-t border-dashed">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="font-medium">Rp {subtotal.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">Potongan / Diskon</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumericFormat
|
||||
id="discount"
|
||||
customInput={Input}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix="Rp "
|
||||
value={data.discount}
|
||||
onValueChange={(values) => {
|
||||
setData('discount', values.floatValue || 0)
|
||||
}}
|
||||
placeholder="Rp 0"
|
||||
autoComplete='off'
|
||||
/>
|
||||
{errors.discount && <p className="text-xs text-red-500">{errors.discount}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span className="">Total</span>
|
||||
<span className="font-medium">Rp {total.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground">Uang Bayar</Label>
|
||||
<NumericFormat
|
||||
id="payment"
|
||||
customInput={Input}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix="Rp "
|
||||
value={data.payment}
|
||||
onValueChange={(values) => {
|
||||
setData('payment', values.floatValue || 0)
|
||||
}}
|
||||
placeholder="Rp 0"
|
||||
autoComplete='off'
|
||||
/>
|
||||
</Field>
|
||||
{change >= 0 && data.payment > 0 && (
|
||||
<div className="flex justify-between items-center p-2 rounded bg-green-500/10 border border-green-500/20 text-green-600 dark:text-green-400">
|
||||
<span className="text-xs font-bold uppercase">Kembalian</span>
|
||||
<span className="text-lg font-bold">Rp {change.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 font-semibold shadow"
|
||||
disabled={processing || cartItems.length === 0}
|
||||
onClick={() => {
|
||||
if (cartItems.length > 0) {
|
||||
onSubmit({ preventDefault: () => { } } as any)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{processing ? 'Menyimpan...' : 'Checkout Pesanan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:h-[calc(100vh-64px)] lg:overflow-hidden">
|
||||
<Head title="Buat Pesanan" />
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Tambah Pesanan</h1>
|
||||
<Link href={orderRoutes.index().url}>
|
||||
<Button variant='outline'>Kembali</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-12 gap-6 pb-20 lg:pb-0">
|
||||
<div className="lg:col-span-8 flex flex-col gap-5 h-full min-h-0">
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Cari nama produk..."
|
||||
className="pl-10 h-10"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-full sm:w-40 h-10">
|
||||
<SelectValue placeholder="Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={globalPriceType} onValueChange={setGlobalPriceType}>
|
||||
<SelectTrigger className="w-full sm:w-40 h-10 bg-primary/5 text-primary border-primary/20 font-bold">
|
||||
<Tag className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priceTypes.filter(t => t.value !== 'purchase').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full pr-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-4 pb-10">
|
||||
{filteredProducts.map((product) => {
|
||||
const cartItem = getCartItem(product.id, globalPriceType);
|
||||
const currentPrice = getProductPrice(product, globalPriceType);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
className={cn(
|
||||
"group cursor-pointer transition-all duration-200 bg-card border p-0",
|
||||
"hover:shadow-lg",
|
||||
cartItem
|
||||
? "border-primary shadow-md"
|
||||
: "shadow-sm hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => addToCart(product, globalPriceType)}
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="-mx-4 -mt-4 aspect-[4/3] overflow-hidden bg-muted/50 rounded-t-xl relative">
|
||||
{product.thumbnail_url ? (
|
||||
<img
|
||||
src={product.thumbnail_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ImagePlus className="h-10 w-10 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
{cartItem && (
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center bg-primary text-primary-foreground text-[10px] font-bold h-6 min-w-6 px-1 rounded-full shadow-lg">
|
||||
{cartItem.qty}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{product.categories?.map(cat => (
|
||||
<Badge key={cat.id} variant="outline" className="text-[10px] h-4 px-1.5 bg-muted/50">
|
||||
{cat.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="font-bold text-sm leading-snug line-clamp-2 min-h-[2.5rem]">
|
||||
{product.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Price + Stepper */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-auto pt-2 border-t"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground">Harga</span>
|
||||
<span className="text-sm font-bold text-primary tabular-nums truncate block">
|
||||
Rp {currentPrice.toLocaleString('id-ID')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-muted/50 rounded-xl sm:rounded-full p-1 sm:p-0.5 shrink-0 w-full sm:w-auto justify-between sm:justify-end border border-transparent hover:border-primary/20 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full flex items-center justify-center transition-all",
|
||||
cartItem
|
||||
? "bg-background text-primary shadow-sm hover:bg-primary hover:text-primary-foreground"
|
||||
: "text-muted-foreground/30 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!cartItem}
|
||||
onClick={(e) => cartItem && decreaseQuantity(cartItem, e)}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 sm:flex-none sm:min-w-[36px] px-1 text-center text-sm font-bold tabular-nums transition-colors",
|
||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
{cartItem?.qty ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/80 transition-all shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cartItem ? increaseQuantity(cartItem, e) : addToCart(product, globalPriceType);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{filteredProducts.length === 0 && (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>Produk tidak ditemukan</EmptyTitle>
|
||||
<EmptyDescription>Coba kata kunci lain atau kategori berbeda.</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block lg:col-span-4 h-full min-h-0">
|
||||
<Card className="border shadow-2xl bg-card overflow-hidden h-full flex flex-col border-primary/10">
|
||||
{CartFormContent()}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden fixed bottom-6 left-0 right-0 px-6 z-50 pointer-events-none">
|
||||
<div className="max-w-md mx-auto pointer-events-auto">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full rounded-full shadow-2xl h-14 flex items-center justify-between px-6 bg-primary animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary-foreground/20 rounded-full h-8 w-8 flex items-center justify-center">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-left leading-tight">
|
||||
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Keranjang</p>
|
||||
<p className="text-sm font-bold">{cartTotalItems} Item</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Total</p>
|
||||
<p className="text-sm font-bold">Rp {total.toLocaleString('id-ID')}</p>
|
||||
</div>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" showCloseButton={false} className="p-0 !h-[90vh] rounded-t-[2.5rem] flex flex-col overflow-hidden">
|
||||
{CartFormContent()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Input Dialog */}
|
||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label className="text-sm mb-2 block">
|
||||
{qtyDialogIndex !== null ? cartItems.find(i => i.id === qtyDialogIndex)?.product?.name : ''}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
onChange={e => setQtyInputValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
||||
className="text-lg font-bold"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setQtyDialogIndex(null)}>Batal</Button>
|
||||
<Button onClick={confirmQty}>Konfirmasi</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OrderCreate.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Kelola' },
|
||||
],
|
||||
};
|
||||
619
resources/js/pages/admin/manage/order/edit.tsx
Normal file
619
resources/js/pages/admin/manage/order/edit.tsx
Normal file
@ -0,0 +1,619 @@
|
||||
import { Head, Link, useForm, router } from '@inertiajs/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Field } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Product, ProductPrice } from '@/types';
|
||||
import { Order, OrderItem } from '@/types/order';
|
||||
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
} from "@/components/ui/sheet";
|
||||
import { NumericFormat } from 'react-number-format';
|
||||
|
||||
type EnumOption = { value: string, label: string };
|
||||
|
||||
export default function OrderEdit({ order, products, orderStatus, orderChannels, paymentMethods, priceTypes }: {
|
||||
order: Order,
|
||||
products: Product[],
|
||||
orderStatus: EnumOption[],
|
||||
orderChannels: EnumOption[],
|
||||
paymentMethods: EnumOption[],
|
||||
priceTypes: EnumOption[]
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [qtyDialogIndex, setQtyDialogIndex] = useState<{ product_id: number, price_type: string } | null>(null);
|
||||
const [qtyInputValue, setQtyInputValue] = useState('');
|
||||
const [globalPriceType, setGlobalPriceType] = useState<string>('retail');
|
||||
|
||||
const categories = useMemo(() => {
|
||||
const map = new Map<number, string>();
|
||||
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
|
||||
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
|
||||
}, [products]);
|
||||
|
||||
const { data, setData, patch, processing, errors } = useForm({
|
||||
invoice_number: order.invoice_number,
|
||||
customer_name: order.customer_name,
|
||||
discount: order.discount,
|
||||
payment: order.payment,
|
||||
payment_method: order.payment_method,
|
||||
order_status: order.order_status,
|
||||
order_channel: order.order_channel,
|
||||
items: (order.items?.map(item => ({
|
||||
product_id: item.product_id,
|
||||
qty: item.qty,
|
||||
price: item.price,
|
||||
total: item.total,
|
||||
price_type: item.price_type,
|
||||
product: products.find(p => p.id === item.product_id) || item.product
|
||||
})) ?? []) as any[],
|
||||
});
|
||||
|
||||
const filteredProducts = products.filter(p => {
|
||||
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
|
||||
return matchesSearch && matchesCategory;
|
||||
});
|
||||
|
||||
const findItemIndex = (productId: number, priceType: string) =>
|
||||
data.items.findIndex(item => item.product_id === productId && item.price_type === priceType);
|
||||
|
||||
const getProductPrice = (product: Product, priceType: string) => {
|
||||
return product.prices?.find(p => p.price_type === priceType)?.price || 0;
|
||||
};
|
||||
|
||||
const addToCart = (product: Product, priceType: string) => {
|
||||
const index = findItemIndex(product.id, priceType);
|
||||
const price = getProductPrice(product, priceType);
|
||||
|
||||
if (index > -1) {
|
||||
const newItems = [...data.items];
|
||||
newItems[index].qty += 1;
|
||||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||||
setData('items', newItems);
|
||||
} else {
|
||||
setData('items', [
|
||||
...data.items,
|
||||
{
|
||||
product_id: product.id,
|
||||
qty: 1,
|
||||
price: price,
|
||||
total: price,
|
||||
price_type: priceType,
|
||||
product: product
|
||||
}
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const index = findItemIndex(productId, priceType);
|
||||
if (index === -1) return;
|
||||
|
||||
const newItems = [...data.items];
|
||||
if (newItems[index].qty <= 1) {
|
||||
newItems.splice(index, 1);
|
||||
} else {
|
||||
newItems[index].qty -= 1;
|
||||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||||
}
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const product = products.find(p => p.id === productId);
|
||||
if (product) addToCart(product, priceType);
|
||||
};
|
||||
|
||||
const removeFromCart = (productId: number, priceType: string) => {
|
||||
const newItems = data.items.filter(item => !(item.product_id === productId && item.price_type === priceType));
|
||||
setData('items', newItems);
|
||||
};
|
||||
|
||||
const updateCartQuantity = (productId: number, priceType: string, qty: number) => {
|
||||
if (qty < 1) return;
|
||||
const index = findItemIndex(productId, priceType);
|
||||
if (index > -1) {
|
||||
const newItems = [...data.items];
|
||||
newItems[index].qty = qty;
|
||||
newItems[index].total = qty * newItems[index].price;
|
||||
setData('items', newItems);
|
||||
}
|
||||
};
|
||||
|
||||
const openQtyDialog = (item: any, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setQtyInputValue(String(item.qty));
|
||||
setQtyDialogIndex({ product_id: item.product_id, price_type: item.price_type });
|
||||
};
|
||||
|
||||
const confirmQty = () => {
|
||||
const val = parseInt(qtyInputValue);
|
||||
if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
|
||||
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
|
||||
}
|
||||
setQtyDialogIndex(null);
|
||||
};
|
||||
|
||||
const onSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (data.items.length === 0) {
|
||||
toast.error('Pilih minimal satu produk');
|
||||
return;
|
||||
}
|
||||
|
||||
patch(orderRoutes.update(order.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const subtotal = useMemo(() => {
|
||||
return data.items.reduce((acc, item) => acc + item.total, 0);
|
||||
}, [data.items]);
|
||||
|
||||
const total = subtotal - data.discount;
|
||||
const change = data.payment - total;
|
||||
const cartTotalItems = data.items.reduce((a, i) => a + i.qty, 0);
|
||||
|
||||
const PriceTypeLabel = ({ type }: { type: string }) => {
|
||||
const option = priceTypes.find(opt => opt.value === type);
|
||||
return option ? option.label : type;
|
||||
};
|
||||
|
||||
const CartFormContent = () => (
|
||||
<div className="flex flex-col h-full">
|
||||
<CardHeader className="border-b py-4 flex-row items-center justify-between shrink-0">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
Keranjang
|
||||
</CardTitle>
|
||||
<Badge className="rounded-full font-bold">
|
||||
{cartTotalItems} pcs
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<CardContent className="p-0">
|
||||
{data.items.length === 0 ? (
|
||||
<div className="py-16 text-center text-muted-foreground">
|
||||
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Keranjang masih kosong</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border">
|
||||
{data.items.map((item, idx) => (
|
||||
<div key={idx} className="p-4 space-y-2.5">
|
||||
<div className="flex gap-3">
|
||||
<div className="h-11 w-11 rounded-md bg-muted overflow-hidden shrink-0 border">
|
||||
{item.product?.thumbnail_url ? (
|
||||
<img src={item.product.thumbnail_url} className="h-full w-full object-cover" alt={item.product.name} />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ImagePlus className="h-4 w-4 text-muted-foreground/50" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold leading-tight line-clamp-1">{item.product?.name}</h4>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4 bg-primary/5 border-primary/20 text-primary">
|
||||
<PriceTypeLabel type={item.price_type} />
|
||||
</Badge>
|
||||
<p className="text-xs text-primary font-medium">
|
||||
Rp {item.total.toLocaleString('id-ID')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20"
|
||||
onClick={() => removeFromCart(item.product_id, item.price_type)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center bg-muted/50 rounded-lg border px-1">
|
||||
<button type="button" onClick={(e) => decreaseQuantity(item.product_id, item.price_type, e)} className="p-1 hover:text-primary"><Minus className="h-3 w-3" /></button>
|
||||
<span className="px-2 font-medium text-foreground">{item.qty}</span>
|
||||
<button type="button" onClick={(e) => increaseQuantity(item.product_id, item.price_type, e)} className="p-1 hover:text-primary"><Plus className="h-3 w-3" /></button>
|
||||
</div>
|
||||
<span>×</span>
|
||||
<span className="font-mono">
|
||||
Rp {item.price.toLocaleString('id-ID')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<Label htmlFor='customer_name' className="text-xs text-muted-foreground" required>Pelanggan</Label>
|
||||
<Input id='customer_name' className="h-8 text-xs" value={data.customer_name} onChange={e => setData('customer_name', e.target.value)} />
|
||||
{errors.customer_name && <p className="text-xs text-red-500">{errors.customer_name}</p>}
|
||||
</Field>
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Channel</Label>
|
||||
<Select value={data.order_channel} onValueChange={val => setData('order_channel', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderChannels.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.order_channel && <p className="text-xs text-red-500">{errors.order_channel}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Metode Bayar</Label>
|
||||
<Select value={data.payment_method} onValueChange={val => setData('payment_method', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentMethods.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.payment_method && <p className="text-xs text-red-500">{errors.payment_method}</p>}
|
||||
</Field>
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground" required>Status</Label>
|
||||
<Select value={data.order_status} onValueChange={val => setData('order_status', val as any)}>
|
||||
<SelectTrigger className="h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{orderStatus.map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.order_status && <p className="text-xs text-red-500">{errors.order_status}</p>}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 pt-2 border-t border-dashed text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Subtotal</span>
|
||||
<span className="font-medium">Rp {subtotal.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Potongan / Diskon</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumericFormat
|
||||
id="discount"
|
||||
customInput={Input}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix="Rp "
|
||||
value={data.discount}
|
||||
onValueChange={(values) => {
|
||||
setData('discount', values.floatValue || 0)
|
||||
}}
|
||||
placeholder="Rp 0"
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
{errors.discount && <p className="text-xs text-red-500">{errors.discount}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
||||
<span className="">Total</span>
|
||||
<span className="font-medium">Rp {total.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Field>
|
||||
<Label className="text-xs text-muted-foreground">Uang Bayar</Label>
|
||||
<NumericFormat
|
||||
id="payment"
|
||||
customInput={Input}
|
||||
thousandSeparator="."
|
||||
decimalSeparator=","
|
||||
prefix="Rp "
|
||||
value={data.payment}
|
||||
onValueChange={(values) => {
|
||||
setData('payment', values.floatValue || 0)
|
||||
}}
|
||||
placeholder="Rp 0"
|
||||
autoComplete='off'
|
||||
/>
|
||||
</Field>
|
||||
{change >= 0 && data.payment > 0 && (
|
||||
<div className="flex justify-between items-center p-2 rounded bg-green-500/10 border border-green-500/20 text-green-600 dark:text-green-400">
|
||||
<span className="text-xs font-bold uppercase">Kembalian</span>
|
||||
<span className="text-lg font-bold">Rp {change.toLocaleString('id-ID')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-10 font-semibold shadow"
|
||||
disabled={processing || data.items.length === 0}
|
||||
onClick={() => {
|
||||
if (data.items.length > 0) {
|
||||
onSubmit({ preventDefault: () => { } } as any)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShoppingCart className="h-4 w-4 mr-2" />
|
||||
{processing ? 'Menyimpan...' : 'Perbarui Pesanan'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 lg:h-[calc(100vh-64px)] lg:overflow-hidden">
|
||||
<Head title="Ubah Pesanan" />
|
||||
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Ubah Pesanan</h1>
|
||||
<Link href={orderRoutes.index().url}>
|
||||
<Button variant='outline'>Kembali</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-12 gap-6 pb-20 lg:pb-0">
|
||||
<div className="lg:col-span-8 flex flex-col gap-5 h-full min-h-0">
|
||||
<div className="flex flex-col sm:flex-row gap-2 shrink-0">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
placeholder="Cari nama produk..."
|
||||
className="pl-10 h-10"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-full sm:w-40 h-10">
|
||||
<SelectValue placeholder="Kategori" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Semua</SelectItem>
|
||||
{categories.map(cat => (
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>{cat.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={globalPriceType} onValueChange={setGlobalPriceType}>
|
||||
<SelectTrigger className="w-full sm:w-40 h-10 bg-primary/5 text-primary border-primary/20 font-bold">
|
||||
<Tag className="mr-2 h-4 w-4" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{priceTypes.filter(t => t.value !== 'purchase').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 h-full pr-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-4 pb-10">
|
||||
{filteredProducts.map((product) => {
|
||||
const index = findItemIndex(product.id, globalPriceType);
|
||||
const cartItem = index > -1 ? data.items[index] : null;
|
||||
const currentPrice = getProductPrice(product, globalPriceType);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={product.id}
|
||||
className={cn(
|
||||
"group cursor-pointer transition-all duration-200 bg-card border p-0",
|
||||
"hover:shadow-lg",
|
||||
cartItem
|
||||
? "border-primary shadow-md"
|
||||
: "shadow-sm hover:border-primary/50"
|
||||
)}
|
||||
onClick={() => addToCart(product, globalPriceType)}
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3">
|
||||
<div className="-mx-4 -mt-4 aspect-[4/3] overflow-hidden bg-muted/50 rounded-t-xl relative">
|
||||
{product.thumbnail_url ? (
|
||||
<img
|
||||
src={product.thumbnail_url}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<ImagePlus className="h-10 w-10 text-muted-foreground/30" />
|
||||
</div>
|
||||
)}
|
||||
{cartItem && (
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center bg-primary text-primary-foreground text-[10px] font-bold h-6 min-w-6 px-1 rounded-full shadow-lg">
|
||||
{cartItem.qty}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-wrap gap-1 mb-1.5">
|
||||
{product.categories?.map(cat => (
|
||||
<Badge key={cat.id} variant="outline" className="text-[10px] h-4 px-1.5 bg-muted/50">
|
||||
{cat.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="font-bold text-sm leading-snug line-clamp-2 min-h-[2.5rem]">
|
||||
{product.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Price + Stepper */}
|
||||
<div
|
||||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-auto pt-2 border-t"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground">Harga</span>
|
||||
<span className="text-sm font-bold text-primary tabular-nums truncate block">
|
||||
Rp {currentPrice.toLocaleString('id-ID')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 bg-muted/50 rounded-xl sm:rounded-full p-1 sm:p-0.5 shrink-0 w-full sm:w-auto justify-between sm:justify-end border border-transparent hover:border-primary/20 transition-colors">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full flex items-center justify-center transition-all",
|
||||
cartItem
|
||||
? "bg-background text-primary shadow-sm hover:bg-primary hover:text-primary-foreground"
|
||||
: "text-muted-foreground/30 cursor-not-allowed"
|
||||
)}
|
||||
disabled={!cartItem}
|
||||
onClick={(e) => cartItem && decreaseQuantity(product.id, globalPriceType, e)}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex-1 sm:flex-none sm:min-w-[36px] px-1 text-center text-sm font-bold tabular-nums transition-colors",
|
||||
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!cartItem) return;
|
||||
openQtyDialog(cartItem, e);
|
||||
}}
|
||||
>
|
||||
{cartItem?.qty ?? 0}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/80 transition-all shadow-sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cartItem ? increaseQuantity(product.id, globalPriceType, e) : addToCart(product, globalPriceType);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="hidden lg:block lg:col-span-4 h-full min-h-0">
|
||||
<Card className="border shadow-2xl bg-card overflow-hidden h-full flex flex-col border-primary/10">
|
||||
{CartFormContent()}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden fixed bottom-6 left-0 right-0 px-6 z-50 pointer-events-none">
|
||||
<div className="max-w-md mx-auto pointer-events-auto">
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-full rounded-full shadow-2xl h-14 flex items-center justify-between px-6 bg-primary"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary-foreground/20 rounded-full h-8 w-8 flex items-center justify-center">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="text-left leading-tight">
|
||||
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Keranjang</p>
|
||||
<p className="text-sm font-bold">{cartTotalItems} Item</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Total</p>
|
||||
<p className="text-sm font-bold">Rp {total.toLocaleString('id-ID')}</p>
|
||||
</div>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" showCloseButton={false} className="p-0 !h-[90vh] rounded-t-[2.5rem] flex flex-col overflow-hidden">
|
||||
{CartFormContent()}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quantity Input Dialog */}
|
||||
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
|
||||
<DialogContent className="max-w-xs">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ubah Jumlah</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label className="text-sm mb-2 block">
|
||||
{qtyDialogIndex !== null ? data.items.find(i => i.product_id === qtyDialogIndex.product_id && i.price_type === qtyDialogIndex.price_type)?.product?.name : ''}
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={qtyInputValue}
|
||||
onChange={e => setQtyInputValue(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
|
||||
className="text-lg font-bold"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setQtyDialogIndex(null)}>Batal</Button>
|
||||
<Button onClick={confirmQty}>Konfirmasi</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OrderEdit.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Kelola' },
|
||||
],
|
||||
};
|
||||
@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Order } from '@/types/order';
|
||||
import { router } from '@inertiajs/react';
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useOrderIndex() {
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [orderToDelete, setOrderToDelete] = useState<Order | null>(null);
|
||||
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const onDelete = (order: Order) => {
|
||||
setOrderToDelete(order);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (orderToDelete) {
|
||||
router.delete(orderRoutes.destroy(orderToDelete.id).url, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsDeleteDialogOpen(false);
|
||||
setOrderToDelete(null);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const confirmBulkDelete = () => {
|
||||
router.post(orderRoutes.bulkDestroy().url, {
|
||||
ids: rowsToDelete.map((row: any) => row.id),
|
||||
_method: 'DELETE'
|
||||
}, {
|
||||
onSuccess: (response: any) => {
|
||||
toast.success(response.props.flash.success);
|
||||
setIsBulkDeleteDialogOpen(false);
|
||||
setRowsToDelete([]);
|
||||
setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
orderToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
};
|
||||
}
|
||||
130
resources/js/pages/admin/manage/order/index.tsx
Normal file
130
resources/js/pages/admin/manage/order/index.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import type { Order } from '@/types/order';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Trash2, ShoppingBag } from 'lucide-react';
|
||||
import { DataTable } from '@/components/data-table';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import { useOrderIndex } from './hooks/use-order-index';
|
||||
import { getColumns } from './partials/columns';
|
||||
|
||||
export default function OrderIndex({ orders }: { orders: Order[] }) {
|
||||
const {
|
||||
isDeleteDialogOpen,
|
||||
isBulkDeleteDialogOpen,
|
||||
orderToDelete,
|
||||
rowsToDelete,
|
||||
rowSelection,
|
||||
setRowSelection,
|
||||
setRowsToDelete,
|
||||
setIsDeleteDialogOpen,
|
||||
setIsBulkDeleteDialogOpen,
|
||||
onDelete,
|
||||
confirmDelete,
|
||||
confirmBulkDelete,
|
||||
} = useOrderIndex();
|
||||
|
||||
const columns = getColumns({ onDelete });
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Head title="Pesanan" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Pesanan</h1>
|
||||
</div>
|
||||
<Link href={orderRoutes.create().url}>
|
||||
<Button>
|
||||
Tambah
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
|
||||
<CardContent className="p-0">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={orders}
|
||||
rowSelection={rowSelection}
|
||||
onRowSelectionChange={setRowSelection}
|
||||
bulkActions={[
|
||||
{
|
||||
label: 'Hapus Terpilih',
|
||||
onClick: (rows) => {
|
||||
setRowsToDelete(rows);
|
||||
setIsBulkDeleteDialogOpen(true);
|
||||
},
|
||||
icon: Trash2,
|
||||
variant: 'destructive'
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Single Delete Confirmation */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus data pesanan?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. Data pesanan dengan nomor invoice <strong>{orderToDelete?.invoice_number}</strong> akan dihapus secara permanen dan stok akan dikembalikan.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent size="default">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
|
||||
<Trash2 className="size-5" />
|
||||
</AlertDialogMedia>
|
||||
<AlertDialogTitle>Hapus {rowsToDelete.length} data pesanan?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> pesanan yang terpilih akan dihapus secara permanen.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmBulkDelete}
|
||||
variant="destructive"
|
||||
>
|
||||
Hapus
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
OrderIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Kelola',
|
||||
}
|
||||
],
|
||||
};
|
||||
131
resources/js/pages/admin/manage/order/partials/columns.tsx
Normal file
131
resources/js/pages/admin/manage/order/partials/columns.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { Order } from '@/types/order';
|
||||
import { DataTableColumnHeader } from '@/components/data-table-column-header';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Pencil, Trash2, ShoppingBag } from 'lucide-react';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import * as orderRoutes from '@/routes/order';
|
||||
import { format } from 'date-fns';
|
||||
import { id } from 'date-fns/locale';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface ColumnProps {
|
||||
onDelete: (order: Order) => void;
|
||||
}
|
||||
|
||||
export const getColumns = ({ onDelete }: ColumnProps): ColumnDef<Order>[] => [
|
||||
{
|
||||
accessorKey: "invoice_number",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="No. Invoice" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{row.original.invoice_number}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{format(new Date(row.original.created_at), 'dd MMM yyyy HH:mm', { locale: id })}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: { title: "No. Invoice" },
|
||||
},
|
||||
{
|
||||
accessorKey: "customer_name",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Pelanggan" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col">
|
||||
<span>{row.original.customer_name}</span>
|
||||
<Badge variant="outline" className="w-fit text-[10px] px-1 py-0 h-4">
|
||||
{row.original.order_channel}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
meta: { title: "Pelanggan" },
|
||||
},
|
||||
{
|
||||
accessorKey: "items",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Item" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const items = row.original.items;
|
||||
|
||||
if (!items || !Array.isArray(items) || items.length === 0) {
|
||||
return <span className="text-muted-foreground text-xs italic">-</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{items.slice(0, 2).map((item, i) => (
|
||||
<div key={i} className="text-xs">
|
||||
<span className="text-muted-foreground">{item.product?.name}:</span>{' '}
|
||||
<span className="font-medium text-primary">{item.qty} x {item.price_formatted}</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length > 2 && (
|
||||
<span className="text-[10px] text-muted-foreground italic">+{items.length - 2} item lainnya...</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Item" },
|
||||
},
|
||||
{
|
||||
accessorKey: "total",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Total" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span className="font-bold text-primary">
|
||||
{row.original.total_formatted}
|
||||
</span>
|
||||
<Badge className="text-[10px] h-4 py-0" variant={
|
||||
row.original.order_status === 'completed' ? 'default' :
|
||||
row.original.order_status === 'pending' ? 'secondary' :
|
||||
row.original.order_status === 'cancelled' ? 'destructive' : 'outline'
|
||||
}>
|
||||
{row.original.order_status}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
meta: { title: "Total" },
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Aksi",
|
||||
cell: ({ row }) => {
|
||||
const order = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link href={orderRoutes.edit(order.id).url}>
|
||||
<Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ubah</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(order)}>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Hapus</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
meta: { title: "Aksi" },
|
||||
},
|
||||
];
|
||||
34
resources/js/types/order.ts
Normal file
34
resources/js/types/order.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { Product } from "./product";
|
||||
|
||||
export interface Order {
|
||||
id: number;
|
||||
invoice_number: string;
|
||||
customer_name: string;
|
||||
hpp: number;
|
||||
discount: number;
|
||||
payment: number;
|
||||
total: number;
|
||||
total_formatted: string;
|
||||
payment_method: 'cash' | 'transfer' | 'e_wallet' | 'qris';
|
||||
order_status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
||||
order_channel: 'tiktok' | 'shopee' | 'tokopedia' | 'lazada' | 'facebook' | 'website' | 'store' | 'other';
|
||||
items?: OrderItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: number;
|
||||
order_id: number | null;
|
||||
user_id: number;
|
||||
product_id: number;
|
||||
price: number;
|
||||
price_formatted: string;
|
||||
qty: number;
|
||||
total: number;
|
||||
total_formatted: string;
|
||||
price_type: string;
|
||||
product?: Product;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Admin\Manage\OrderCartController;
|
||||
use App\Http\Controllers\Admin\Manage\OrderController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseCartController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@ -18,5 +20,18 @@
|
||||
Route::post('purchase/add-to-cart', [PurchaseCartController::class, 'addToCart'])->name('purchase.addToCart');
|
||||
Route::delete('purchase/remove-from-cart/{purchaseItem}', [PurchaseCartController::class, 'removeFromCart'])->name('purchase.removeFromCart');
|
||||
Route::patch('purchase/update-cart-item/{purchaseItem}', [PurchaseCartController::class, 'updateCartItem'])->name('purchase.updateCartItem');
|
||||
|
||||
Route::get('orders', [OrderController::class, 'index'])->name('order.index');
|
||||
Route::get('order/create', [OrderController::class, 'create'])->name('order.create');
|
||||
Route::post('order/store', [OrderController::class, 'store'])->name('order.store');
|
||||
Route::get('order/edit/{order}', [OrderController::class, 'edit'])->name('order.edit');
|
||||
Route::patch('order/update/{order}', [OrderController::class, 'update'])->name('order.update');
|
||||
Route::delete('order/destroy/{order}', [OrderController::class, 'destroy'])->name('order.destroy');
|
||||
Route::delete('order/bulk-destroy', [OrderController::class, 'bulkDestroy'])->name('order.bulkDestroy');
|
||||
|
||||
// Order Cart routes
|
||||
Route::post('order/add-to-cart', [OrderCartController::class, 'addToCart'])->name('order.addToCart');
|
||||
Route::delete('order/remove-from-cart/{orderItem}', [OrderCartController::class, 'removeFromCart'])->name('order.removeFromCart');
|
||||
Route::patch('order/update-cart-item/{orderItem}', [OrderCartController::class, 'updateCartItem'])->name('order.updateCartItem');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user