feat: add transaction management page with filtering and CRUD functionality

- Implemented TransactionIndex component for displaying transactions with pagination and filtering options.
- Created TransactionCardRow component for rendering individual transaction details.
- Added TransactionItemSubRow component for displaying detailed order items within a transaction.
- Integrated delete confirmation dialog for transaction deletion.
- Updated routes to include transaction management with appropriate permissions.
This commit is contained in:
Yoga Pangestu 2026-08-04 10:37:28 +07:00
parent e7aa582572
commit 72848d51d8
23 changed files with 3569 additions and 37 deletions

View File

@ -20,12 +20,19 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=laravel
# DB_USERNAME=root DB_USERNAME=root
# DB_PASSWORD= DB_PASSWORD=
DB_OLD_CONNECTION=mysql
DB_OLD_HOST=127.0.0.1
DB_OLD_PORT=3306
DB_OLD_DATABASE=dst_old
DB_OLD_USERNAME=root
DB_OLD_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120

View File

@ -8,9 +8,16 @@ enum OrderChannel: string
{ {
use HasValues; use HasValues;
case OFFLINE = 'offline'; case STORE = 'store';
case ONLINE = 'online';
case WHATSAPP = 'whatsapp';
case SHOPEE = 'shopee'; case SHOPEE = 'shopee';
case TIKTOK = 'tiktok'; case TIKTOK = 'tiktok';
public function label(): string
{
return match ($this) {
self::STORE => 'Toko',
self::SHOPEE => 'Shopee',
self::TIKTOK => 'TikTok',
};
}
} }

View File

@ -13,4 +13,15 @@ enum OrderStatus: string
case COMPLETED = 'completed'; case COMPLETED = 'completed';
case CANCELLED = 'cancelled'; case CANCELLED = 'cancelled';
case REFUNDED = 'refunded'; case REFUNDED = 'refunded';
public function label(): string
{
return match ($this) {
self::PENDING => 'Pending',
self::PROCESSING => 'Diproses',
self::COMPLETED => 'Selesai',
self::CANCELLED => 'Dibatalkan',
self::REFUNDED => 'Dikembalikan',
};
}
} }

View File

