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_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
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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\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
|
||||||
{
|
{
|
||||||
|
|||||||
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' => [
|
'mariadb' => [
|
||||||
'driver' => 'mariadb',
|
'driver' => 'mariadb',
|
||||||
'url' => env('DB_URL'),
|
'url' => env('DB_URL'),
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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'],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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 },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
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\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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user