Add order management features including Order and OrderItem models, OrderController for CRUD operations, and request validation. Implement OrderChannel and OrderStatus enums for order categorization and status management. Enhance sidebar navigation for orders and integrate UI components for creating, editing, and listing orders, along with permissions in the Permission and Role enums.

This commit is contained in:
Yoga Pangestu 2026-06-12 10:34:50 +07:00
parent d4016a86d4
commit 7a0ac4f4d1
31 changed files with 2318 additions and 16 deletions

View File

@ -0,0 +1,32 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
enum OrderChannel: string
{
use ProvidesEnumOptions;
case STORE = 'store';
case SHOPEE = 'shopee';
case TIKTOK = 'tiktok';
public function label(): string
{
return match ($this) {
self::STORE => 'Toko',
self::SHOPEE => 'Shopee',
self::TIKTOK => 'TikTok',
};
}
public function defaultPriceType(): ?PriceType
{
return match ($this) {
self::STORE => null,
self::SHOPEE => PriceType::SHOPEE,
self::TIKTOK => PriceType::TIKTOK,
};
}
}

92
app/Enums/OrderStatus.php Normal file
View File

@ -0,0 +1,92 @@
<?php
namespace App\Enums;
use App\Traits\ProvidesEnumOptions;
use InvalidArgumentException;
enum OrderStatus: string
{
use ProvidesEnumOptions;
case PENDING = 'pending';
case PROCESSING = 'processing';
case COMPLETED = 'completed';
case CANCELLED = 'cancelled';
public function label(): string
{
return match ($this) {
self::PENDING => 'Menunggu',
self::PROCESSING => 'Diproses',
self::COMPLETED => 'Selesai',
self::CANCELLED => 'Dibatalkan',
};
}
public function isEditable(): bool
{
return in_array($this, [self::PENDING, self::PROCESSING], true);
}
public function canTransitionTo(self $status): bool
{
return match ($this) {
self::PENDING => in_array($status, [self::PROCESSING, self::CANCELLED], true),
self::PROCESSING => in_array($status, [self::COMPLETED, self::CANCELLED], true),
self::COMPLETED, self::CANCELLED => false,
};
}
public function transitionPermission(): Permission
{
return match ($this) {
self::PROCESSING => Permission::ORDERS_SEND,
self::COMPLETED => Permission::ORDERS_COMPLETE,
self::CANCELLED => Permission::ORDERS_CANCEL,
default => throw new InvalidArgumentException('Status tidak mendukung transisi.'),
};
}
/**
* @return list<array{status: string, label: string, destructive: bool, permission: string, icon_only: bool}>
*/
public function availableActions(): array
{
return match ($this) {
self::PENDING => [
[
'status' => self::PROCESSING->value,
'label' => 'Kirim',
'destructive' => false,
'permission' => Permission::ORDERS_SEND->value,
'icon_only' => true,
],
[
'status' => self::CANCELLED->value,
'label' => 'Batalkan',
'destructive' => true,
'permission' => Permission::ORDERS_CANCEL->value,
'icon_only' => true,
],
],
self::PROCESSING => [
[
'status' => self::COMPLETED->value,
'label' => 'Selesai',
'destructive' => false,
'permission' => Permission::ORDERS_COMPLETE->value,
'icon_only' => false,
],
[
'status' => self::CANCELLED->value,
'label' => 'Batalkan',
'destructive' => true,
'permission' => Permission::ORDERS_CANCEL->value,
'icon_only' => true,
],
],
default => [],
};
}
}

View File

@ -60,6 +60,14 @@ enum Permission: string
case PURCHASES_UPDATE = 'purchases.update';
case PURCHASES_DELETE = 'purchases.delete';
case ORDERS_VIEW = 'orders.view';
case ORDERS_CREATE = 'orders.create';
case ORDERS_UPDATE = 'orders.update';
case ORDERS_DELETE = 'orders.delete';
case ORDERS_SEND = 'orders.send';
case ORDERS_COMPLETE = 'orders.complete';
case ORDERS_CANCEL = 'orders.cancel';
case CASH_VIEW = 'cash.view';
case CASH_DEPOSIT = 'cash.deposit';
case CASH_UPDATE = 'cash.update';
@ -140,6 +148,14 @@ public function label(): string
self::PURCHASES_UPDATE => 'Ubah Belanja',
self::PURCHASES_DELETE => 'Hapus Belanja',
self::ORDERS_VIEW => 'Lihat Pesanan',
self::ORDERS_CREATE => 'Tambah Pesanan',
self::ORDERS_UPDATE => 'Ubah Pesanan',
self::ORDERS_DELETE => 'Hapus Pesanan',
self::ORDERS_SEND => 'Kirim Pesanan',
self::ORDERS_COMPLETE => 'Selesaikan Pesanan',
self::ORDERS_CANCEL => 'Batalkan Pesanan',
self::CASH_VIEW => 'Lihat Kas',
self::CASH_DEPOSIT => 'Setor Kas',
self::CASH_UPDATE => 'Ubah Setor Kas',
@ -189,6 +205,9 @@ public function group(): string
self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku',
self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE,
self::PURCHASES_DELETE => 'Belanja',
self::ORDERS_VIEW, self::ORDERS_CREATE, self::ORDERS_UPDATE,
self::ORDERS_DELETE, self::ORDERS_SEND, self::ORDERS_COMPLETE,
self::ORDERS_CANCEL => 'Pesanan',
self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas',
self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE,
self::EXPENSES_DELETE => 'Pengeluaran',

View File

