feat: implement full order management system including CRUD operations, inventory tracking, and payment processing

This commit is contained in:
Yoga Pangestu 2026-04-22 15:18:18 +07:00
parent a121e7b52d
commit 75450301bb
25 changed files with 2531 additions and 1 deletions

View 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
View 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',
};
}
}

View 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',
};
}
}

View 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();
}
}

View 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');
}
}

View 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),
],
];
}
}

View 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
View 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
View 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);
}
}
}

View File

@ -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()

View File

@ -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);

View 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()),
];
}
}

View 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()),
];
}
}

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -19,6 +19,7 @@ public function run(): void
ProductSeeder::class,
ExpenseSeeder::class,
PurchaseSeeder::class,
OrderSeeder::class,
]);
}
}

View 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
]);
});
}
}

View File

@ -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,

View 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' },
],
};

View 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' },
],
};

View File

@ -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,
};
}

View 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',
}
],
};

View 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" },
},
];

View 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;
}

View File

@ -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');
});
});