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:
parent
e7aa582572
commit
72848d51d8
19
.env.example
19
.env.example
@ -20,12 +20,19 @@ LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=sqlite
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
# DB_DATABASE=laravel
|
||||
# DB_USERNAME=root
|
||||
# DB_PASSWORD=
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_DATABASE=laravel
|
||||
DB_USERNAME=root
|
||||
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_LIFETIME=120
|
||||
|
||||
@ -8,9 +8,16 @@ enum OrderChannel: string
|
||||
{
|
||||
use HasValues;
|
||||
|
||||
case OFFLINE = 'offline';
|
||||
case ONLINE = 'online';
|
||||
case WHATSAPP = 'whatsapp';
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,4 +13,15 @@ enum OrderStatus: string
|
||||
case COMPLETED = 'completed';
|
||||
case CANCELLED = 'cancelled';
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,6 +10,16 @@ enum PaymentType: string
|
||||
|
||||
case CASH = 'cash';
|
||||
case TRANSFER = 'transfer';
|
||||
case DEBIT = 'debit';
|
||||
case CREDIT = 'credit';
|
||||
case MARKETPLACE = 'marketplace';
|
||||
case QRIS = 'qris';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CASH => 'Tunai',
|
||||
self::TRANSFER => 'Transfer',
|
||||
self::MARKETPLACE => 'Marketplace',
|
||||
self::QRIS => 'QRIS',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
78
app/Http/Controllers/Admin/Manage/TransactionController.php
Normal file
78
app/Http/Controllers/Admin/Manage/TransactionController.php
Normal 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'
|
||||
);
|
||||
}
|
||||
}
|
||||
87
app/Http/Requests/Admin/Manage/TransactionRequest.php
Normal file
87
app/Http/Requests/Admin/Manage/TransactionRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -14,11 +14,13 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Order extends Model
|
||||
class Order extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
@ -32,6 +34,7 @@ protected function casts(): array
|
||||
'discount' => 'integer',
|
||||
'nego_price' => 'integer',
|
||||
'total_amount' => 'integer',
|
||||
'cogs' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@ -54,27 +57,15 @@ protected function completed(Builder $query): void
|
||||
}
|
||||
|
||||
#[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]
|
||||
protected function debit(Builder $query): void
|
||||
protected function store(Builder $query): void
|
||||
{
|
||||
$query->where('payment_type', PaymentType::DEBIT);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function offline(Builder $query): void
|
||||
{
|
||||
$query->where('channel', OrderChannel::OFFLINE);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function online(Builder $query): void
|
||||
{
|
||||
$query->where('channel', OrderChannel::ONLINE);
|
||||
$query->where('channel', OrderChannel::STORE);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
@ -119,12 +110,6 @@ protected function transfer(Builder $query): void
|
||||
$query->where('payment_type', PaymentType::TRANSFER);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function whatsapp(Builder $query): void
|
||||
{
|
||||
$query->where('channel', OrderChannel::WHATSAPP);
|
||||
}
|
||||
|
||||
#[Scope]
|
||||
protected function wholesale(Builder $query): void
|
||||
{
|
||||
|
||||
440
app/Services/Admin/Manage/TransactionService.php
Normal file
440
app/Services/Admin/Manage/TransactionService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
|
||||
@ -17,10 +17,10 @@ public function definition(): array
|
||||
'customer_id' => Customer::factory(),
|
||||
'created_by_id' => User::factory(),
|
||||
'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']),
|
||||
'status' => 'pending',
|
||||
'payment_type' => fake()->randomElement(['cash', 'transfer', 'debit', 'credit']),
|
||||
'payment_type' => fake()->randomElement(['cash', 'transfer', 'marketplace', 'qris']),
|
||||
'is_affiliate' => false,
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
|
||||
@ -33,6 +33,7 @@ public function up(): void
|
||||
$table->unsignedBigInteger('nego_price')->nullable();
|
||||
$table->json('marketplace_settings_snapshot')->nullable();
|
||||
$table->unsignedBigInteger('total_amount');
|
||||
$table->unsignedBigInteger('cogs')->default(0);
|
||||
$table->text('notes')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
@ -30,6 +30,8 @@ public function run(): void
|
||||
'purchase' => ['view', 'create', 'update', 'delete'],
|
||||
'cutting' => ['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'],
|
||||
'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'],
|
||||
];
|
||||
|
||||
@ -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 purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
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 customersIndex } from '@/routes/admin/master/customers';
|
||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
||||
@ -81,6 +82,7 @@ const masterItems: NavMenuItem[] = [
|
||||
const kelolaItems: NavMenuItem[] = [
|
||||
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
|
||||
{ title: 'Cutting', href: cuttingsIndex.url(), icon: Scissors },
|
||||
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart },
|
||||
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw },
|
||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||
];
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
|
||||
type RupiahInputProps = {
|
||||
name?: string;
|
||||
id?: string;
|
||||
defaultValue?: number;
|
||||
value?: number;
|
||||
onValueChange?: (value: number) => void;
|
||||
@ -30,6 +31,7 @@ function parseRupiah(value: string): number {
|
||||
|
||||
export function RupiahInput({
|
||||
name,
|
||||
id,
|
||||
defaultValue = 0,
|
||||
value,
|
||||
onValueChange,
|
||||
@ -85,6 +87,7 @@ export function RupiahInput({
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
name={name}
|
||||
id={id}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={displayValue}
|
||||
|
||||
23
resources/js/hooks/use-transaction-draft.ts
Normal file
23
resources/js/hooks/use-transaction-draft.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
45
resources/js/lib/transaction-draft.ts
Normal file
45
resources/js/lib/transaction-draft.ts
Normal 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);
|
||||
}
|
||||
134
resources/js/pages/admin/manage/transaction/columns.tsx
Normal file
134
resources/js/pages/admin/manage/transaction/columns.tsx
Normal 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[];
|
||||
};
|
||||
1042
resources/js/pages/admin/manage/transaction/create.tsx
Normal file
1042
resources/js/pages/admin/manage/transaction/create.tsx
Normal file
File diff suppressed because it is too large
Load Diff
977
resources/js/pages/admin/manage/transaction/edit.tsx
Normal file
977
resources/js/pages/admin/manage/transaction/edit.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
374
resources/js/pages/admin/manage/transaction/index.tsx
Normal file
374
resources/js/pages/admin/manage/transaction/index.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
resources/js/pages/admin/manage/transaction/transaction-card.tsx
Normal file
195
resources/js/pages/admin/manage/transaction/transaction-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Http\Controllers\Admin\Manage\CuttingController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
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\CustomerController;
|
||||
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('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('transactions', TransactionController::class)->except(['show'])->middleware('permission:transaction.view|transaction.create|transaction.update|transaction.delete');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user