@ -10,6 +10,16 @@ enum PaymentType: string
case CASH = 'cash'; case CASH = 'cash';
case TRANSFER = 'transfer'; case TRANSFER = 'transfer';
case DEBIT = 'debit'; case MARKETPLACE = 'marketplace';
case CREDIT = 'credit'; case QRIS = 'qris';
public function label(): string
{
return match ($this) {
self::CASH => 'Tunai',
self::TRANSFER => 'Transfer',
self::MARKETPLACE => 'Marketplace',
self::QRIS => 'QRIS',
};
}
} }

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\TransactionRequest;
use App\Http\Requests\PaginatedRequest;
use App\Models\Order;
use App\Services\Admin\Manage\TransactionService;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class TransactionController extends Controller
{
public function __construct(
private TransactionService $service,
) {}
public function index(PaginatedRequest $request): Response
{
$filters = $request->only(['status', 'channel', 'payment_type', 'customer_id', 'marketing_id', 'created_by_id']);
return Inertia::render('admin/manage/transaction/index', [
'transactions' => $this->service->paginated(
...$request->validatedWithDefaults(),
filters: $filters,
),
'filters' => $filters,
'filterOptions' => $this->service->getFilterOptions(),
]);
}
public function create(): Response
{
return Inertia::render('admin/manage/transaction/create', [
'data' => $this->service->getForCreate(),
]);
}
public function store(TransactionRequest $request): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->create($request->validated()),
'Transaksi berhasil ditambahkan.',
'admin.manage.transactions.index',
'admin.manage.transactions.create'
);
}
public function edit(Order $transaction): Response
{
return Inertia::render('admin/manage/transaction/edit', [
'transaction' => $this->service->getForEdit($transaction),
'data' => $this->service->getForCreate(),
]);
}
public function update(TransactionRequest $request, Order $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->update($transaction, $request->validated()),
'Transaksi berhasil diperbarui.',
'admin.manage.transactions.index',
'admin.manage.transactions.edit',
['transaction' => $transaction]
);
}
public function destroy(Order $transaction): RedirectResponse
{
return $this->handleAction(
fn () => $this->service->delete($transaction),
'Transaksi berhasil dihapus.',
'admin.manage.transactions.index'
);
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class TransactionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function prepareForValidation(): void
{
if ($this->has('discount')) {
$this->merge([
'discount' => str_replace('.', '', $this->discount),
]);
}
if ($this->has('nego_price')) {
$this->merge([
'nego_price' => str_replace('.', '', $this->nego_price),
]);
}
}
public function rules(): array
{
$sellingPriceTypes = array_diff(PriceType::values(), [PriceType::CAPITAL->value]);
return [
'stock_type' => ['sometimes', 'required', Rule::in(ProductStockQuality::values())],
'channel' => ['sometimes', 'required', Rule::in(OrderChannel::values())],
'price_type' => ['sometimes', 'required', Rule::in($sellingPriceTypes)],
'payment_type' => ['sometimes', 'required', Rule::in(PaymentType::values())],
'customer_id' => ['nullable', 'integer', 'exists:customers,id'],
'marketing_id' => ['nullable', 'integer', 'exists:users,id'],
'discount' => ['nullable', 'integer', 'min:0'],
'nego_price' => ['nullable', 'integer'],
'is_completed' => ['sometimes', 'boolean'],
'is_affiliate' => ['sometimes', 'boolean'],
'tiktok_order_id' => ['nullable', 'string', 'max:100'],
'shopee_order_id' => ['nullable', 'string', 'max:100'],
'items' => ['required', 'array', 'min:1'],
'items.*.product_variant_id' => ['required', 'integer', 'exists:product_variants,id'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'notes' => ['nullable', 'string', 'max:100'],
'photo_key' => [
Rule::when(
in_array($this->input('payment_type'), [PaymentType::TRANSFER->value, PaymentType::QRIS->value]),
['required', 'string', 'max:500'],
['nullable', 'string', 'max:500'],
),
],
];
}
public function attributes(): array
{
return [
'stock_type' => 'Tipe Stok',
'channel' => 'Channel',
'price_type' => 'Tipe Harga',
'payment_type' => 'Tipe Pembayaran',
'customer_id' => 'Pelanggan',
'marketing_id' => 'Marketing',
'discount' => 'Diskon',
'nego_price' => 'Harga Nego',
'is_completed' => 'Pesanan Selesai',
'is_affiliate' => 'Affiliasi',
'tiktok_order_id' => 'ID Pesanan TikTok',
'shopee_order_id' => 'ID Pesanan Shopee',
'items' => 'Item Produk',
'items.*.product_variant_id' => 'Varian Produk',
'items.*.quantity' => 'Jumlah',
'notes' => 'Keterangan',
'photo_key' => 'Foto',
];
}
}

View File

@ -14,11 +14,13 @@
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
#[Guarded(['id'])] #[Guarded(['id'])]
class Order extends Model class Order extends Model implements HasMedia
{ {
use HasFactory, SoftDeletes; use HasFactory, InteractsWithMedia, SoftDeletes;
protected function casts(): array protected function casts(): array
{ {
@ -32,6 +34,7 @@ protected function casts(): array
'discount' => 'integer', 'discount' => 'integer',
'nego_price' => 'integer', 'nego_price' => 'integer',
'total_amount' => 'integer', 'total_amount' => 'integer',
'cogs' => 'integer',
]; ];
} }
@ -54,27 +57,15 @@ protected function completed(Builder $query): void
} }
#[Scope] #[Scope]
protected function credit(Builder $query): void protected function qris(Builder $query): void
{ {
$query->where('payment_type', PaymentType::CREDIT); $query->where('payment_type', PaymentType::QRIS);
} }
#[Scope] #[Scope]
protected function debit(Builder $query): void protected function store(Builder $query): void
{ {
$query->where('payment_type', PaymentType::DEBIT); $query->where('channel', OrderChannel::STORE);
}
#[Scope]
protected function offline(Builder $query): void
{
$query->where('channel', OrderChannel::OFFLINE);
}
#[Scope]
protected function online(Builder $query): void
{
$query->where('channel', OrderChannel::ONLINE);
} }
#[Scope] #[Scope]
@ -119,12 +110,6 @@ protected function transfer(Builder $query): void
$query->where('payment_type', PaymentType::TRANSFER); $query->where('payment_type', PaymentType::TRANSFER);
} }
#[Scope]
protected function whatsapp(Builder $query): void
{
$query->where('channel', OrderChannel::WHATSAPP);
}
#[Scope] #[Scope]
protected function wholesale(Builder $query): void protected function wholesale(Builder $query): void
{ {

View File

@ -0,0 +1,440 @@
<?php
namespace App\Services\Admin\Manage;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\PaymentType;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Models\Customer;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\Product;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\Concerns\RegistersMedia;
use App\Services\NotificationService;
use App\Services\S3PresignedService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class TransactionService
{
use RegistersMedia;
private const QUALITY_STOCK_MAP = [
ProductStockQuality::GOOD->value => 'stock',
ProductStockQuality::REJECT->value => 'reject_stock',
];
private const SELLING_PRICE_MAP = [
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
PriceType::AGEN->value => PriceType::AGEN,
PriceType::SUB_AGEN->value => PriceType::SUB_AGEN,
PriceType::WHOLESALE->value => PriceType::WHOLESALE,
PriceType::RETAIL->value => PriceType::RETAIL,
PriceType::TIKTOK->value => PriceType::TIKTOK,
PriceType::SHOPEE->value => PriceType::SHOPEE,
];
public function __construct(
private S3PresignedService $s3Service = new S3PresignedService,
) {}
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
{
$paginator = Order::query()
->select('id', 'created_by_id', 'customer_id', 'marketing_id', 'order_number', 'channel', 'price_type', 'status', 'payment_type', 'subtotal', 'discount', 'nego_price', 'total_amount', 'cogs', 'notes', 'created_at')
->with([
'createdBy:id',
'createdBy.userProfile:id,user_id,full_name',
'customer:id,name',
'marketing:id',
'marketing.userProfile:id,user_id,full_name',
'orderItems:id,order_id,product_variant_id,stock_quality,quantity,unit_price,subtotal',
'orderItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock',
'orderItems.productVariant.product:id,name',
])
->when($search, function ($q) use ($search) {
$q->whereHas('orderItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
->orWhere('order_number', 'like', "%{$search}%")
->orWhere('notes', 'like', "%{$search}%");
})
->when($filters['status'] ?? null, fn ($q, $status) => $q->where('status', $status))
->when($filters['channel'] ?? null, fn ($q, $channel) => $q->where('channel', $channel))
->when($filters['payment_type'] ?? null, fn ($q, $paymentType) => $q->where('payment_type', $paymentType))
->when($filters['customer_id'] ?? null, fn ($q, $customerId) => $q->where('customer_id', $customerId))
->when($filters['marketing_id'] ?? null, fn ($q, $marketingId) => $q->where('marketing_id', $marketingId))
->when($filters['created_by_id'] ?? null, fn ($q, $createdById) => $q->where('created_by_id', $createdById))
->orderBy($sort, $direction)
->paginate($perPage);
$paginator->getCollection()->each(function (Order $order) {
$order->status_label = $order->status->label();
$order->payment_type_label = $order->payment_type->label();
$order->channel_label = $order->channel->label();
$order->price_type_label = $order->price_type->label();
$order->orderItems->each(function (OrderItem $item) {
if (! $item->productVariant) {
return;
}
$media = $item->productVariant->getFirstMedia('photos');
$item->productVariant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
});
$order->profit = $order->total_amount - $order->cogs;
});
return $paginator;
}
public function getFilterOptions(): array
{
return [
'customers' => Customer::query()
->select('id', 'name')
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values()
->map(fn (User $user) => [
'id' => $user->id,
'name' => $user->userProfile->full_name,
]),
];
}
public function getForCreate(): array
{
return [
'products' => Product::query()
->select('id', 'name', 'status')
->with([
'productVariants:id,product_id,name,stock,reject_stock',
'productVariants.productPrices:id,variant_id,type,price',
])
->orderBy('name')
->get()
->each(function (Product $product) {
$product->productVariants->each(function (ProductVariant $variant) {
$media = $variant->getFirstMedia('photos');
$variant->photo_url = $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null;
$prices = $variant->productPrices->mapWithKeys(fn ($p) => [$p->type->value => $p->price]);
$variant->prices = $prices;
});
}),
'customers' => Customer::query()
->select('id', 'name')
->orderBy('name')
->get(),
'employees' => User::query()
->select('id')
->active()
->with('userProfile:id,user_id,full_name')
->orderBy('id')
->get()
->filter(fn (User $user) => $user->userProfile?->full_name)
->values(),
'channelOptions' => collect(OrderChannel::cases())->map(fn ($c) => ['value' => $c->value, 'label' => $c->label()])->values(),
'paymentTypeOptions' => collect(PaymentType::cases())->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(),
'priceTypeOptions' => collect(PriceType::cases())->filter(fn ($p) => ! in_array($p, [PriceType::CAPITAL]))->map(fn ($p) => ['value' => $p->value, 'label' => $p->label()])->values(),
];
}
public function getForEdit(Order $order): array
{
$order->load('orderItems.productVariant.product');
$media = $order->getFirstMedia('photos');
return [
'id' => $order->id,
'order_number' => $order->order_number,
'stock_type' => $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value,
'channel' => $order->channel?->value ?? OrderChannel::STORE->value,
'price_type' => $order->price_type?->value ?? PriceType::RETAIL->value,
'payment_type' => $order->payment_type?->value ?? PaymentType::CASH->value,
'customer_id' => $order->customer_id,
'marketing_id' => $order->marketing_id,
'discount' => $order->discount,
'nego_price' => $order->nego_price,
'is_completed' => $order->status === OrderStatus::COMPLETED,
'is_affiliate' => $order->is_affiliate,
'tiktok_order_id' => $order->tiktok_order_id,
'shopee_order_id' => $order->shopee_order_id,
'notes' => $order->notes,
'photo_key' => $media?->file_name,
'photo_url' => $media
? $this->s3Service->getTemporaryUrl($media->file_name)
: null,
'items' => $order->orderItems->map(fn (OrderItem $item) => [
'id' => $item->id,
'product_variant_id' => $item->product_variant_id,
'quantity' => $item->quantity,
'unit_price' => $item->unit_price,
])->values(),
];
}
public function create(array $data): Order
{
return DB::transaction(function () use ($data) {
$now = now();
$stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value;
$priceType = $data['price_type'] ?? PriceType::RETAIL->value;
$channel = $data['channel'] ?? OrderChannel::STORE->value;
$paymentType = $data['payment_type'] ?? PaymentType::CASH->value;
$subtotal = 0;
$totalCost = 0;
$itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost);
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
$order = Order::create([
'created_by_id' => auth()->id(),
'customer_id' => $data['customer_id'] ?? null,
'marketing_id' => $data['marketing_id'] ?? null,
'order_number' => $this->generateOrderNumber(),
'channel' => $channel,
'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $paymentType,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'total_amount' => $totalAmount,
'cogs' => $totalCost,
'notes' => $data['notes'] ?? null,
]);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
}
DB::table('order_items')->insert($itemRows);
$this->applyStock($data['items'], $stockType, -1);
if ($paymentType !== PaymentType::CASH->value) {
$this->syncPhoto($order, $data);
}
NotificationService::notify(
roles: ['Owner', 'Developer', 'Admin Toko'],
title: 'Transaksi Baru',
body: 'Transaksi '.$order->order_number.' sebesar Rp '.number_format($totalAmount, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
url: route('admin.manage.transactions.index'),
);
return $order;
});
}
public function update(Order $order, array $data): Order
{
return DB::transaction(function () use ($order, $data) {
$order->load('orderItems');
$oldStockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
});
$order->orderItems()->delete();
$now = now();
$stockType = $data['stock_type'] ?? ProductStockQuality::GOOD->value;
$priceType = $data['price_type'] ?? PriceType::RETAIL->value;
$subtotal = 0;
$totalCost = 0;
$itemRows = $this->buildItemRows($data['items'], $stockType, $priceType, $now, $subtotal, $totalCost);
$discount = (int) ($data['discount'] ?? 0);
$negoPrice = ! empty($data['nego_price']) ? (int) $data['nego_price'] : null;
$totalAmount = $subtotal - $discount + ($negoPrice ?? 0);
foreach ($itemRows as &$row) {
$row['order_id'] = $order->id;
}
DB::table('order_items')->insert($itemRows);
$order->update([
'customer_id' => $data['customer_id'] ?? null,
'marketing_id' => $data['marketing_id'] ?? null,
'channel' => $data['channel'] ?? $order->channel->value,
'price_type' => $priceType,
'status' => ($data['is_completed'] ?? false) ? OrderStatus::COMPLETED : OrderStatus::PENDING,
'payment_type' => $data['payment_type'] ?? $order->payment_type->value,
'is_affiliate' => $data['is_affiliate'] ?? false,
'tiktok_order_id' => $data['tiktok_order_id'] ?? null,
'shopee_order_id' => $data['shopee_order_id'] ?? null,
'subtotal' => $subtotal,
'discount' => $discount,
'nego_price' => $negoPrice,
'total_amount' => $totalAmount,
'cogs' => $totalCost,
'notes' => $data['notes'] ?? null,
]);
$this->applyStock($data['items'], $stockType, -1);
$paymentType = $data['payment_type'] ?? $order->payment_type->value;
if ($paymentType !== PaymentType::CASH->value) {
$this->syncPhoto($order, $data);
} else {
$order->clearMediaCollection('photos');
}
return $order;
});
}
public function delete(Order $order): bool
{
return DB::transaction(function () use ($order) {
$order->load('orderItems');
$stockType = $order->orderItems->first()?->stock_type?->value ?? ProductStockQuality::GOOD->value;
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
});
$order->orderItems()->delete();
$order->clearMediaCollection('photos');
$order->delete();
return true;
});
}
private function buildItemRows(array $items, string $stockType, string $priceType, $now, int &$subtotal, int &$totalCost): array
{
$resolvedPriceType = $stockType === ProductStockQuality::REJECT->value
? PriceType::REJECT
: (self::SELLING_PRICE_MAP[$priceType] ?? PriceType::RETAIL);
$variantIds = collect($items)->pluck('product_variant_id')->unique()->all();
$variants = ProductVariant::query()
->whereKey($variantIds)
->with('productPrices:id,variant_id,type,price')
->get();
$prices = $variants->mapWithKeys(function (ProductVariant $variant) use ($resolvedPriceType) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === $resolvedPriceType);
return [$variant->id => $price?->price ?? 0];
});
$capitalPrices = $variants->mapWithKeys(function (ProductVariant $variant) {
$price = $variant->productPrices
->first(fn ($p) => $p->type === PriceType::CAPITAL);
return [$variant->id => $price?->price ?? 0];
});
return collect($items)->map(function ($item) use ($now, $prices, $capitalPrices, $stockType, &$subtotal, &$totalCost) {
$quantity = (int) $item['quantity'];
$unitPrice = (int) ($prices[$item['product_variant_id']] ?? 0);
$itemSubtotal = $unitPrice * $quantity;
$subtotal += $itemSubtotal;
$capitalPrice = (int) ($capitalPrices[$item['product_variant_id']] ?? 0);
$totalCost += $capitalPrice * $quantity;
return [
'order_id' => null,
'user_id' => auth()->id(),
'product_variant_id' => $item['product_variant_id'],
'stock_quality' => $stockType,
'quantity' => $quantity,
'unit_price' => $unitPrice,
'subtotal' => $itemSubtotal,
'created_at' => $now,
'updated_at' => $now,
];
})->toArray();
}
private function applyStock(array $items, string $stockType, int $sign): void
{
foreach ($items as $item) {
$this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType);
}
}
private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void
{
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
if ($sign > 0) {
ProductVariant::whereKey($variantId)->increment($field, $quantity);
} else {
ProductVariant::whereKey($variantId)->decrement($field, $quantity);
}
}
private function syncPhoto(Order $order, array $data): void
{
if (! array_key_exists('photo_key', $data)) {
return;
}
$currentKey = $order->getFirstMedia('photos')?->file_name;
if ($data['photo_key'] === $currentKey) {
return;
}
$order->clearMediaCollection('photos');
if (! empty($data['photo_key'])) {
$this->registerMedia(
model: $order,
s3Key: $data['photo_key'],
collectionName: 'photos',
orderColumn: 1,
);
}
}
private function generateOrderNumber(): string
{
$prefix = 'TRX';
$date = now()->format('ymd');
$lastOrder = Order::where('order_number', 'like', "{$prefix}{$date}%")
->orderByDesc('order_number')
->first();
if ($lastOrder) {
$lastSequence = (int) substr($lastOrder->order_number, -4);
$sequence = $lastSequence + 1;
} else {
$sequence = 1;
}
return $prefix.$date.str_pad($sequence, 4, '0', STR_PAD_LEFT);
}
}