@ -78,6 +78,13 @@ public function permissions(): array
Permission::PURCHASES_CREATE,
Permission::PURCHASES_UPDATE,
Permission::PURCHASES_DELETE,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
@ -128,6 +135,13 @@ public function permissions(): array
Permission::PRODUCTS_UPDATE,
Permission::PRODUCTS_DELETE,
Permission::PRODUCTS_TOGGLE_STATUS,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_DELETE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
Permission::CASH_VIEW,
Permission::CASH_DEPOSIT,
Permission::CASH_UPDATE,
@ -197,6 +211,12 @@ public function permissions(): array
Permission::CUSTOMERS_UPDATE,
Permission::CUSTOMERS_DELETE,
Permission::PRODUCTS_VIEW,
Permission::ORDERS_VIEW,
Permission::ORDERS_CREATE,
Permission::ORDERS_UPDATE,
Permission::ORDERS_SEND,
Permission::ORDERS_COMPLETE,
Permission::ORDERS_CANCEL,
],
self::NON_OPERATOR => [
Permission::DASHBOARD_VIEW,

View File

@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\OrderRequest;
use App\Http\Requests\Admin\Manage\OrderStatusTransitionRequest;
use App\Models\Order;
use App\Services\Manage\OrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class OrderController extends Controller
{
use ParsesDataTableQuery;
public function __construct(
private readonly OrderService $orderService,
) {}
public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
return Inertia::render('admin/manage/orders/Index', [
'orders' => $this->orderService->paginateForIndex($tableQuery, $request->user()),
'filters' => $this->dataTableFilters($tableQuery),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/orders/Create', [
'customers' => $this->orderService->customerOptions(),
'catalog' => $this->orderService->catalogItems(),
'channels' => OrderChannel::selectOptions(),
'storePriceTypes' => $this->orderService->storePriceTypeOptions(),
]);
}
public function store(OrderRequest $request): RedirectResponse
{
$this->orderService->create($request->validated(), $request->user());
Inertia::flash('success', 'Pesanan berhasil ditambahkan.');
return redirect()->route('admin.manage.orders.index');
}
public function edit(Order $order): Response|RedirectResponse
{
if (! $order->status->isEditable()) {
Inertia::flash('error', 'Pesanan tidak dapat diubah.');
return redirect()->route('admin.manage.orders.index');
}
return Inertia::render('admin/manage/orders/Edit', [
'order' => $this->orderService->findForEdit($order),
'customers' => $this->orderService->customerOptions(),
'catalog' => $this->orderService->catalogItems($order),
'channels' => OrderChannel::selectOptions(),
'storePriceTypes' => $this->orderService->storePriceTypeOptions(),
]);
}
public function update(OrderRequest $request, Order $order): RedirectResponse
{
$this->orderService->update($order, $request->validated());
Inertia::flash('success', 'Pesanan berhasil diperbarui.');
return redirect()->route('admin.manage.orders.index');
}
public function destroy(Order $order): RedirectResponse
{
$this->orderService->delete($order);
Inertia::flash('success', 'Pesanan berhasil dihapus.');
return redirect()->route('admin.manage.orders.index');
}
public function transitionStatus(OrderStatusTransitionRequest $request, Order $order): RedirectResponse
{
$status = OrderStatus::from($request->validated('status'));
$this->orderService->transitionStatus($order, $status);
$message = match ($status) {
OrderStatus::PROCESSING => 'Pesanan berhasil dikirim.',
OrderStatus::COMPLETED => 'Pesanan berhasil diselesaikan.',
OrderStatus::CANCELLED => 'Pesanan berhasil dibatalkan.',
default => 'Status pesanan berhasil diperbarui.',
};
Inertia::flash('success', $message);
return redirect()->route('admin.manage.orders.index');
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\Permission;
use App\Enums\PriceType;
use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class OrderRequest extends FormRequest
{
public function authorize(): bool
{
$permission = $this->isMethod('POST')
? Permission::ORDERS_CREATE
: Permission::ORDERS_UPDATE;
return $this->user()?->can($permission->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$rules = [
'customer_id' => ['nullable', 'integer', Rule::exists('customers', 'id')->whereNull('deleted_at')],
'channel' => ['required', 'string', Rule::in(array_column(OrderChannel::cases(), 'value'))],
'price_type' => ['required', 'string', Rule::in(array_column(PriceType::cases(), 'value'))],
'discount' => ['nullable', 'integer', 'min:0'],
'marketplace_fee' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => [
'required',
'integer',
Rule::exists('product_variants', 'id')->whereNull('deleted_at'),
],
'items.*.quantity' => ['required', 'integer', 'min:1'],
];
return $rules;
}
public function withValidator(Validator $validator): void
{
if (! $this->isMethod('PUT') && ! $this->isMethod('PATCH')) {
return;
}
$validator->after(function (Validator $validator): void {
/** @var Order $order */
$order = $this->route('order');
if (! $order->status->isEditable()) {
$validator->errors()->add('status', 'Pesanan tidak dapat diubah.');
}
});
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\OrderStatus;
use App\Models\Order;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class OrderStatusTransitionRequest extends FormRequest
{
public function authorize(): bool
{
$status = OrderStatus::tryFrom((string) $this->input('status'));
if ($status === null) {
return false;
}
return $this->user()?->can($status->transitionPermission()->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'status' => ['required', 'string', Rule::in(array_column(OrderStatus::cases(), 'value'))],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
/** @var Order $order */
$order = $this->route('order');
$status = OrderStatus::tryFrom((string) $this->input('status'));
if ($status === null) {
return;
}
if (! $order->status->canTransitionTo($status)) {
$validator->errors()->add('status', 'Status pesanan tidak dapat diubah.');
}
});
}
}

View File

@ -4,10 +4,16 @@
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
class Customer extends Model
{
use SoftDeletes;
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}

119
app/Models/Order.php Normal file
View File

@ -0,0 +1,119 @@
<?php
namespace App\Models;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
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\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends([
'subtotal_formatted',
'discount_formatted',
'marketplace_fee_formatted',
'net_amount_formatted',
'created_at_formatted',
'channel_label',
'price_type_label',
'status_label',
])]
class Order extends Model
{
use SoftDeletes;
protected function casts(): array
{
return [
'channel' => OrderChannel::class,
'price_type' => PriceType::class,
'status' => OrderStatus::class,
'subtotal' => 'integer',
'discount' => 'integer',
'marketplace_fee' => 'integer',
'net_amount' => 'integer',
];
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function discountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'),
);
}
public function marketplaceFeeFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->marketplace_fee, 0, ',', '.'),
);
}
public function netAmountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->net_amount, 0, ',', '.'),
);
}
public function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'),
);
}
public function channelLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->channel->label(),
);
}
public function priceTypeLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->price_type->label(),
);
}
public function statusLabel(): Attribute
{
return Attribute::make(
get: fn () => $this->status->label(),
);
}
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
public function items(): HasMany
{
return $this->hasMany(OrderItem::class);
}
public function cashTransaction(): BelongsTo
{
return $this->belongsTo(CashTransaction::class);
}
public function createdBy(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by_id');
}
}