View File

@ -64,6 +64,26 @@
]) : [], ]) : [],
], ],
'mysql_old' => [
'driver' => 'mysql',
'url' => env('DB_OLD_URL'),
'host' => env('DB_OLD_HOST', '127.0.0.1'),
'port' => env('DB_OLD_PORT', '3306'),
'database' => env('DB_OLD_DATABASE', 'laravel'),
'username' => env('DB_OLD_USERNAME', 'root'),
'password' => env('DB_OLD_PASSWORD', ''),
'unix_socket' => env('DB_OLD_SOCKET', ''),
'charset' => env('DB_OLD_CHARSET', 'utf8mb4'),
'collation' => env('DB_OLD_COLLATION', 'utf8mb4_unicode_ci'),
'prefix' => '',
'prefix_indexes' => true,
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mariadb' => [ 'mariadb' => [
'driver' => 'mariadb', 'driver' => 'mariadb',
'url' => env('DB_URL'), 'url' => env('DB_URL'),

View File

@ -17,10 +17,10 @@ public function definition(): array
'customer_id' => Customer::factory(), 'customer_id' => Customer::factory(),
'created_by_id' => User::factory(), 'created_by_id' => User::factory(),
'order_number' => fake()->unique()->numerify('ORD-####'), 'order_number' => fake()->unique()->numerify('ORD-####'),
'channel' => fake()->randomElement(['offline', 'online', 'whatsapp', 'shopee', 'tiktok']), 'channel' => fake()->randomElement(['store', 'shopee', 'tiktok']),
'price_type' => fake()->randomElement(['retail', 'wholesale']), 'price_type' => fake()->randomElement(['retail', 'wholesale']),
'status' => 'pending', 'status' => 'pending',
'payment_type' => fake()->randomElement(['cash', 'transfer', 'debit', 'credit']), 'payment_type' => fake()->randomElement(['cash', 'transfer', 'marketplace', 'qris']),
'is_affiliate' => false, 'is_affiliate' => false,
'subtotal' => $subtotal, 'subtotal' => $subtotal,
'discount' => $discount, 'discount' => $discount,

View File

@ -33,6 +33,7 @@ public function up(): void
$table->unsignedBigInteger('nego_price')->nullable(); $table->unsignedBigInteger('nego_price')->nullable();
$table->json('marketplace_settings_snapshot')->nullable(); $table->json('marketplace_settings_snapshot')->nullable();
$table->unsignedBigInteger('total_amount'); $table->unsignedBigInteger('total_amount');
$table->unsignedBigInteger('cogs')->default(0);
$table->text('notes')->nullable(); $table->text('notes')->nullable();
$table->timestamp('created_at')->useCurrent(); $table->timestamp('created_at')->useCurrent();

View File

@ -30,6 +30,8 @@ public function run(): void
'purchase' => ['view', 'create', 'update', 'delete'], 'purchase' => ['view', 'create', 'update', 'delete'],
'cutting' => ['view', 'create', 'update', 'delete'], 'cutting' => ['view', 'create', 'update', 'delete'],
'restock' => ['view', 'create', 'update', 'delete'], 'restock' => ['view', 'create', 'update', 'delete'],
'transaction' => ['view', 'create', 'update', 'delete'],
'transaction' => ['view', 'create', 'update', 'delete'],
'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'], 'dashboard' => ['attendance', 'revenue', 'expense', 'orders_channel', 'orders_payment', 'orders_marketing', 'orders_status'],
'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'], 'analysis' => ['attendance', 'cash', 'raw_materials', 'product_stock', 'revenue', 'expense', 'profit_gross', 'profit_hpp', 'profit_orders', 'marketing_sales', 'top_suppliers', 'top_products', 'top_customers', 'busy_hours'],
]; ];

View File

@ -49,6 +49,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests';
import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings'; import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
import { index as purchasesIndex } from '@/routes/admin/manage/purchases'; import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
import { index as restocksIndex } from '@/routes/admin/manage/restocks'; import { index as restocksIndex } from '@/routes/admin/manage/restocks';
import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
import { index as categoriesIndex } from '@/routes/admin/master/categories'; import { index as categoriesIndex } from '@/routes/admin/master/categories';
import { index as customersIndex } from '@/routes/admin/master/customers'; import { index as customersIndex } from '@/routes/admin/master/customers';
import { index as productsIndex } from '@/routes/admin/master/products'; import { index as productsIndex } from '@/routes/admin/master/products';
@ -81,6 +82,7 @@ const masterItems: NavMenuItem[] = [
const kelolaItems: NavMenuItem[] = [ const kelolaItems: NavMenuItem[] = [
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart }, { title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors }, { title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors },
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart },
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw }, { title: 'Restock', href: restocksIndex.url(), icon: RefreshCw },
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck }, { title: 'Stok Opname', href: '#', icon: ClipboardCheck },
]; ];

View File

@ -8,6 +8,7 @@ import {
type RupiahInputProps = { type RupiahInputProps = {
name?: string; name?: string;
id?: string;
defaultValue?: number; defaultValue?: number;
value?: number; value?: number;
onValueChange?: (value: number) => void; onValueChange?: (value: number) => void;
@ -30,6 +31,7 @@ function parseRupiah(value: string): number {
export function RupiahInput({ export function RupiahInput({
name, name,
id,
defaultValue = 0, defaultValue = 0,
value, value,
onValueChange, onValueChange,
@ -85,6 +87,7 @@ export function RupiahInput({
</InputGroupAddon> </InputGroupAddon>
<InputGroupInput <InputGroupInput
name={name} name={name}
id={id}
type="text" type="text"
inputMode="numeric" inputMode="numeric"
value={displayValue} value={displayValue}

View File

@ -0,0 +1,23 @@
import { useDraftSave } from '@/hooks/use-draft-save';
import {
clearTransactionDraft,
saveTransactionDraft,
} from '@/lib/transaction-draft';
import type { TransactionDraftData } from '@/lib/transaction-draft';
type DraftType = 'create' | 'edit';
export function useTransactionDraftSave(
type: DraftType,
data: TransactionDraftData,
userId?: number,
delay = 500,
) {
return useDraftSave({
type,
data,
userId,
delay,
store: { save: saveTransactionDraft, clear: clearTransactionDraft },
});
}

View File

@ -0,0 +1,45 @@
import { createDraftStore } from '@/lib/draft-store';
export type TransactionDraftData = {
stockType: 'good' | 'reject';
channel: string;
priceType: string;
paymentType: string;
customerId: number | null;
marketingId: number | null;
discount: number;
negoPrice: number | null;
isCompleted: boolean;
isAffiliate: boolean;
tiktokOrderId: string;
shopeeOrderId: string;
selectedProductId: string;
quantities: Record<string, number>;
notes: string;
photo?: string;
};
export const transactionDraftStore =
createDraftStore<TransactionDraftData>('transaction-draft');
export function saveTransactionDraft(
type: 'create' | 'edit',
data: TransactionDraftData,
userId?: number,
): boolean {
return transactionDraftStore.save(type, data, userId);
}
export function loadTransactionDraft(
type: 'create' | 'edit',
userId?: number,
): TransactionDraftData | null {
return transactionDraftStore.load(type, userId);
}
export function clearTransactionDraft(
type: 'create' | 'edit',
userId?: number,
): void {
transactionDraftStore.clear(type, userId);
}

View File

@ -0,0 +1,134 @@
export type TransactionStockType = 'good' | 'reject';
export type TransactionChannel = 'offline' | 'online' | 'whatsapp' | 'shopee' | 'tiktok';
export type TransactionPriceType = 'distributor' | 'agent' | 'sub_agent' | 'wholesale' | 'retail' | 'tiktok' | 'shopee';
export type TransactionPaymentType = 'cash' | 'transfer' | 'marketplace' | 'qris';
export type TransactionStatus = 'pending' | 'processing' | 'completed' | 'cancelled' | 'refunded';
export type TransactionItem = {
id: number;
product_variant_id: number;
stock_quality: TransactionStockType;
quantity: number;
unit_price: number;
subtotal: number;
product_variant: {
id: number;
name: string;
photo_url: string | null;
product: {
id: number;
name: string;
};
};
};
export type Transaction = {
id: number;
created_by_id: number;
customer_id: number | null;
marketing_id: number | null;
order_number: string;
channel: TransactionChannel;
price_type: TransactionPriceType;
status: TransactionStatus;
status_label: string;
payment_type: TransactionPaymentType;
payment_type_label: string;
channel_label: string;
price_type_label: string;
profit: number;
cogs: number;
subtotal: number;
discount: number;
nego_price: number | null;
total_amount: number;
notes: string | null;
created_at: string;
created_by: {
id: number;
user_profile: {
full_name: string;
};
};
customer: {
id: number;
name: string;
} | null;
marketing: {
id: number;
user_profile: {
full_name: string;
};
} | null;
order_items: TransactionItem[];
};
export type TransactionForEdit = {
id: number;
order_number: string;
stock_type: TransactionStockType;
channel: TransactionChannel;
price_type: TransactionPriceType;
payment_type: TransactionPaymentType;
customer_id: number | null;
marketing_id: number | null;
discount: number;
nego_price: number | null;
is_completed: boolean;
is_affiliate: boolean;
tiktok_order_id: string | null;
shopee_order_id: string | null;
notes: string | null;
photo_key: string | null;
photo_url: string | null;
items: {
id: number;
product_variant_id: number;
quantity: number;
unit_price: number;
}[];
};
export type ProductForTransaction = {
id: number;
name: string;
status: string;
product_variants: {
id: number;
name: string;
stock: number;
reject_stock: number;
photo_url: string | null;
prices: Record<string, number>;
}[];
};
export type CustomerForTransaction = {
id: number;
name: string;
};
export type EmployeeForTransaction = {
id: number;
user_profile: {
full_name: string;
} | null;
};
export type OptionItem = {
value: string;
label: string;
};
export type TransactionCreateData = {
products: ProductForTransaction[];
customers: CustomerForTransaction[];
employees: EmployeeForTransaction[];
channelOptions: OptionItem[];
paymentTypeOptions: OptionItem[];
priceTypeOptions: OptionItem[];
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,977 @@
'use no memo';
import { Form, Head } from '@inertiajs/react';
import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { FileUpload } from '@/components/file-upload';
import { ImagePreviewModal } from '@/components/image-preview-modal';
import InputError from '@/components/input-error';
import { NumberInput } from '@/components/number-input';
import { RupiahInput } from '@/components/rupiah-input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { formatNumber } from '@/lib/format';
import { getTemporaryUrl } from '@/lib/upload';
import { formatCurrency } from '@/lib/utils';
import { index as transactionIndex, update } from '@/routes/admin/manage/transactions';
import type { TransactionCreateData, TransactionForEdit, OptionItem } from './columns';
type CartLine = {
key: string;
photoUrl: string | null;
title: string;
subtitle: string;
price: number;
quantity: number;
onAdjust: (delta: number) => void;
onSet: (value: number) => void;
onRemove: () => void;
};
type Props = {
transaction: TransactionForEdit;
data: TransactionCreateData;
};
const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee'];
export default function TransactionEdit({ transaction, data }: Props) {
const { products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions } = data;
const [stockType, setStockType] = useState<'good' | 'reject'>(
transaction.stock_type === 'reject' ? 'reject' : 'good',
);
const [channel, setChannel] = useState(transaction.channel);
const [priceType, setPriceType] = useState(transaction.price_type);
const [paymentType, setPaymentType] = useState(transaction.payment_type);
const [customerId, setCustomerId] = useState<number | null>(transaction.customer_id);
const [marketingId, setMarketingId] = useState<number | null>(transaction.marketing_id);
const [discount, setDiscount] = useState(transaction.discount);
const [negoPrice, setNegoPrice] = useState<number | null>(transaction.nego_price);
const [isCompleted, setIsCompleted] = useState(transaction.is_completed);
const [isAffiliate, setIsAffiliate] = useState(transaction.is_affiliate);
const [notes, setNotes] = useState(transaction.notes ?? '');
const [photo, setPhoto] = useState<string | null>(transaction.photo_key);
const [photoUrl, setPhotoUrl] = useState<string | null>(transaction.photo_url);
const [uploading, setUploading] = useState(false);
const selectedProductId = useMemo(() => {
const items = transaction.items ?? [];
const product = products.find((p) =>
p.product_variants.some((v) =>
items.some((i) => i.product_variant_id === v.id),
),
);
return product ? String(product.id) : '';
}, [products, transaction.items]);
const [quantities, setQuantities] = useState<Record<number, number>>(() =>
Object.fromEntries(
(transaction.items ?? []).map((item) => [
item.product_variant_id,
item.quantity,
]),
),
);
const [cartOpen, setCartOpen] = useState(false);
const [previewKey, setPreviewKey] = useState<string | null>(null);
const [cartRemoveKey, setCartRemoveKey] = useState<string | null>(null);
const quantitiesRef = useRef(quantities);
useEffect(() => {
quantitiesRef.current = quantities;
}, [quantities]);
useEffect(() => {
if (channel === 'tiktok') {
setPriceType('tiktok');
setPaymentType('marketplace');
} else if (channel === 'shopee') {
setPriceType('shopee');
setPaymentType('marketplace');
}
}, [channel]);
useEffect(() => {
if (stockType === 'reject') {
setPriceType('reject');
} else if (priceType === 'reject') {
setPriceType('retail');
}
}, [stockType]);
const [tiktokOrderId, setTiktokOrderId] = useState(transaction.tiktok_order_id ?? '');
const [shopeeOrderId, setShopeeOrderId] = useState(transaction.shopee_order_id ?? '');
const selectedProduct = useMemo(
() => products.find((p) => String(p.id) === selectedProductId) ?? null,
[products, selectedProductId],
);
const variantById = useMemo(
() =>
new Map(
products.flatMap((p) =>
p.product_variants.map((v) => [v.id, v]),
),
),
[products],
);
const showPhoto = paymentType === 'transfer' || paymentType === 'qris';
const availablePriceTypes = useMemo(() => {
if (stockType === 'reject') {
return priceTypeOptions.filter((o) => o.value === 'reject');
}
return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value));
}, [stockType, priceTypeOptions]);
const getUnitPrice = useCallback(
(variantId: number) => {
const variant = variantById.get(variantId);
if (!variant) {
return 0;
}
if (stockType === 'reject') {
return variant.prices?.reject ?? 0;
}
return variant.prices?.[priceType] ?? 0;
},
[variantById, stockType, priceType],
);
const subtotal = Object.entries(quantities).reduce(
(sum, [variantId, quantity]) => {
const unitPrice = getUnitPrice(Number(variantId));
return sum + unitPrice * quantity;
},
0,
);
const total = subtotal - discount + (negoPrice ?? 0);
const updateQuantity = useCallback((variantId: number, value: number) => {
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, value),
}));
}, []);
const incrementQuantity = useCallback(
(variantId: number, amount: number) => {
setQuantities((prev) => ({
...prev,
[variantId]: Math.max(0, (prev[variantId] ?? 0) + amount),
}));
},
[],
);
const cartItems: CartLine[] = (() => {
const lines: CartLine[] = [];
for (const [variantId, quantity] of Object.entries(quantities)) {
if (quantity <= 0) {
continue;
}
const id = Number(variantId);
const variant = variantById.get(id);
if (variant) {
const unitPrice = getUnitPrice(id);
lines.push({
key: `variant-${id}`,
photoUrl: variant.photo_url,
title: variant.name,
subtitle: `${formatCurrency(unitPrice)} / pcs`,
price: unitPrice,
quantity,
onAdjust: (delta) => incrementQuantity(id, delta),
onSet: (value) => updateQuantity(id, value),
onRemove: () => updateQuantity(id, 0),
});
}
}
return lines;
})();
function formatQuantity(value: number): string {
return formatNumber(value, { maximumFractionDigits: 4 });
}
function getPayload() {
return {
stock_type: stockType,
channel,
price_type: stockType === 'reject' ? 'reject' : priceType,
payment_type: paymentType,
customer_id: customerId,
marketing_id: marketingId,
discount,
nego_price: negoPrice,
is_completed: isCompleted,
is_affiliate: isAffiliate,
tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null,
shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null,
items: Object.entries(quantitiesRef.current)
.map(([variantId, quantity]) => ({
product_variant_id: Number(variantId),
quantity: Number(quantity),
}))
.filter((item) => item.quantity > 0),
notes: notes || null,
photo_key: showPhoto ? photo : null,
};
}
if (transaction.id == null) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground">Transaksi tidak ditemukan.</p>
</div>
);
}
return (
<>
<Head title="Edit Transaksi" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold tracking-tight">
Edit Transaksi
</h2>
<Button asChild variant="outline">
<a href={transactionIndex.url()}>
<ArrowLeft className="h-4 w-4" />
Kembali
</a>
</Button>
</div>
<Form
action={update(transaction.id)}
method="put"
transform={(formData) => ({
...formData,
...getPayload(),
})}
>
{({ errors, processing }) => (
<div className="grid gap-6 md:grid-cols-3">
<div className="space-y-6 md:col-span-2">
<Card>
<CardHeader>
<CardTitle>Item Transaksi</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{selectedProduct ? (
<div className="space-y-2">
{selectedProduct.product_variants.map(
(variant) => {
const currentStock =
stockType === 'good'
? variant.stock
: variant.reject_stock;
return (
<div
key={variant.id}
className={
(quantities[
variant
.id
] ?? 0) > 0
? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3'
: 'flex items-center justify-between gap-3 rounded-lg border p-3'
}
>
<div className="flex min-w-0 items-center gap-3">
{variant.photo_url ? (
<img
src={
variant.photo_url
}
alt={
variant.name
}
className="h-10 w-10 shrink-0 rounded-md object-cover"
/>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<div className="min-w-0">
<p className="truncate font-medium">
{
variant.name
}
</p>
<p className="text-xs text-muted-foreground">
Stok:{' '}
{formatQuantity(
Number(
currentStock,
),
)}{' '}
pcs
·{' '}
{formatCurrency(
stockType === 'reject'
? (variant.prices?.reject ?? 0)
: (variant.prices?.[priceType] ?? 0),
)}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
disabled={
!(
quantities[
variant
.id
] ??
0
)
}
onClick={() =>
incrementQuantity(
variant.id,
-1,
)
}
>
<Minus className="h-4 w-4" />
</Button>
<NumberInput
className="w-24 text-center"
value={
quantities[
variant
.id
] ??
0
}
onValueChange={(
val,
) =>
updateQuantity(
variant.id,
val,
)
}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() =>
incrementQuantity(
variant.id,
1,
)
}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
);
},
)}
</div>
) : (
<p className="text-sm text-muted-foreground">
Tidak ada item.
</p>
)}
<InputError message={errors.items} />
</CardContent>
</Card>
</div>
<div className="space-y-6 md:col-span-1">
<Card className="sticky top-6">
<CardHeader>
<CardTitle>Ringkasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2">
<Label>Tipe Stok <span className="text-destructive">*</span></Label>
<RadioGroup
value={stockType}
onValueChange={(value) =>
setStockType(
value as
'good' | 'reject',
)
}
className="flex flex-wrap gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="good"
id="edit-stock-type-good"
/>
<Label
htmlFor="edit-stock-type-good"
className="font-normal"
>
Bagus
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="reject"
id="edit-stock-type-reject"
/>
<Label
htmlFor="edit-stock-type-reject"
className="font-normal"
>
Reject
</Label>
</div>
</RadioGroup>
<InputError
message={errors.stock_type}
/>
</div>
<div className="grid gap-2">
<Label>Channel <span className="text-destructive">*</span></Label>
<Select
value={channel}
onValueChange={setChannel}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih channel" />
</SelectTrigger>
<SelectContent>
{channelOptions.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.channel}
/>
</div>
{channel === 'tiktok' && (
<div className="grid gap-2">
<Label htmlFor="edit-tiktok_order_id">
ID Pesanan TikTok
</Label>
<input
id="edit-tiktok_order_id"
type="text"
value={tiktokOrderId}
onChange={(e) => setTiktokOrderId(e.target.value)}
placeholder="Masukkan ID pesanan TikTok"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
/>
<InputError
message={errors.tiktok_order_id}
/>
</div>
)}
{channel === 'shopee' && (
<div className="grid gap-2">
<Label htmlFor="edit-shopee_order_id">
ID Pesanan Shopee
</Label>
<input
id="edit-shopee_order_id"
type="text"
value={shopeeOrderId}
onChange={(e) => setShopeeOrderId(e.target.value)}
placeholder="Masukkan ID pesanan Shopee"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
/>
<InputError
message={errors.shopee_order_id}
/>
</div>
)}
<div className="grid gap-2">
<Label>Tipe Harga <span className="text-destructive">*</span></Label>
<Select
value={priceType}
onValueChange={setPriceType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe harga" />
</SelectTrigger>
<SelectContent>
{availablePriceTypes.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.price_type}
/>
</div>
<div className="grid gap-2">
<Label>Tipe Pembayaran <span className="text-destructive">*</span></Label>
<Select
value={paymentType}
onValueChange={setPaymentType}
disabled={channel === 'tiktok' || channel === 'shopee'}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Pilih tipe pembayaran" />
</SelectTrigger>
<SelectContent>
{paymentTypeOptions.map(
(opt) => (
<SelectItem
key={opt.value}
value={opt.value}
>
{opt.label}
</SelectItem>
),
)}
</SelectContent>
</Select>
<InputError
message={errors.payment_type}
/>
</div>
{showPhoto && (
<div className="grid gap-2">
<Label>Foto <span className="text-destructive">*</span></Label>
<FileUpload
value={photo}
onChange={(key) => {
setPhoto(key);
setPhotoUrl(
key
? getTemporaryUrl(
key,
)
: null,
);
}}
folder="transaction"
existingUrl={photoUrl}
onUploadingChange={setUploading}
/>
<InputError
message={errors.photo_key}
/>
</div>
)}
<div className="grid gap-2">
<Label>Pelanggan</Label>
<Combobox
items={customers}
itemToStringLabel={(c) => c.name}
value={customers.find((c) => c.id === customerId) ?? null}
onValueChange={(value) =>
setCustomerId(value ? value.id : null)
}
>
<ComboboxInput
placeholder="Pilih pelanggan..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada pelanggan.
</ComboboxEmpty>
<ComboboxList>
{(c) => (
<ComboboxItem
key={c.id}
value={c}
>
{c.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.customer_id}
/>
</div>
<div className="grid gap-2">
<Label>Marketing</Label>
<Combobox
items={employees}
itemToStringLabel={(e) =>
e.user_profile?.full_name ?? '-'
}
value={employees.find((e) => e.id === marketingId) ?? null}
onValueChange={(value) =>
setMarketingId(value ? value.id : null)
}
>
<ComboboxInput
placeholder="Pilih marketing..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada karyawan.
</ComboboxEmpty>
<ComboboxList>
{(e) => (
<ComboboxItem
key={e.id}
value={e}
>
{e.user_profile?.full_name ?? '-'}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
<InputError
message={errors.marketing_id}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Subtotal
</span>
<span className="font-medium">
{formatCurrency(subtotal)}
</span>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-discount">
Diskon
</Label>
<RupiahInput
id="edit-discount"
value={discount}
onValueChange={setDiscount}
placeholder="0"
/>
<InputError
message={errors.discount}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-nego_price">
Harga Nego
</Label>
<RupiahInput
id="edit-nego_price"
value={negoPrice ?? 0}
onValueChange={(val) =>
setNegoPrice(val || null)
}
placeholder="0"
/>
<InputError
message={errors.nego_price}
/>
</div>
<div className="border-t pt-2">
<div className="flex items-center justify-between text-sm font-semibold">
<span>Total</span>
<span>
{formatCurrency(
total,
)}
</span>
</div>
</div>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-is_completed">
Pesanan Selesai
</Label>
<Switch
id="edit-is_completed"
checked={isCompleted}
onCheckedChange={setIsCompleted}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="edit-is_affiliate">
Affiliasi
</Label>
<Switch
id="edit-is_affiliate"
checked={isAffiliate}
onCheckedChange={setIsAffiliate}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-notes">
Keterangan
</Label>
<Textarea
id="edit-notes"
value={notes}
onChange={(e) =>
setNotes(e.target.value)
}
placeholder="Masukkan keterangan"
maxLength={100}
/>
<InputError
message={errors.notes}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={
processing ||
uploading ||
Object.values(quantities).every(
(q) => q <= 0,
)
}
>
{processing
? 'Menyimpan...'
: 'Simpan'}
</Button>
</CardContent>
</Card>
</div>
</div>
)}
</Form>
<Button
type="button"
onClick={() => setCartOpen(true)}
className="fixed top-1/2 right-4 z-50 h-14 w-14 -translate-y-1/2 rounded-full shadow-lg"
size="icon"
aria-label="Buka keranjang transaksi"
>
<ShoppingCart className="h-5 w-5" />
{cartItems.length > 0 && (
<span className="absolute -top-1 -right-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-destructive px-1 text-xs font-semibold text-white">
{cartItems.length}
</span>
)}
</Button>
<Sheet open={cartOpen} onOpenChange={setCartOpen}>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>Keranjang Transaksi</SheetTitle>
</SheetHeader>
<div className="flex-1 space-y-3 overflow-y-auto px-6 pb-6">
{cartItems.length === 0 ? (
<p className="text-sm text-muted-foreground">
Keranjang kosong.
</p>
) : (
cartItems.map((item) => (
<div
key={item.key}
className="space-y-3 rounded-lg border p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-3">
{item.photoUrl ? (
<button
type="button"
onClick={() =>
setPreviewKey(
item.key,
)
}
className="block h-10 w-10 shrink-0 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
>
<img
src={item.photoUrl}
alt={item.title}
className="h-full w-full object-cover"
/>
</button>
) : (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
<div>
<p className="font-medium">
{item.title}
</p>
<p className="text-xs text-muted-foreground">
{item.subtitle}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() =>
setCartRemoveKey(item.key)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon-sm"
disabled={
item.quantity <= 0
}
onClick={() =>
item.onAdjust(-1)
}
>
<Minus className="h-4 w-4" />
</Button>
<NumberInput
className="w-20 text-center"
value={item.quantity}
onValueChange={item.onSet}
/>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={() =>
item.onAdjust(1)
}
>
<Plus className="h-4 w-4" />
</Button>
</div>
<span className="font-medium">
{formatCurrency(
item.price * item.quantity,
)}
</span>
</div>
</div>
))
)}
</div>
<SheetFooter>
<div className="flex items-center justify-between border-t pt-4">
<span className="text-sm">Subtotal</span>
<span className="text-sm font-semibold">
{formatCurrency(subtotal)}
</span>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
<ImagePreviewModal
open={previewKey !== null}
onOpenChange={(open) => {
if (!open) {
setPreviewKey(null);
}
}}
src={
cartItems.find((i) => i.key === previewKey)?.photoUrl ??
null
}
title={cartItems.find((i) => i.key === previewKey)?.title}
/>
<ConfirmDialog
open={cartRemoveKey !== null}
onOpenChange={(open) => {
if (!open) {
setCartRemoveKey(null);
}
}}
title="Hapus Item Keranjang"
description="Apakah Anda yakin ingin menghapus item ini dari keranjang?"
confirmLabel="Hapus"
variant="destructive"
onConfirm={() => {
cartItems
.find((i) => i.key === cartRemoveKey)
?.onRemove();
setCartRemoveKey(null);
}}
/>
</div>
</>
);
}

View File

@ -0,0 +1,374 @@
import { Head, router } from '@inertiajs/react';
import { Plus } from 'lucide-react';
import { useMemo, useState } from 'react';
import { CardTable } from '@/components/card-table';
import { DeleteConfirmDialog } from '@/components/delete-confirm-dialog';
import { FilterPopover } from '@/components/filter-popover';
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
import { PageHeader } from '@/components/page-header';
import { Button } from '@/components/ui/button';
import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
} from '@/components/ui/combobox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useServerTable } from '@/hooks/use-server-table';
import {
destroy,
create as transactionCreate,
index as transactionIndex,
edit as transactionEdit,
} from '@/routes/admin/manage/transactions';
import type { Transaction } from './columns';
import { TransactionCardRow } from './transaction-card';
import { TransactionItemSubRow } from './transaction-sub-row';
type FilterOption = {
id: number;
name: string;
};
type Props = {
transactions: {
data: Transaction[];
current_page: number;
last_page: number;
per_page: number;
total: number;
};
filters: {
status?: string;
channel?: string;
payment_type?: string;
customer_id?: string;
marketing_id?: string;
created_by_id?: string;
};
filterOptions: {
customers: FilterOption[];
employees: FilterOption[];
};
};
export default function TransactionIndex({
transactions,
filters,
filterOptions,
}: Props) {
const [deleting, setDeleting] = useState<Transaction | null>(null);
const expand = useCardTableExpand(true);
const pagination = {
current_page: transactions.current_page,
last_page: transactions.last_page,
per_page: transactions.per_page,
total: transactions.total,
};
const {
search,
filterOpen,
setFilterOpen,
handlePageChange,
handlePerPageChange,
handleSearchChange,
applyFilter,
clearFilters,
} = useServerTable({
route: () => transactionIndex.url(),
pagination,
filters,
});
const selectedCustomer = useMemo(
() =>
filterOptions.customers.find(
(c) => String(c.id) === filters.customer_id,
) ?? null,
[filterOptions.customers, filters.customer_id],
);
const selectedMarketing = useMemo(
() =>
filterOptions.employees.find(
(e) => String(e.id) === filters.marketing_id,
) ?? null,
[filterOptions.employees, filters.marketing_id],
);
const selectedEmployee = useMemo(
() =>
filterOptions.employees.find(
(e) => String(e.id) === filters.created_by_id,
) ?? null,
[filterOptions.employees, filters.created_by_id],
);
function handleDelete() {
if (!deleting) {
return;
}
router.delete(destroy.url(deleting.id), {
onSuccess: () => setDeleting(null),
});
}
const filterToolbar = (
<FilterPopover
open={filterOpen}
onOpenChange={setFilterOpen}
filters={filters}
hasActiveFilters={
Boolean(filters.status) ||
Boolean(filters.channel) ||
Boolean(filters.payment_type) ||
Boolean(filters.customer_id) ||
Boolean(filters.marketing_id) ||
Boolean(filters.created_by_id)
}
onClear={clearFilters}
>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={filters.status ?? 'all'}
onValueChange={(value) => applyFilter('status', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="processing">Diproses</SelectItem>
<SelectItem value="completed">Selesai</SelectItem>
<SelectItem value="cancelled">Dibatalkan</SelectItem>
<SelectItem value="refunded">Dikembalikan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Channel
</label>
<Select
value={filters.channel ?? 'all'}
onValueChange={(value) => applyFilter('channel', value)}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Channel" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Channel</SelectItem>
<SelectItem value="store">Toko</SelectItem>
<SelectItem value="shopee">Shopee</SelectItem>
<SelectItem value="tiktok">TikTok</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Tipe Pembayaran
</label>
<Select
value={filters.payment_type ?? 'all'}
onValueChange={(value) =>
applyFilter('payment_type', value)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Semua Tipe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="cash">Tunai</SelectItem>
<SelectItem value="transfer">Transfer</SelectItem>
<SelectItem value="marketplace">Marketplace</SelectItem>
<SelectItem value="qris">QRIS</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Pelanggan
</label>
<Combobox
items={filterOptions.customers}
itemToStringLabel={(c) => c.name}
value={selectedCustomer}
onValueChange={(value) =>
applyFilter(
'customer_id',
value ? String(value.id) : '',
)
}
>
<ComboboxInput
placeholder="Pilih pelanggan..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada pelanggan ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(customer) => (
<ComboboxItem value={customer}>
{customer.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Marketing
</label>
<Combobox
items={filterOptions.employees}
itemToStringLabel={(e) => e.name}
value={selectedMarketing}
onValueChange={(value) =>
applyFilter(
'marketing_id',
value ? String(value.id) : '',
)
}
>
<ComboboxInput
placeholder="Pilih marketing..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada marketing ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(employee) => (
<ComboboxItem value={employee}>
{employee.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs text-muted-foreground">
Pegawai
</label>
<Combobox
items={filterOptions.employees}
itemToStringLabel={(e) => e.name}
value={selectedEmployee}
onValueChange={(value) =>
applyFilter(
'created_by_id',
value ? String(value.id) : '',
)
}
>
<ComboboxInput
placeholder="Pilih pegawai..."
className="w-full"
/>
<ComboboxContent>
<ComboboxEmpty>
Tidak ada pegawai ditemukan.
</ComboboxEmpty>
<ComboboxList>
{(employee) => (
<ComboboxItem value={employee}>
{employee.name}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</div>
</FilterPopover>
);
return (
<>
<Head title="Transaksi" />
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
<PageHeader
title="Transaksi"
actions={
<Button asChild>
<a href={transactionCreate.url()}>
<Plus className="h-4 w-4" />
Tambah
</a>
</Button>
}
/>
<CardTable
data={transactions.data}
getItemKey={(t) => t.id}
expandedKeys={expand.expandedKeys}
onToggleExpand={expand.toggleExpand}
searchValue={search}
onSearchChange={handleSearchChange}
searchPlaceholder="Cari berdasarkan produk atau nomor transaksi..."
toolbar={filterToolbar}
pagination={pagination}
onPageChange={handlePageChange}
onPerPageChange={handlePerPageChange}
renderCard={({
item,
index,
isExpanded,
onToggleExpand,
}) => (
<TransactionCardRow
transaction={item}
index={
(pagination.current_page - 1) *
pagination.per_page +
index +
1
}
isExpanded={isExpanded}
onToggleExpand={onToggleExpand}
onEdit={(t) => {
window.location.href = transactionEdit.url(t.id);
}}
onDelete={(t) => setDeleting(t)}
/>
)}
renderSubContent={(transaction) => (
<TransactionItemSubRow transaction={transaction} />
)}
/>
<DeleteConfirmDialog
target={deleting}
onOpenChange={(open) => {
if (!open) {
setDeleting(null);
}
}}
title="Hapus Transaksi"
description="Apakah Anda yakin ingin menghapus transaksi ini? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan."
onConfirm={handleDelete}
/>
</div>
</>
);
}

View File

@ -0,0 +1,195 @@
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
import { RowActions } from '@/components/row-actions';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { formatDateTime, formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Transaction } from './columns';
const STATUS_BADGE_CLASSES: Record<string, string> = {
pending: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
processing: 'bg-blue-100 text-blue-800 hover:bg-blue-100',
completed: 'bg-green-100 text-green-800 hover:bg-green-100',
cancelled: 'bg-red-100 text-red-800 hover:bg-red-100',
refunded: 'bg-purple-100 text-purple-800 hover:bg-purple-100',
};
export type TransactionCardRowParams = {
transaction: Transaction;
index: number;
isExpanded: boolean;
onToggleExpand: () => void;
onEdit: (transaction: Transaction) => void;
onDelete: (transaction: Transaction) => void;
};
export function TransactionCardRow({
transaction,
index,
isExpanded,
onToggleExpand,
onEdit,
onDelete,
}: TransactionCardRowParams) {
const items = transaction.order_items ?? [];
const variantCount = items.length;
const productNames = [
...new Set(
items
.map((item) => item.product_variant?.product?.name)
.filter(Boolean),
),
];
const totalQty = items.reduce(
(sum, item) => sum + Number(item.quantity),
0,
);
const statusBadgeClass =
STATUS_BADGE_CLASSES[transaction.status] ?? STATUS_BADGE_CLASSES.pending;
return (
<Card className="overflow-hidden">
<CardContent className="p-0">
<div className="flex items-start gap-3 p-4">
<Button
variant="ghost"
size="icon"
className="mt-0.5 h-6 w-6 shrink-0"
onClick={onToggleExpand}
>
<ChevronDown
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
/>
</Button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">
{index}.
</span>
<h3 className="truncate font-medium">
{transaction.order_number}
</h3>
<Badge
variant="secondary"
className={statusBadgeClass}
>
{transaction.status_label}
</Badge>
<Badge variant="outline">
{transaction.payment_type_label}
</Badge>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{productNames.length > 0 && (
<span>{productNames.join(', ')}</span>
)}
{variantCount > 0 && (
<span> ({variantCount} varian)</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{formatDateTime(transaction.created_at)}
</span>
<span className="text-muted-foreground">
Oleh:{' '}
<span className="font-medium text-foreground">
{transaction.created_by?.user_profile
?.full_name ?? '-'}
</span>
</span>
{transaction.customer && (
<span className="text-muted-foreground">
Pelanggan:{' '}
<span className="font-medium text-foreground">
{transaction.customer.name}
</span>
</span>
)}
{transaction.notes && (
<span className="max-w-[200px] truncate">
{transaction.notes}
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span>
<span className="text-muted-foreground">
Qty:{' '}
</span>
{formatNumber(totalQty)}
</span>
<span>
<span className="text-muted-foreground">
Channel:{' '}
</span>
{transaction.channel_label}
</span>
<span>
<span className="text-muted-foreground">
Harga:{' '}
</span>
{transaction.price_type_label}
</span>
<span>
<span className="text-muted-foreground">
Sub:{' '}
</span>
{formatCurrency(transaction.subtotal)}
</span>
{transaction.discount > 0 && (
<span>
<span className="text-muted-foreground">
Diskon:{' '}
</span>
{formatCurrency(transaction.discount)}
</span>
)}
<span className="font-semibold">
<span className="font-normal text-muted-foreground">
Total:{' '}
</span>
{formatCurrency(transaction.total_amount)}
</span>
<span className={transaction.profit >= 0 ? 'text-green-600' : 'text-red-600'}>
<span className="font-normal text-muted-foreground">
Laba:{' '}
</span>
{formatCurrency(transaction.profit)}
</span>
<span>
<span className="text-muted-foreground">
HPP:{' '}
</span>
{formatCurrency(transaction.cogs)}
</span>
</div>
</div>
<RowActions
actions={[
{
label: 'Edit',
icon: <Pencil className="h-4 w-4" />,
onClick: () => onEdit(transaction),
},
{
label: 'Hapus',
icon: (
<Trash2 className="h-4 w-4 text-destructive" />
),
onClick: () => onDelete(transaction),
},
]}
wrapperClassName="flex shrink-0 items-center gap-1"
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,87 @@
import { ImagePreviewButton } from '@/components/image-preview-button';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { formatNumber } from '@/lib/format';
import { formatCurrency } from '@/lib/utils';
import type { Transaction } from './columns';
export function TransactionItemSubRow({ transaction }: { transaction: Transaction }) {
const items = transaction.order_items ?? [];
return (
<div className="space-y-4 overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px] text-center">
No
</TableHead>
<TableHead className="w-[60px]">Foto</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead className="text-right">
Harga
</TableHead>
<TableHead className="text-center">Qty</TableHead>
<TableHead className="text-right">Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.length === 0 ? (
<TableRow>
<TableCell
colSpan={7}
className="text-center text-muted-foreground"
>
Tidak ada item.
</TableCell>
</TableRow>
) : (
items.map((item, index) => (
<TableRow key={item.id}>
<TableCell className="text-center">
{index + 1}
</TableCell>
<TableCell>
{item.product_variant?.photo_url ? (
<ImagePreviewButton
srcs={[
item.product_variant.photo_url,
]}
title={item.product_variant.name}
/>
) : (
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
N/A
</div>
)}
</TableCell>
<TableCell>
{item.product_variant?.product?.name ?? '-'}
</TableCell>
<TableCell>
{item.product_variant?.name ?? '-'}
</TableCell>
<TableCell className="text-right">
{formatCurrency(item.unit_price)}
</TableCell>
<TableCell className="text-center">
{formatNumber(item.quantity)}
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(item.subtotal)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
}

View File

@ -15,6 +15,7 @@
use App\Http\Controllers\Admin\Manage\CuttingController; use App\Http\Controllers\Admin\Manage\CuttingController;
use App\Http\Controllers\Admin\Manage\PurchaseController; use App\Http\Controllers\Admin\Manage\PurchaseController;
use App\Http\Controllers\Admin\Manage\RestockController; use App\Http\Controllers\Admin\Manage\RestockController;
use App\Http\Controllers\Admin\Manage\TransactionController;
use App\Http\Controllers\Admin\Master\CategoryController; use App\Http\Controllers\Admin\Master\CategoryController;
use App\Http\Controllers\Admin\Master\CustomerController; use App\Http\Controllers\Admin\Master\CustomerController;
use App\Http\Controllers\Admin\Master\Product\ProductController; use App\Http\Controllers\Admin\Master\Product\ProductController;
@ -118,6 +119,7 @@
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete'); Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete');
Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cutting.view|cutting.create|cutting.update|cutting.delete'); Route::resource('cuttings', CuttingController::class)->except(['show'])->middleware('permission:cutting.view|cutting.create|cutting.update|cutting.delete');
Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restock.view|restock.create|restock.update|restock.delete'); Route::resource('restocks', RestockController::class)->except(['show'])->middleware('permission:restock.view|restock.create|restock.update|restock.delete');
Route::resource('transactions', TransactionController::class)->except(['show'])->middleware('permission:transaction.view|transaction.create|transaction.update|transaction.delete');
}); });
}); });