66
app/Models/OrderItem.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Guarded(['id'])]
#[Appends([
'quantity_formatted',
'quantity_input',
'unit_price_formatted',
'subtotal_formatted',
])]
class OrderItem extends Model
{
protected function casts(): array
{
return [
'quantity' => 'integer',
'unit_price' => 'integer',
'subtotal' => 'integer',
];
}
public function quantityFormatted(): Attribute
{
return Attribute::make(
get: fn () => number_format($this->quantity, 0, ',', '.').' pcs',
);
}
public function quantityInput(): Attribute
{
return Attribute::make(
get: fn () => (string) $this->quantity,
);
}
public function unitPriceFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'),
);
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
);
}
public function order(): BelongsTo
{
return $this->belongsTo(Order::class);
}
public function productVariant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class);
}
}

View File

@ -42,4 +42,9 @@ public function prices(): HasMany
{
return $this->hasMany(ProductPrice::class, 'variant_id');
}
public function orderItems(): HasMany
{
return $this->hasMany(OrderItem::class);
}
}

View File

@ -80,4 +80,9 @@ public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'created_by_id');
}
public function orders(): HasMany
{
return $this->hasMany(Order::class, 'created_by_id');
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Observers;
use App\Models\Order;
class OrderObserver
{
public function creating(Order $order): void
{
if ($order->order_number !== null && $order->order_number !== '') {
return;
}
$date = now()->format('Ymd');
$prefix = "PSN-{$date}-";
$lastOrder = Order::withTrashed()
->where('order_number', 'like', "{$prefix}%")
->orderByDesc('order_number')
->value('order_number');
$sequence = $lastOrder !== null
? ((int) substr($lastOrder, -5)) + 1
: 1;
$order->order_number = $prefix.str_pad((string) $sequence, 5, '0', STR_PAD_LEFT);
}
}

View File

@ -3,6 +3,8 @@
namespace App\Providers;
use App\Enums\Role;
use App\Models\Order;
use App\Observers\OrderObserver;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
@ -27,6 +29,12 @@ public function boot(): void
{
$this->configureDefaults();
$this->configureAuthorization();
$this->configureObservers();
}
protected function configureObservers(): void
{
Order::observe(OrderObserver::class);
}
protected function configureAuthorization(): void

View File

@ -0,0 +1,369 @@
<?php
namespace App\Services\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PriceType;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use App\Support\Media\MediaPresenter;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class OrderService
{
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, User $user): LengthAwarePaginator
{
$query = Order::query()
->with([
'customer:id,name',
'createdBy.profile',
'items.productVariant.product:id,name',
'items.productVariant:id,product_id,name',
])
->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void {
$search = $tableQuery['search'];
$query->where(function (Builder $query) use ($search): void {
$query->where('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%")
->orWhereHas('customer', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"))
->orWhereHas('items.productVariant', function (Builder $query) use ($search): void {
$query->where('name', 'like', "%{$search}%")
->orWhereHas('product', fn (Builder $query) => $query->where('name', 'like', "%{$search}%"));
});
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
return $query
->paginate(10)
->withQueryString()
->through(function (Order $order) use ($user) {
$actions = collect($order->status->availableActions())
->filter(fn (array $action) => $user->can($action['permission']))
->values()
->all();
$order->setAttribute('available_actions', $actions);
$order->setAttribute('is_editable', $order->status->isEditable());
return $order;
});
}
/**
* @return list<array{value: int, label: string}>
*/
public function customerOptions(): array
{
return Customer::query()
->orderBy('name')
->get(['id', 'name'])
->map(fn (Customer $customer) => [
'value' => $customer->id,
'label' => $customer->name,
])
->all();
}
/**
* @return list<array{value: string, label: string}>
*/
public function storePriceTypeOptions(): array
{
return collect(PriceType::cases())
->reject(fn (PriceType $type) => in_array($type, [PriceType::SHOPEE, PriceType::TIKTOK], true))
->map(fn (PriceType $type) => [
'value' => $type->value,
'label' => $type->label(),
])
->values()
->all();
}
/**
* @return Collection<int, Product>
*/
public function catalogItems(?Order $order = null): Collection
{
$orderVariantIds = $order
? $order->items()->pluck('product_variant_id')->all()
: [];
return Product::query()
->with([
'variants' => fn ($query) => $query
->with(['prices' => fn ($query) => $query->orderBy('type'), 'media'])
->orderBy('created_at'),
])
->where(function (Builder $query) use ($orderVariantIds): void {
$query->where('is_active', true);
if ($orderVariantIds !== []) {
$query->orWhereHas(
'variants',
fn (Builder $query) => $query->whereIn('id', $orderVariantIds),
);
}
})
->orderBy('name')
->get()
->each(function (Product $product): void {
$product->variants->each(function (ProductVariant $variant): void {
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
});
});
}
public function findForEdit(Order $order): Order
{
$order->load([
'items.productVariant.product:id,name',
'items.productVariant.prices',
'items.productVariant.media',
]);
$order->items->each(function (OrderItem $item): void {
$variant = $item->productVariant;
if ($variant) {
$item->setAttribute('product_name', $variant->product?->name);
$item->setAttribute('variant_name', $variant->name);
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
}
});
return $order;
}
/**
* @param array<string, mixed> $validated
*/
public function create(array $validated, User $user): Order
{
return DB::transaction(function () use ($validated, $user): Order {
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$lineItems = $this->buildLineItems($validated['items'], $priceType);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0);
$netAmount = max($subtotal - $discount - $marketplaceFee, 0);
$order = Order::create([
'customer_id' => $validated['customer_id'] ?? null,
'channel' => $validated['channel'],
'price_type' => $priceType,
'status' => OrderStatus::PENDING,
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'marketplace_fee' => $marketplaceFee,
'net_amount' => $netAmount,
'notes' => $validated['notes'] ?? null,
]);
foreach ($lineItems as $itemData) {
$orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem);
}
return $order;
});
}
/**
* @param array<string, mixed> $validated
*/
public function update(Order $order, array $validated): void
{
if (! $order->status->isEditable()) {
throw ValidationException::withMessages([
'status' => 'Pesanan tidak dapat diubah.',
]);
}
DB::transaction(function () use ($order, $validated): void {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
$order->items()->delete();
$priceType = $this->resolvePriceType($validated['channel'], $validated['price_type']);
$lineItems = $this->buildLineItems($validated['items'], $priceType);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$marketplaceFee = (int) ($validated['marketplace_fee'] ?? 0);
$netAmount = max($subtotal - $discount - $marketplaceFee, 0);
$order->customer_id = $validated['customer_id'] ?? null;
$order->channel = $validated['channel'];
$order->price_type = $priceType;
$order->subtotal = $subtotal;
$order->discount = $discount;
$order->marketplace_fee = $marketplaceFee;
$order->net_amount = $netAmount;
$order->notes = $validated['notes'] ?? null;
$order->save();
foreach ($lineItems as $itemData) {
$orderItem = $order->items()->create($itemData);
$this->decrementStock($orderItem);
}
});
}
public function delete(Order $order): void
{
DB::transaction(function () use ($order): void {
$order->load('items');
if ($order->status->isEditable()) {
foreach ($order->items as $item) {
$this->incrementStock($item);
}
}
$order->items()->delete();
$order->delete();
});
}
public function transitionStatus(Order $order, OrderStatus $status): void
{
if (! $order->status->canTransitionTo($status)) {
throw ValidationException::withMessages([
'status' => 'Status pesanan tidak dapat diubah.',
]);
}
DB::transaction(function () use ($order, $status): void {
if ($status === OrderStatus::CANCELLED) {
$order->load('items');
foreach ($order->items as $item) {
$this->incrementStock($item);
}
}
$order->status = $status;
$order->save();
});
}
/**
* @param list<array{product_variant_id: int, quantity: int|string}> $items
* @return list<array{product_variant_id: int, quantity: int, unit_price: int, subtotal: int}>
*/
private function buildLineItems(array $items, PriceType $priceType): array
{
return collect($items)
->map(function (array $itemData, int $index) use ($priceType) {
$variant = ProductVariant::query()->find($itemData['product_variant_id']);
if ($variant === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Varian produk tidak ditemukan.',
]);
}
$price = ProductPrice::query()
->where('variant_id', $variant->id)
->where('type', $priceType)
->first();
if ($price === null) {
throw ValidationException::withMessages([
"items.{$index}.product_variant_id" => 'Harga untuk tipe harga ini belum diatur.',
]);
}
$quantity = (int) $itemData['quantity'];
if ($quantity < 1) {
throw ValidationException::withMessages([
"items.{$index}.quantity" => 'Jumlah minimal 1 pcs.',
]);
}
$unitPrice = (int) $price->price;
$subtotal = $unitPrice * $quantity;
return [
'product_variant_id' => $variant->id,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $subtotal,
];
})
->all();
}
private function resolvePriceType(string $channel, string $priceType): PriceType
{
$channelEnum = OrderChannel::from($channel);
$priceTypeEnum = PriceType::from($priceType);
$defaultPriceType = $channelEnum->defaultPriceType();
if ($defaultPriceType !== null && $priceTypeEnum !== $defaultPriceType) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga tidak sesuai dengan channel pesanan.',
]);
}
if ($channelEnum === OrderChannel::STORE && in_array($priceTypeEnum, [PriceType::SHOPEE, PriceType::TIKTOK], true)) {
throw ValidationException::withMessages([
'price_type' => 'Tipe harga marketplace tidak dapat digunakan untuk channel toko.',
]);
}
return $priceTypeEnum;
}
private function decrementStock(OrderItem $item): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->decrement('stock', $item->quantity);
}
private function incrementStock(OrderItem $item): void
{
ProductVariant::query()
->whereKey($item->product_variant_id)
->increment('stock', $item->quantity);
}
private function applySorting(Builder $query, string $sort, string $direction): void
{
if (in_array($sort, ['created_at', 'net_amount', 'discount', 'subtotal', 'order_number'], true)) {
$query->orderBy($sort, $direction);
return;
}
$query->latest();
}
}

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->string('order_number', 30)->unique();
$table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
$table->string('channel', 20);
$table->string('price_type', 20);
$table->string('status', 20)->default('pending');
$table->unsignedBigInteger('subtotal');
$table->unsignedBigInteger('discount')->default(0);
$table->unsignedBigInteger('marketplace_fee')->default(0);
$table->unsignedBigInteger('net_amount');
$table->text('notes')->nullable();
$table->foreignId('cash_transaction_id')
->nullable()
->unique()
->constrained()
->restrictOnDelete();
$table->foreignId('created_by_id')->constrained('users')->restrictOnDelete();
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->softDeletes();
$table->index('status');
$table->index('customer_id');
$table->index('channel');
$table->index('price_type');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('orders');
}
};

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('order_items', function (Blueprint $table) {
$table->id();
$table->foreignId('order_id')->constrained()->cascadeOnDelete();
$table->foreignId('product_variant_id')->constrained()->restrictOnDelete();
$table->unsignedInteger('quantity');
$table->unsignedBigInteger('unit_price');
$table->unsignedBigInteger('subtotal');
$table->timestamp('created_at')->useCurrent();
$table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate();
$table->unique(['order_id', 'product_variant_id']);
$table->index('order_id');
$table->index('product_variant_id');
});
}
public function down(): void
{
Schema::dropIfExists('order_items');
}
};

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Settings2, ShoppingBag, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { Banknote, CalendarDays, Clock, FolderTree, Layers, LayoutDashboard, Package, Receipt, Settings2, ShoppingBag, ShoppingCart, User, UserCheck, Users, Wallet, WalletCards } from '@lucide/vue';
import { computed } from 'vue';
import {
Sidebar,
@ -30,7 +30,8 @@ const isRawMaterialsActive = computed(() => page.url.startsWith('/admin/master/r
const isSuppliersActive = computed(() => page.url.startsWith('/admin/master/suppliers'));
const isCustomersActive = computed(() => page.url.startsWith('/admin/master/customers'));
const isPurchasesActive = computed(() => page.url.startsWith('/admin/manage/purchases'));
const showManageMenu = computed(() => can('purchases.view'));
const isOrdersActive = computed(() => page.url.startsWith('/admin/manage/orders'));
const showManageMenu = computed(() => can('purchases.view') || can('orders.view'));
const isCashActive = computed(() => page.url.startsWith('/admin/finance/cash'));
const isExpensesActive = computed(() => page.url.startsWith('/admin/finance/expenses'));
const isEmployeeAdvancesActive = computed(() => page.url.startsWith('/admin/finance/employee-advances'));
@ -136,6 +137,14 @@ const showSystemMenu = computed(() => can('settings.view'));
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem v-if="can('orders.view')">
<SidebarMenuButton as-child tooltip="Pesanan" :is-active="isOrdersActive">
<Link href="/admin/manage/orders">
<ShoppingCart />
<span>Pesanan</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View File

@ -1,7 +1,6 @@
<script setup lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
@ -9,6 +8,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
const open = defineModel<boolean>('open', { default: false });
@ -33,11 +33,6 @@ withDefaults(
const emit = defineEmits<{
confirm: [];
}>();
function handleConfirm(event: Event) {
event.preventDefault();
emit('confirm');
}
</script>
<template>
@ -53,13 +48,14 @@ function handleConfirm(event: Event) {
<AlertDialogCancel :disabled="loading">
{{ cancelLabel }}
</AlertDialogCancel>
<AlertDialogAction
<Button
type="button"
:disabled="loading"
:class="cn(destructive && 'bg-destructive text-white hover:bg-destructive/90')"
@click="handleConfirm"
@click="emit('confirm')"
>
{{ loading ? 'Memproses...' : confirmLabel }}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>

View File

@ -0,0 +1,198 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/admin/manage/orders/data-table-actions.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { OrderListItem } from '@/types/order';
const props = defineProps<{
orders: OrderListItem[];
firstItem?: number;
pagination?: DataTablePagination;
paginationLinks?: DataTablePaginationLink[];
}>();
const search = defineModel<string>('search', { default: '' });
const emit = defineEmits<{
'filters-reset': [];
}>();
const showingCount = computed(() => props.orders.length);
const paginationSummary = computed(() => {
if (!props.pagination) {
return null;
}
const { total } = props.pagination;
if (total === 0) {
return 'Menampilkan 0 pesanan';
}
return `Menampilkan ${showingCount.value} pesanan dari ${total}`;
});
function rowNumber(index: number): number {
return (props.firstItem ?? 1) + index;
}
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'completed') {
return 'default';
}
if (status === 'cancelled') {
return 'destructive';
}
if (status === 'processing') {
return 'secondary';
}
return 'outline';
}
</script>
<template>
<div class="space-y-4">
<DataTableToolbar v-model:search="search" @filters-reset="emit('filters-reset')" />
<div v-if="orders.length" class="space-y-4">
<div v-for="(order, index) in orders" :key="order.id" class="overflow-hidden rounded-md border">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ order.order_number }}
</h3>
<Badge :variant="statusVariant(order.status)">
{{ order.status_label }}
</Badge>
<Badge variant="secondary">
{{ order.channel_label }}
</Badge>
</div>
<div class="text-muted-foreground space-y-1 text-sm">
<p v-if="order.customer">
{{ order.customer.name }}
</p>
<p v-else>
Tanpa pelanggan
</p>
<p>{{ order.created_at_formatted }}</p>
<p>Oleh {{ order.created_by?.profile?.full_name ?? order.created_by?.username }}</p>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm">
<span>Tipe Harga <strong class="text-foreground">{{ order.price_type_label }}</strong></span>
<span>Subtotal <strong class="text-foreground">{{ order.subtotal_formatted }}</strong></span>
<span>Diskon <strong class="text-foreground">{{ order.discount_formatted }}</strong></span>
<span v-if="order.channel !== 'store'">
Biaya MP <strong class="text-foreground">{{ order.marketplace_fee_formatted }}</strong>
</span>
<span>Net <strong class="text-primary">{{ order.net_amount_formatted }}</strong></span>
</div>
<p v-if="order.notes" class="text-muted-foreground text-sm">
{{ order.notes }}
</p>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<DataTableActions :order="order" />
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead>Jumlah</TableHead>
<TableHead>Harga Satuan</TableHead>
<TableHead>Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!order.items.length" :key="`${order.id}-empty`">
<TableCell colspan="5" class="text-muted-foreground">
Belum ada item
</TableCell>
</TableRow>
<TableRow v-for="item in order.items" :key="item.id">
<TableCell class="font-medium">
{{ item.product_variant?.product?.name }}
</TableCell>
<TableCell>
{{ item.product_variant?.name }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.quantity_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ item.unit_price_formatted }}
</TableCell>
<TableCell class="tabular-nums font-medium">
{{ item.subtotal_formatted }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<div v-else class="rounded-md border px-6 py-10">
<Empty>
<EmptyHeader>
<EmptyTitle>Data tidak ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan data yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-if="pagination" class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-muted-foreground text-sm">
{{ paginationSummary }}
</p>
<div v-if="paginationLinks?.length && pagination.lastPage > 1"
class="flex flex-wrap items-center justify-center gap-1 sm:justify-end">
<Button v-for="link in paginationLinks" :key="`${link.label}-${link.url}`" variant="outline" size="sm"
:disabled="!link.url || link.active" as-child>
<Link v-if="link.url" :href="link.url" preserve-scroll>
<span v-html="link.label" />
</Link>
<span v-else v-html="link.label" />
</Button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,492 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import type { ProductPriceItem, ProductVariantItem } from '@/types/product';
import type { EnumOption, OrderCartItem, OrderCatalogItem, SelectOption } from '@/types/order';
const props = defineProps<{
customers: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
initialData?: {
customer_id: string;
channel: string;
price_type: string;
discount: string;
marketplace_fee: string;
notes: string;
items: OrderCartItem[];
};
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const search = ref('');
const cart = ref<OrderCartItem[]>([]);
const form = useForm({
customer_id: '',
channel: 'store',
price_type: 'ecer',
discount: '',
marketplace_fee: '',
notes: '',
});
const isStoreChannel = computed(() => form.channel === 'store');
const isMarketplaceChannel = computed(() => form.channel === 'shopee' || form.channel === 'tiktok');
function populateForm() {
if (!props.initialData) {
return;
}
form.customer_id = props.initialData.customer_id;
form.channel = props.initialData.channel;
form.price_type = props.initialData.price_type;
form.discount = props.initialData.discount;
form.marketplace_fee = props.initialData.marketplace_fee;
form.notes = props.initialData.notes;
cart.value = props.initialData.items.map((item) => ({ ...item }));
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
watch(
() => form.channel,
(channel) => {
if (channel === 'shopee') {
form.price_type = 'shopee';
} else if (channel === 'tiktok') {
form.price_type = 'tiktok';
} else if (!props.storePriceTypes.some((type) => type.value === form.price_type)) {
form.price_type = 'ecer';
}
},
);
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) {
return props.catalog;
}
return props.catalog.filter((product) =>
product.name.toLowerCase().includes(keyword)
|| product.variants.some((variant) => variant.name.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const marketplaceFeeAmount = computed(() => Number(parseRupiah(form.marketplace_fee)) || 0);
const netAmount = computed(() => Math.max(subtotal.value - discountAmount.value - marketplaceFeeAmount.value, 0));
function getVariantPrice(variant: ProductVariantItem): ProductPriceItem | undefined {
return variant.prices.find((price) => price.type === form.price_type);
}
function lineSubtotal(item: OrderCartItem): number {
const quantity = Number(item.quantity) || 0;
return quantity * item.unit_price;
}
function addToCart(product: OrderCatalogItem, variant: ProductVariantItem) {
const price = getVariantPrice(variant);
if (!price) {
toast.error('Harga untuk tipe harga ini belum diatur.');
return;
}
const existing = cart.value.find(
(item) => item.product_variant_id === variant.id,
);
if (existing) {
const currentQty = Number(existing.quantity) || 0;
existing.quantity = String(currentQty + 1);
return;
}
cart.value.push({
product_variant_id: variant.id,
product_name: product.name,
variant_name: variant.name,
quantity: '1',
unit_price: Number(price.price_input),
images: variant.images ?? [],
});
}
function removeFromCart(index: number) {
cart.value.splice(index, 1);
}
function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty < 1) {
removeFromCart(index);
return;
}
item.quantity = String(nextQty);
}
function buildFormData(): FormData {
const formData = new FormData();
if (props.method === 'put') {
formData.append('_method', 'PUT');
}
if (form.customer_id) {
formData.append('customer_id', form.customer_id);
}
formData.append('channel', form.channel);
formData.append('price_type', form.price_type);
formData.append('discount', parseRupiah(form.discount));
formData.append('marketplace_fee', parseRupiah(form.marketplace_fee));
formData.append('notes', form.notes);
cart.value.forEach((item, index) => {
formData.append(`items[${index}][product_variant_id]`, String(item.product_variant_id));
formData.append(`items[${index}][quantity]`, item.quantity);
});
return formData;
}
function submit() {
if (cart.value.length === 0) {
toast.error('Tambahkan minimal satu produk ke keranjang.');
return;
}
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
}
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Produk</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari produk..." class="pl-9" />
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada produk ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan produk yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-4">
<div v-for="product in filteredCatalog" :key="product.id" class="overflow-hidden rounded-md border">
<div class="border-b bg-muted/30 px-4 py-3">
<h3 class="font-medium leading-tight">
{{ product.name }}
</h3>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga</TableHead>
<TableHead class="w-24 text-right" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
<TableCell colspan="5" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="variant in product.variants" :key="variant.id"
class="hover:bg-muted/30"
:class="getVariantPrice(variant) ? 'cursor-pointer' : 'opacity-60'"
@click="getVariantPrice(variant) && addToCart(product, variant)">
<TableCell class="font-medium">
{{ variant.name }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="variant.images ?? []" />
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock }} pcs
</TableCell>
<TableCell class="tabular-nums">
<template v-if="getVariantPrice(variant)">
{{ getVariantPrice(variant)!.price_formatted }}
</template>
<span v-else class="text-muted-foreground text-sm">-</span>
</TableCell>
<TableCell class="text-right">
<Button type="button" variant="outline" size="sm"
:disabled="!getVariantPrice(variant)"
@click.stop="addToCart(product, variant)">
<Plus class="size-3.5" />
Tambah
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<ShoppingCart class="size-4" />
Ringkasan Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="channel" required>Channel</FieldLabel>
<Select v-model="form.channel">
<SelectTrigger id="channel" class="w-full">
<SelectValue placeholder="Pilih channel" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="channel in channels" :key="channel.value"
:value="channel.value">
{{ channel.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="form.errors.channel ? [form.errors.channel] : []" />
</Field>
<Field v-if="isStoreChannel">
<FieldLabel for="price_type" required>Tipe Harga</FieldLabel>
<Select v-model="form.price_type">
<SelectTrigger id="price_type" class="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="priceType in storePriceTypes" :key="priceType.value"
:value="priceType.value">
{{ priceType.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="form.errors.price_type ? [form.errors.price_type] : []" />
</Field>
<Field v-else>
<FieldLabel>Tipe Harga</FieldLabel>
<div class="flex h-9 items-center rounded-md border bg-muted/40 px-3 text-sm">
{{ form.channel === 'shopee' ? 'Shopee' : 'TikTok' }}
</div>
</Field>
<Field>
<FieldLabel for="customer">Pelanggan</FieldLabel>
<Select v-model="form.customer_id">
<SelectTrigger id="customer" class="w-full">
<SelectValue placeholder="Pilih pelanggan" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="customer in customers" :key="customer.value"
:value="String(customer.value)">
{{ customer.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="form.errors.customer_id ? [form.errors.customer_id] : []" />
</Field>
<div v-if="cart.length === 0"
class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian produk di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-3">
<div v-for="(item, index) in cart" :key="item.product_variant_id"
class="rounded-lg border p-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">
{{ item.product_name }}
</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeFromCart(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah (pcs)</FieldLabel>
<div class="flex items-center gap-1">
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" />
</Button>
<Input v-model="item.quantity" type="number" min="1" step="1"
class="h-8 text-center" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="form.errors.discount ? [form.errors.discount] : []" />
</Field>
<Field v-if="isMarketplaceChannel">
<FieldLabel for="marketplace_fee">Biaya Marketplace</FieldLabel>
<RupiahInput id="marketplace_fee" v-model="form.marketplace_fee" placeholder="0" />
<FieldError
:errors="form.errors.marketplace_fee ? [form.errors.marketplace_fee] : []" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Net</span>
<span class="text-primary">Rp {{ formatRupiah(netAmount) }}</span>
</div>
</div>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Pesanan walk-in"
rows="2" />
<FieldError :errors="form.errors.notes ? [form.errors.notes] : []" />
</Field>
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</template>

View File

@ -0,0 +1,187 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Check, Pencil, Send, Trash2, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { useCan } from '@/composables/useCan';
import type { OrderListItem, OrderStatusAction } from '@/types/order';
const props = defineProps<{
order: OrderListItem;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const statusConfirmOpen = ref(false);
const statusProcessing = ref(false);
const pendingAction = ref<OrderStatusAction | null>(null);
const availableActions = computed(() => props.order.available_actions ?? []);
const canEdit = computed(() => props.order.is_editable && can('orders.update'));
const canDelete = computed(() => can('orders.delete'));
function canPerformAction(action: OrderStatusAction): boolean {
return can(action.permission);
}
function statusConfirmDescription(action: OrderStatusAction): string {
if (action.status === 'processing') {
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
}
if (action.status === 'completed') {
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
}
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
}
function openStatusConfirm(action: OrderStatusAction) {
pendingAction.value = action;
statusConfirmOpen.value = true;
}
function transitionStatus() {
if (!pendingAction.value) {
return;
}
statusProcessing.value = true;
router.post(`/admin/manage/orders/${props.order.id}/status`, {
status: pendingAction.value.status,
}, {
preserveScroll: true,
onSuccess: () => {
statusConfirmOpen.value = false;
pendingAction.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
},
onFinish: () => {
statusProcessing.value = false;
},
});
}
function destroyOrder() {
deleteProcessing.value = true;
router.delete(`/admin/manage/orders/${props.order.id}`, {
preserveScroll: true,
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus pesanan.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
function actionIcon(status: string) {
if (status === 'processing') {
return Send;
}
if (status === 'completed') {
return Check;
}
return X;
}
</script>
<template>
<div class="flex flex-wrap items-center justify-end gap-1">
<template v-for="action in availableActions" :key="action.status">
<Tooltip v-if="action.icon_only && canPerformAction(action)">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="size-8"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)"
>
<component :is="actionIcon(action.status)" class="size-4" />
<span class="sr-only">{{ action.label }}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{{ action.label }}</TooltipContent>
</Tooltip>
<Button
v-else-if="canPerformAction(action)"
size="sm"
:variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)"
>
<component :is="actionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
</template>
<Tooltip v-if="canEdit">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/manage/orders/${order.id}/edit`">
<Pencil class="size-4" />
<span class="sr-only">Ubah</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Ubah</TooltipContent>
</Tooltip>
<Tooltip v-if="canDelete">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
</TooltipTrigger>
<TooltipContent>Hapus</TooltipContent>
</Tooltip>
</div>
<ConfirmDialog
v-model:open="statusConfirmOpen"
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
<ConfirmDialog
v-if="canDelete"
v-model:open="deleteConfirmOpen"
title="Hapus pesanan?"
:description="`Pesanan ${order.order_number} akan dihapus.${order.is_editable ? ' Stok produk akan dikembalikan.' : ''}`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyOrder"
/>
</template>

View File

@ -26,14 +26,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<AlertDialogPortal>
<AlertDialogOverlay
data-slot="alert-dialog-overlay"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80"
class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80"
/>
<AlertDialogContent
data-slot="alert-dialog-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 pointer-events-auto fixed top-[50%] left-[50%] z-[201] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)
"

View File

@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 pointer-events-auto fixed top-[50%] left-[50%] z-[201] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>

View File

@ -14,7 +14,7 @@ const delegatedProps = reactiveOmit(props, "class")
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 pointer-events-none fixed inset-0 z-[200] bg-black/80', props.class)"
>
<slot />
</DialogOverlay>

View File

@ -8,7 +8,7 @@ const props = defineProps<ToasterProps>()
</script>
<template>
<Sonner :class="cn('toaster group', props.class)" :style="{
<Sonner :class="cn('toaster group [&_[data-sonner-toaster]]:!z-[100]', props.class)" :style="{
'--normal-bg': 'var(--popover)',
'--normal-text': 'var(--popover-foreground)',
'--normal-border': 'var(--border)',

View File

@ -0,0 +1,46 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import OrderPosForm from '@/components/admin/manage/orders/OrderPosForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, OrderCatalogItem, SelectOption } from '@/types/order';
defineProps<{
customers: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
}>();
</script>
<template>
<Head title="Tambah Pesanan" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Tambah Pesanan
</h2>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/orders">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<OrderPosForm
:customers="customers"
:catalog="catalog"
:channels="channels"
:store-price-types="storePriceTypes"
submit-url="/admin/manage/orders"
method="post"
submit-label="Simpan"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,69 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import OrderPosForm from '@/components/admin/manage/orders/OrderPosForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, OrderCatalogItem, OrderEditItem, SelectOption } from '@/types/order';
const props = defineProps<{
order: OrderEditItem;
customers: SelectOption[];
catalog: OrderCatalogItem[];
channels: EnumOption[];
storePriceTypes: EnumOption[];
}>();
const initialData = computed(() => ({
customer_id: props.order.customer_id ? String(props.order.customer_id) : '',
channel: props.order.channel,
price_type: props.order.price_type,
discount: String(props.order.discount),
marketplace_fee: String(props.order.marketplace_fee),
notes: props.order.notes ?? '',
items: props.order.items.map((item) => ({
product_variant_id: item.product_variant_id,
product_name: item.product_name,
variant_name: item.variant_name,
quantity: item.quantity_input,
unit_price: item.unit_price,
images: item.product_variant?.images ?? [],
})),
}));
</script>
<template>
<Head title="Ubah Pesanan" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Ubah Pesanan
</h2>
<p class="text-muted-foreground text-sm">
{{ order.order_number }}
</p>
</div>
<Button variant="outline" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/orders">
<ArrowLeft class="size-4" />
Kembali
</Link>
</Button>
</div>
<OrderPosForm
:customers="customers"
:catalog="catalog"
:channels="channels"
:store-price-types="storePriceTypes"
:initial-data="initialData"
:submit-url="`/admin/manage/orders/${order.id}`"
method="put"
submit-label="Perbarui"
/>
</AdminLayout>
</template>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import OrderGroupedTable from '@/components/admin/manage/orders/OrderGroupedTable.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { PaginatedOrders } from '@/types/order';
const props = defineProps<{
orders: PaginatedOrders;
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
};
}>();
const { can } = useCan();
const search = ref(props.filters.search ?? '');
const { setSearch, resetFilters, syncFromServer } = useDataTableQuery({
url: '/admin/manage/orders',
initial: { ...props.filters },
});
useDataTableQuerySync(() => props.filters, syncFromServer);
const tablePagination = computed(() => ({
currentPage: props.orders.current_page,
perPage: props.orders.per_page,
lastPage: props.orders.last_page,
total: props.orders.total,
}));
const firstItem = computed(() => (
props.orders.data.length > 0
? (props.orders.current_page - 1) * props.orders.per_page + 1
: 0
));
watch(search, (value) => {
setSearch(value);
});
watch(
() => props.filters.search,
(value) => {
search.value = value ?? '';
},
);
</script>
<template>
<Head title="Pesanan" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Pesanan
</h2>
</div>
<Button v-if="can('orders.create')" as-child class="shrink-0 self-start sm:self-center">
<Link href="/admin/manage/orders/create">
<Plus class="size-4" />
Tambah
</Link>
</Button>
</div>
<Card class="min-w-0">
<CardContent class="min-w-0">
<OrderGroupedTable
v-model:search="search"
:orders="orders.data"
:first-item="firstItem"
:pagination="tablePagination"
:pagination-links="orders.links"
@filters-reset="resetFilters"
/>
</CardContent>
</Card>
</AdminLayout>
</template>

111
resources/js/types/order.ts Normal file
View File

@ -0,0 +1,111 @@
import type { MediaItem } from '@/types/media';
import type { ProductListItem } from '@/types/product';
export type SelectOption = {
value: number;
label: string;
};
export type EnumOption = {
value: string;
label: string;
};
export type OrderItemListItem = {
id: number;
quantity_formatted: string;
unit_price_formatted: string;
subtotal_formatted: string;
product_variant?: {
id: number;
name: string;
product?: {
id: number;
name: string;
};
};
};
export type OrderStatusAction = {
status: string;
label: string;
destructive: boolean;
permission: string;
icon_only: boolean;
};
export type OrderListItem = {
id: number;
order_number: string;
channel: string;
channel_label: string;
price_type: string;
price_type_label: string;
status: string;
status_label: string;
is_editable: boolean;
available_actions: OrderStatusAction[];
subtotal_formatted: string;
discount_formatted: string;
marketplace_fee_formatted: string;
net_amount_formatted: string;
notes: string | null;
created_at_formatted: string;
customer?: {
id: number;
name: string;
} | null;
created_by?: {
username: string;
profile?: {
full_name: string;
} | null;
};
items: OrderItemListItem[];
};
export type OrderCartItem = {
product_variant_id: number;
product_name: string;
variant_name: string;
quantity: string;
unit_price: number;
images?: MediaItem[];
};
export type OrderEditItem = {
id: number;
order_number: string;
customer_id: number | null;
channel: string;
price_type: string;
status: string;
discount: number;
marketplace_fee: number;
notes: string | null;
items: Array<{
product_variant_id: number;
product_name: string;
variant_name: string;
quantity_input: string;
unit_price: number;
product_variant?: {
images?: MediaItem[];
};
}>;
};
export type PaginatedOrders = {
data: OrderListItem[];
current_page: number;
last_page: number;
per_page: number;
total: number;
links: Array<{
url: string | null;
label: string;
active: boolean;
}>;
};
export type OrderCatalogItem = ProductListItem;

View File

@ -13,6 +13,7 @@
use App\Http\Controllers\Admin\Hr\AttendanceController;
use App\Http\Controllers\Admin\Hr\EmployeeController;
use App\Http\Controllers\Admin\Hr\LeaveRequestController;
use App\Http\Controllers\Admin\Manage\OrderController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController;
@ -181,6 +182,35 @@
->middleware('permission:'.Permission::PURCHASES_DELETE->value)
->name('destroy');
});
Route::prefix('orders')->name('orders.')
->middleware('permission:'.Permission::ORDERS_VIEW->value)
->group(function () {
Route::get('/', [OrderController::class, 'index'])->name('index');
Route::get('create', [OrderController::class, 'create'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('create');
Route::post('/', [OrderController::class, 'store'])
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('store');
Route::get('{order}/edit', [OrderController::class, 'edit'])
->middleware('permission:'.Permission::ORDERS_UPDATE->value)
->name('edit');
Route::put('{order}', [OrderController::class, 'update'])
->middleware('permission:'.Permission::ORDERS_UPDATE->value)
->name('update');
Route::post('{order}/status', [OrderController::class, 'transitionStatus'])
->name('transition-status');
Route::delete('{order}', [OrderController::class, 'destroy'])
->middleware('permission:'.Permission::ORDERS_DELETE->value)
->name('destroy');
});
});
Route::prefix('system')->name('system.')->group(function () {