feat: implement stock mutation management with filtering and pagination
This commit is contained in:
parent
0553324264
commit
fff815ae77
@ -75,6 +75,9 @@ enum Permission: string
|
|||||||
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
|
case PRODUCTS_TRANSFER_STOCK = 'products.transfer_stock';
|
||||||
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations';
|
case PRODUCTS_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations';
|
||||||
|
|
||||||
|
// Stock Mutations (global log)
|
||||||
|
case STOCK_MUTATIONS_VIEW = 'stock_mutations.view';
|
||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
case ORDERS_VIEW = 'orders.view';
|
case ORDERS_VIEW = 'orders.view';
|
||||||
case ORDERS_CREATE = 'orders.create';
|
case ORDERS_CREATE = 'orders.create';
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Manage;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\PaginatedRequest;
|
||||||
|
use App\Services\StockMutationService;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class StockMutationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private StockMutationService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(PaginatedRequest $request): Response
|
||||||
|
{
|
||||||
|
$filters = $request->only(['product_id', 'type', 'stock_quality', 'date_from', 'date_to']);
|
||||||
|
|
||||||
|
return Inertia::render('admin/manage/stock-mutation/index', [
|
||||||
|
'mutations' => $this->service->paginatedAll(
|
||||||
|
...$request->validatedWithDefaults(),
|
||||||
|
filters: $filters,
|
||||||
|
),
|
||||||
|
'filters' => $filters,
|
||||||
|
'filterOptions' => $this->service->getFilterOptions(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,10 +9,10 @@
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||||
use Spatie\Activitylog\Support\LogOptions;
|
|
||||||
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
use Spatie\Activitylog\Models\Concerns\LogsActivity;
|
||||||
|
use Spatie\Activitylog\Support\LogOptions;
|
||||||
|
|
||||||
#[Appends(['formatted_quantity', 'formatted_stock_after', 'formatted_stock_before'])]
|
#[Appends(['formatted_quantity', 'formatted_stock_after', 'formatted_stock_before', 'formatted_created_at'])]
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class StockMutation extends Model
|
class StockMutation extends Model
|
||||||
{
|
{
|
||||||
@ -56,6 +56,13 @@ protected function formattedStockBefore(): Attribute
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function formattedCreatedAt(): Attribute
|
||||||
|
{
|
||||||
|
return Attribute::make(
|
||||||
|
get: fn () => $this->created_at?->translatedFormat('d F Y, H:i'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function source(): MorphTo
|
public function source(): MorphTo
|
||||||
{
|
{
|
||||||
return $this->morphTo();
|
return $this->morphTo();
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class RestockService
|
class RestockService
|
||||||
@ -94,7 +95,7 @@ public function getForEdit(Restock $restock): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems(Restock $restock): \Illuminate\Support\Collection
|
public function getItems(Restock $restock): Collection
|
||||||
{
|
{
|
||||||
return $restock->restockItems()
|
return $restock->restockItems()
|
||||||
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
|
->select(['id', 'restock_id', 'product_variant_id', 'quantity', 'unit_price', 'subtotal'])
|
||||||
@ -139,7 +140,7 @@ public function store(array $data): Restock
|
|||||||
}
|
}
|
||||||
DB::table('restock_items')->insert($itemRows);
|
DB::table('restock_items')->insert($itemRows);
|
||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, 1);
|
$this->applyStock($data['items'], $stockType, 1, source: $restock, description: 'Restock stok masuk');
|
||||||
$this->syncPhoto($restock, $data);
|
$this->syncPhoto($restock, $data);
|
||||||
|
|
||||||
NotificationService::notify(
|
NotificationService::notify(
|
||||||
@ -159,7 +160,7 @@ public function update(Restock $restock, array $data): Restock
|
|||||||
$restock->load('restockItems');
|
$restock->load('restockItems');
|
||||||
|
|
||||||
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value);
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value, source: $restock, description: 'Pembatalan stok restock (revisi)');
|
||||||
});
|
});
|
||||||
|
|
||||||
$restock->restockItems()->delete();
|
$restock->restockItems()->delete();
|
||||||
@ -181,7 +182,7 @@ public function update(Restock $restock, array $data): Restock
|
|||||||
'stock_type' => $stockType,
|
'stock_type' => $stockType,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, 1);
|
$this->applyStock($data['items'], $stockType, 1, source: $restock, description: 'Restock stok masuk (revisi)');
|
||||||
$this->syncPhoto($restock, $data);
|
$this->syncPhoto($restock, $data);
|
||||||
|
|
||||||
return $restock;
|
return $restock;
|
||||||
@ -203,7 +204,7 @@ public function destroy(Restock $restock): bool
|
|||||||
$restock->load('restockItems');
|
$restock->load('restockItems');
|
||||||
|
|
||||||
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
$restock->restockItems->each(function (RestockItem $item) use ($restock) {
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value);
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, -1, $restock->stock_type->value, source: $restock, description: 'Pembatalan stok restock (dihapus)');
|
||||||
});
|
});
|
||||||
|
|
||||||
$restock->restockItems()->delete();
|
$restock->restockItems()->delete();
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
@ -131,6 +132,8 @@ public function verify(StokOpname $stokOpname, array $data): StokOpname
|
|||||||
abs($item->difference),
|
abs($item->difference),
|
||||||
$item->difference > 0 ? 1 : -1,
|
$item->difference > 0 ? 1 : -1,
|
||||||
$item->stock_quality->value,
|
$item->stock_quality->value,
|
||||||
|
source: $stokOpname,
|
||||||
|
description: 'Penyesuaian stok opname',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -212,7 +215,7 @@ public function getForEdit(StokOpname $stokOpname): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems(StokOpname $stokOpname): \Illuminate\Support\Collection
|
public function getItems(StokOpname $stokOpname): Collection
|
||||||
{
|
{
|
||||||
return $stokOpname->stokOpnameItems()
|
return $stokOpname->stokOpnameItems()
|
||||||
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes'])
|
->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes'])
|
||||||
|
|||||||
@ -9,24 +9,26 @@
|
|||||||
use App\Enums\PriceType;
|
use App\Enums\PriceType;
|
||||||
use App\Enums\ProductStockQuality;
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
|
use App\Models\CashAccount;
|
||||||
use App\Models\Customer;
|
use App\Models\Customer;
|
||||||
use App\Models\Order;
|
use App\Models\Order;
|
||||||
use App\Models\OrderItem;
|
use App\Models\OrderItem;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Concerns\HasStockAdjustment;
|
|
||||||
use App\Services\Concerns\HandlesCashTransactions;
|
use App\Services\Concerns\HandlesCashTransactions;
|
||||||
|
use App\Services\Concerns\HasStockAdjustment;
|
||||||
use App\Services\Concerns\LogsFormHistory;
|
use App\Services\Concerns\LogsFormHistory;
|
||||||
use App\Services\Concerns\RegistersMedia;
|
use App\Services\Concerns\RegistersMedia;
|
||||||
use App\Services\NotificationService;
|
use App\Services\NotificationService;
|
||||||
use App\Services\S3PresignedService;
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class TransactionService
|
class TransactionService
|
||||||
{
|
{
|
||||||
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
|
use HandlesCashTransactions, HasStockAdjustment, LogsFormHistory, RegistersMedia;
|
||||||
|
|
||||||
private const SELLING_PRICE_MAP = [
|
private const SELLING_PRICE_MAP = [
|
||||||
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
|
PriceType::DISTRIBUTOR->value => PriceType::DISTRIBUTOR,
|
||||||
@ -148,7 +150,7 @@ public function getForEdit(Order $order): array
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getItems(Order $order): \Illuminate\Support\Collection
|
public function getItems(Order $order): Collection
|
||||||
{
|
{
|
||||||
return $order->orderItems()
|
return $order->orderItems()
|
||||||
->select(['id', 'order_id', 'product_variant_id', 'stock_quality', 'quantity', 'unit_price', 'subtotal'])
|
->select(['id', 'order_id', 'product_variant_id', 'stock_quality', 'quantity', 'unit_price', 'subtotal'])
|
||||||
@ -251,7 +253,7 @@ public function store(array $data): Order
|
|||||||
}
|
}
|
||||||
DB::table('order_items')->insert($itemRows);
|
DB::table('order_items')->insert($itemRows);
|
||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, -1);
|
$this->applyStock($data['items'], $stockType, -1, source: $order, description: 'Penjualan - stok keluar');
|
||||||
|
|
||||||
if ($paymentType === PaymentType::CASH->value) {
|
if ($paymentType === PaymentType::CASH->value) {
|
||||||
$cashTransaction = $this->creditCash(
|
$cashTransaction = $this->creditCash(
|
||||||
@ -287,8 +289,8 @@ public function update(Order $order, array $data): Order
|
|||||||
$oldPaymentType = $order->payment_type;
|
$oldPaymentType = $order->payment_type;
|
||||||
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||||
|
|
||||||
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
|
$order->orderItems->each(function (OrderItem $item) use ($oldStockType, $order) {
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType, source: $order, description: 'Pembatalan stok transaksi (revisi)');
|
||||||
});
|
});
|
||||||
|
|
||||||
$order->orderItems()->delete();
|
$order->orderItems()->delete();
|
||||||
@ -356,7 +358,7 @@ public function update(Order $order, array $data): Order
|
|||||||
$order->refresh();
|
$order->refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->applyStock($data['items'], $stockType, -1);
|
$this->applyStock($data['items'], $stockType, -1, source: $order, description: 'Penjualan - stok keluar (revisi)');
|
||||||
|
|
||||||
if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) {
|
if ($oldPaymentType === PaymentType::CASH && $newPaymentType !== PaymentType::CASH->value) {
|
||||||
if ($order->cash_transaction_id) {
|
if ($order->cash_transaction_id) {
|
||||||
@ -376,7 +378,7 @@ public function update(Order $order, array $data): Order
|
|||||||
$difference = $totalAmount - $oldAmount;
|
$difference = $totalAmount - $oldAmount;
|
||||||
|
|
||||||
if ($difference !== 0) {
|
if ($difference !== 0) {
|
||||||
$cashAccount = \App\Models\CashAccount::firstOrFail();
|
$cashAccount = CashAccount::firstOrFail();
|
||||||
$newBalance = $cashAccount->balance + $difference;
|
$newBalance = $cashAccount->balance + $difference;
|
||||||
$cashAccount->update(['balance' => $newBalance]);
|
$cashAccount->update(['balance' => $newBalance]);
|
||||||
|
|
||||||
@ -418,8 +420,8 @@ public function destroy(Order $order): bool
|
|||||||
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
|
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
|
||||||
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||||
|
|
||||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
$order->orderItems->each(function (OrderItem $item) use ($stockType, $order) {
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType, source: $order, description: 'Pengembalian stok - transaksi dihapus');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,8 +473,10 @@ public function updateStatus(Order $order, string $status): Order
|
|||||||
$order->load('orderItems');
|
$order->load('orderItems');
|
||||||
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||||
|
|
||||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
$statusLabel = mb_strtolower(OrderStatus::from($status)->label());
|
||||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
|
||||||
|
$order->orderItems->each(function (OrderItem $item) use ($stockType, $order, $statusLabel) {
|
||||||
|
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType, source: $order, description: "Pengembalian stok - transaksi {$statusLabel}");
|
||||||
});
|
});
|
||||||
|
|
||||||
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
if ($order->payment_type === PaymentType::CASH && $order->cash_transaction_id) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Enums\ProductStockQuality;
|
use App\Enums\ProductStockQuality;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Services\StockMutationService;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@ -16,12 +17,14 @@ trait HasStockAdjustment
|
|||||||
ProductStockQuality::RETAIL->value => 'retail_stock',
|
ProductStockQuality::RETAIL->value => 'retail_stock',
|
||||||
];
|
];
|
||||||
|
|
||||||
private function adjustStock(Model $model, string $field, int $quantity, int $sign): void
|
private function adjustStock(Model $model, string $field, int $quantity, int $sign, ?Model $source = null, ?string $description = null): void
|
||||||
{
|
{
|
||||||
|
$before = (int) $model->{$field};
|
||||||
|
|
||||||
if ($sign > 0) {
|
if ($sign > 0) {
|
||||||
$model->increment($field, $quantity);
|
$model->increment($field, $quantity);
|
||||||
} else {
|
} else {
|
||||||
if ($model->{$field} < $quantity) {
|
if ($before < $quantity) {
|
||||||
$label = match ($field) {
|
$label = match ($field) {
|
||||||
'stock' => 'stok bagus',
|
'stock' => 'stok bagus',
|
||||||
'reject_stock' => 'stok reject',
|
'reject_stock' => 'stok reject',
|
||||||
@ -34,9 +37,20 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
|
|||||||
}
|
}
|
||||||
$model->decrement($field, $quantity);
|
$model->decrement($field, $quantity);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app(StockMutationService::class)->record(
|
||||||
|
model: $model,
|
||||||
|
type: $sign > 0 ? 'in' : 'out',
|
||||||
|
quantity: $sign * $quantity,
|
||||||
|
stockBefore: $before,
|
||||||
|
stockAfter: $before + ($sign * $quantity),
|
||||||
|
stockQuality: StockMutationService::qualityFromField($field),
|
||||||
|
description: $description ?? 'Perubahan stok',
|
||||||
|
source: $source,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType): void
|
private function adjustVariantStock(int $variantId, int $quantity, int $sign, string $stockType, ?Model $source = null, ?string $description = null): void
|
||||||
{
|
{
|
||||||
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
|
$field = self::QUALITY_STOCK_MAP[$stockType] ?? 'stock';
|
||||||
|
|
||||||
@ -45,14 +59,16 @@ private function adjustVariantStock(int $variantId, int $quantity, int $sign, st
|
|||||||
field: $field,
|
field: $field,
|
||||||
quantity: $quantity,
|
quantity: $quantity,
|
||||||
sign: $sign,
|
sign: $sign,
|
||||||
|
source: $source,
|
||||||
|
description: $description,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyStock(array $items, string $stockType, int $sign): void
|
private function applyStock(array $items, string $stockType, int $sign, ?Model $source = null, ?string $description = null): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($items, $stockType, $sign) {
|
DB::transaction(function () use ($items, $stockType, $sign, $source, $description) {
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType);
|
$this->adjustVariantStock($item['product_variant_id'], $item['quantity'], $sign, $stockType, $source, $description);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Product;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
use App\Models\StockMutation;
|
use App\Models\StockMutation;
|
||||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||||
@ -27,6 +28,86 @@ public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
|
|||||||
->paginate($perPage);
|
->paginate($perPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function paginatedAll(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
return StockMutation::query()
|
||||||
|
->where('stockable_type', ProductVariant::class)
|
||||||
|
->with([
|
||||||
|
'user:id,username,email',
|
||||||
|
'user.userProfile:id,user_id,full_name',
|
||||||
|
'stockable' => fn ($q) => $q->withTrashed()->select('id', 'product_id', 'name'),
|
||||||
|
'stockable.product' => fn ($q) => $q->withTrashed()->select('id', 'name'),
|
||||||
|
])
|
||||||
|
->when($search, function ($q) use ($search) {
|
||||||
|
$q->where(function ($sq) use ($search) {
|
||||||
|
$sq->whereHasMorph('stockable', [ProductVariant::class], function ($vq) use ($search) {
|
||||||
|
$vq->withTrashed()
|
||||||
|
->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhereHas('product', fn ($pq) => $pq->withTrashed()->where('name', 'like', "%{$search}%"));
|
||||||
|
})->orWhere('description', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when($filters['product_id'] ?? null, function ($q, $productId) {
|
||||||
|
$q->whereHasMorph('stockable', [ProductVariant::class], fn ($vq) => $vq->withTrashed()->where('product_id', $productId));
|
||||||
|
})
|
||||||
|
->when($filters['type'] ?? null, fn ($q, $type) => $q->where('type', $type))
|
||||||
|
->when($filters['stock_quality'] ?? null, fn ($q, $quality) => $q->where('stock_quality', $quality))
|
||||||
|
->when($filters['date_from'] ?? null, fn ($q, $dateFrom) => $q->whereDate('created_at', '>=', $dateFrom))
|
||||||
|
->when($filters['date_to'] ?? null, fn ($q, $dateTo) => $q->whereDate('created_at', '<=', $dateTo))
|
||||||
|
->orderBy($sort, $direction)
|
||||||
|
->paginate($perPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFilterOptions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'products' => Product::query()
|
||||||
|
->select(['id', 'name'])
|
||||||
|
->whereHas('productVariants.stockMutations')
|
||||||
|
->orderBy('name')
|
||||||
|
->get(),
|
||||||
|
'typeOptions' => [
|
||||||
|
['value' => 'in', 'label' => 'Stok Masuk'],
|
||||||
|
['value' => 'out', 'label' => 'Stok Keluar'],
|
||||||
|
],
|
||||||
|
'stockQualityOptions' => [
|
||||||
|
['value' => 'good', 'label' => 'Bagus'],
|
||||||
|
['value' => 'reject', 'label' => 'Reject'],
|
||||||
|
['value' => 'retail', 'label' => 'Ecer'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function qualityFromField(string $field): string
|
||||||
|
{
|
||||||
|
return self::QUALITY_MAP[$field] ?? 'good';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function record(
|
||||||
|
Model $model,
|
||||||
|
string $type,
|
||||||
|
int $quantity,
|
||||||
|
int $stockBefore,
|
||||||
|
int $stockAfter,
|
||||||
|
string $stockQuality,
|
||||||
|
string $description,
|
||||||
|
?Model $source = null,
|
||||||
|
): StockMutation {
|
||||||
|
return StockMutation::create([
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'stockable_type' => get_class($model),
|
||||||
|
'stockable_id' => $model->id,
|
||||||
|
'type' => $type,
|
||||||
|
'quantity' => $quantity,
|
||||||
|
'stock_before' => $stockBefore,
|
||||||
|
'stock_after' => $stockAfter,
|
||||||
|
'stock_quality' => $stockQuality,
|
||||||
|
'description' => $description,
|
||||||
|
'source_type' => $source ? get_class($source) : null,
|
||||||
|
'source_id' => $source?->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
|
public function recordInitial(Model $model, array $stockData, string $description = 'Stok awal'): void
|
||||||
{
|
{
|
||||||
$userId = auth()->id();
|
$userId = auth()->id();
|
||||||
|
|||||||
@ -23,6 +23,7 @@ public function run(): void
|
|||||||
'customers' => ['view', 'create', 'update', 'delete'],
|
'customers' => ['view', 'create', 'update', 'delete'],
|
||||||
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'],
|
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'],
|
||||||
'stocks' => ['view'],
|
'stocks' => ['view'],
|
||||||
|
'stock_mutations' => ['view'],
|
||||||
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
|
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
|
||||||
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
|
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
|
||||||
'cash' => ['view', 'deposit', 'withdraw', 'update', 'delete'],
|
'cash' => ['view', 'deposit', 'withdraw', 'update', 'delete'],
|
||||||
@ -97,6 +98,8 @@ public function run(): void
|
|||||||
|
|
||||||
'stok_opnames.view',
|
'stok_opnames.view',
|
||||||
|
|
||||||
|
'stock_mutations.view',
|
||||||
|
|
||||||
'attendances.view',
|
'attendances.view',
|
||||||
'attendances.create',
|
'attendances.create',
|
||||||
'attendances.delete',
|
'attendances.delete',
|
||||||
@ -399,7 +402,7 @@ public function run(): void
|
|||||||
|
|
||||||
'payroll.view',
|
'payroll.view',
|
||||||
|
|
||||||
'activity_logs.view'
|
'activity_logs.view',
|
||||||
], true);
|
], true);
|
||||||
})),
|
})),
|
||||||
|
|
||||||
@ -447,6 +450,8 @@ public function run(): void
|
|||||||
'stok_opnames.delete',
|
'stok_opnames.delete',
|
||||||
'stok_opnames.submit',
|
'stok_opnames.submit',
|
||||||
|
|
||||||
|
'stock_mutations.view',
|
||||||
|
|
||||||
'employee_advances.view',
|
'employee_advances.view',
|
||||||
'employee_advances.create',
|
'employee_advances.create',
|
||||||
'employee_advances.update',
|
'employee_advances.update',
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
HandCoins,
|
HandCoins,
|
||||||
|
History,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Package,
|
Package,
|
||||||
Receipt,
|
Receipt,
|
||||||
@ -52,6 +53,7 @@ import { index as cuttingsIndex } from '@/routes/admin/manage/cuttings';
|
|||||||
import { index as invoicesIndex } from '@/routes/admin/manage/invoices';
|
import { index as invoicesIndex } from '@/routes/admin/manage/invoices';
|
||||||
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 stockMutationsIndex } from '@/routes/admin/manage/stock-mutations';
|
||||||
import { index as stokOpnamesIndex } from '@/routes/admin/manage/stok-opnames';
|
import { index as stokOpnamesIndex } from '@/routes/admin/manage/stok-opnames';
|
||||||
import { index as transactionsIndex } from '@/routes/admin/manage/transactions';
|
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';
|
||||||
@ -90,6 +92,7 @@ const kelolaItems: NavMenuItem[] = [
|
|||||||
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' },
|
{ title: 'Transaksi', href: transactionsIndex.url(), icon: ShoppingCart, permission: 'orders.view' },
|
||||||
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' },
|
{ title: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.view' },
|
||||||
{ title: 'Stok Opname', href: stokOpnamesIndex.url(), icon: ClipboardCheck, permission: 'stok_opnames.view' },
|
{ title: 'Stok Opname', href: stokOpnamesIndex.url(), icon: ClipboardCheck, permission: 'stok_opnames.view' },
|
||||||
|
{ title: 'Riwayat Stok', href: stockMutationsIndex.url(), icon: History, permission: 'stock_mutations.view' },
|
||||||
{ title: 'Invoice', href: invoicesIndex.url(), icon: Receipt, permission: 'invoices.view' },
|
{ title: 'Invoice', href: invoicesIndex.url(), icon: Receipt, permission: 'invoices.view' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
178
resources/js/pages/admin/manage/stock-mutation/columns.tsx
Normal file
178
resources/js/pages/admin/manage/stock-mutation/columns.tsx
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||||
|
|
||||||
|
export type StockMutation = {
|
||||||
|
id: number;
|
||||||
|
type: 'in' | 'out';
|
||||||
|
quantity: number;
|
||||||
|
formatted_quantity: string;
|
||||||
|
stock_before: number;
|
||||||
|
stock_after: number;
|
||||||
|
formatted_stock_before: string;
|
||||||
|
formatted_stock_after: string;
|
||||||
|
stock_quality: 'good' | 'reject' | 'retail';
|
||||||
|
description: string | null;
|
||||||
|
created_at: string;
|
||||||
|
formatted_created_at: string;
|
||||||
|
stockable: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
product: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
} | null;
|
||||||
|
user: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
user_profile?: {
|
||||||
|
full_name: string;
|
||||||
|
};
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getQualityLabel(quality: string): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
good: 'Bagus',
|
||||||
|
reject: 'Reject',
|
||||||
|
retail: 'Ecer',
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[quality] ?? quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getQualityColor(quality: string): string {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
good: 'bg-green-100 text-green-800',
|
||||||
|
reject: 'bg-red-100 text-red-800',
|
||||||
|
retail: 'bg-blue-100 text-blue-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
return colors[quality] ?? 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<StockMutation>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'formatted_created_at',
|
||||||
|
header: () => <span>Tanggal</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="whitespace-nowrap">
|
||||||
|
{row.getValue('formatted_created_at') as string}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'product',
|
||||||
|
header: () => <span>Produk</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const stockable = row.original.stockable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">
|
||||||
|
{stockable?.product?.name ?? '-'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{stockable?.name ?? '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
header: () => <span>Tipe</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mutation = row.original;
|
||||||
|
const isIn = mutation.type === 'in';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-xs font-medium ${
|
||||||
|
isIn
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-red-100 text-red-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isIn ? (
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{isIn ? 'Masuk' : 'Keluar'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stock_quality',
|
||||||
|
header: () => <span>Kualitas</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const quality = row.original.stock_quality;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium ${getQualityColor(quality)}`}
|
||||||
|
>
|
||||||
|
{getQualityLabel(quality)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quantity',
|
||||||
|
header: () => <span>Qty</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mutation = row.original;
|
||||||
|
const isPositive = mutation.quantity > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isPositive
|
||||||
|
? 'font-medium text-green-600'
|
||||||
|
: 'font-medium text-red-600'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isPositive ? '+' : ''}
|
||||||
|
{mutation.formatted_quantity}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stock_change',
|
||||||
|
header: () => <span>Stok</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const mutation = row.original;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="whitespace-nowrap text-muted-foreground">
|
||||||
|
{mutation.formatted_stock_before} →{' '}
|
||||||
|
{mutation.formatted_stock_after}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'description',
|
||||||
|
header: () => <span>Keterangan</span>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="block max-w-[220px]">
|
||||||
|
{(row.getValue('description') as string | null) ?? '-'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user',
|
||||||
|
header: () => <span>Oleh</span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const user = row.original.user;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span>{user?.user_profile?.full_name ?? user?.username ?? '-'}</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
240
resources/js/pages/admin/manage/stock-mutation/index.tsx
Normal file
240
resources/js/pages/admin/manage/stock-mutation/index.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { Head } from '@inertiajs/react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { DataTable, FilterPopover } from '@/components/data-display';
|
||||||
|
import { DatePicker } from '@/components/inputs';
|
||||||
|
import { PageHeader } from '@/components/layout';
|
||||||
|
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 { index as stockMutationsIndex } from '@/routes/admin/manage/stock-mutations';
|
||||||
|
import { columns } from './columns';
|
||||||
|
import type { StockMutation } from './columns';
|
||||||
|
|
||||||
|
type FilterOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SelectOption = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mutations: {
|
||||||
|
data: StockMutation[];
|
||||||
|
current_page: number;
|
||||||
|
last_page: number;
|
||||||
|
per_page: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
product_id?: string;
|
||||||
|
type?: string;
|
||||||
|
stock_quality?: string;
|
||||||
|
date_from?: string;
|
||||||
|
date_to?: string;
|
||||||
|
};
|
||||||
|
filterOptions: {
|
||||||
|
products: FilterOption[];
|
||||||
|
typeOptions: SelectOption[];
|
||||||
|
stockQualityOptions: SelectOption[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StockMutationIndex({
|
||||||
|
mutations,
|
||||||
|
filters,
|
||||||
|
filterOptions,
|
||||||
|
}: Props) {
|
||||||
|
const pagination = {
|
||||||
|
current_page: mutations.current_page,
|
||||||
|
last_page: mutations.last_page,
|
||||||
|
per_page: mutations.per_page,
|
||||||
|
total: mutations.total,
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
filterOpen,
|
||||||
|
setFilterOpen,
|
||||||
|
handlePageChange,
|
||||||
|
handlePerPageChange,
|
||||||
|
handleSearchChange,
|
||||||
|
applyFilter,
|
||||||
|
clearFilters,
|
||||||
|
} = useServerTable({
|
||||||
|
route: () => stockMutationsIndex.url(),
|
||||||
|
pagination,
|
||||||
|
filters,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedProduct = useMemo(
|
||||||
|
() =>
|
||||||
|
filterOptions.products.find(
|
||||||
|
(p) => String(p.id) === filters.product_id,
|
||||||
|
) ?? null,
|
||||||
|
[filterOptions.products, filters.product_id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filterToolbar = (
|
||||||
|
<FilterPopover
|
||||||
|
open={filterOpen}
|
||||||
|
onOpenChange={setFilterOpen}
|
||||||
|
filters={filters}
|
||||||
|
hasActiveFilters={
|
||||||
|
Boolean(filters.product_id) ||
|
||||||
|
Boolean(filters.type) ||
|
||||||
|
Boolean(filters.stock_quality) ||
|
||||||
|
Boolean(filters.date_from) ||
|
||||||
|
Boolean(filters.date_to)
|
||||||
|
}
|
||||||
|
onClear={clearFilters}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Produk</label>
|
||||||
|
<Combobox
|
||||||
|
items={filterOptions.products}
|
||||||
|
itemToStringLabel={(p) => p.name}
|
||||||
|
value={selectedProduct}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
applyFilter('product_id', value ? String(value.id) : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ComboboxInput
|
||||||
|
placeholder="Pilih produk..."
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<ComboboxContent>
|
||||||
|
<ComboboxEmpty>
|
||||||
|
Tidak ada produk ditemukan.
|
||||||
|
</ComboboxEmpty>
|
||||||
|
<ComboboxList>
|
||||||
|
{(product) => (
|
||||||
|
<ComboboxItem value={product}>
|
||||||
|
{product.name}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
</ComboboxList>
|
||||||
|
</ComboboxContent>
|
||||||
|
</Combobox>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Tipe</label>
|
||||||
|
<Select
|
||||||
|
value={filters.type ?? 'all'}
|
||||||
|
onValueChange={(value) => applyFilter('type', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Semua Tipe" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Tipe</SelectItem>
|
||||||
|
{filterOptions.typeOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Kualitas
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={filters.stock_quality ?? 'all'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
applyFilter('stock_quality', value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue placeholder="Semua Kualitas" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Semua Kualitas</SelectItem>
|
||||||
|
{filterOptions.stockQualityOptions.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Dari Tanggal
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
value={filters.date_from ?? null}
|
||||||
|
onChange={(date) =>
|
||||||
|
applyFilter(
|
||||||
|
'date_from',
|
||||||
|
date ? format(date, 'yyyy-MM-dd') : '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Pilih tanggal mulai"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">
|
||||||
|
Sampai Tanggal
|
||||||
|
</label>
|
||||||
|
<DatePicker
|
||||||
|
value={filters.date_to ?? null}
|
||||||
|
onChange={(date) =>
|
||||||
|
applyFilter(
|
||||||
|
'date_to',
|
||||||
|
date ? format(date, 'yyyy-MM-dd') : '',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
placeholder="Pilih tanggal akhir"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</FilterPopover>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head title="Riwayat Stok" />
|
||||||
|
|
||||||
|
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Riwayat Stok"
|
||||||
|
description={
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Catatan seluruh pergerakan stok produk: restock, penjualan, stok opname, transfer kualitas, dan penyesuaian varian.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={mutations.data}
|
||||||
|
searchKey="description"
|
||||||
|
emptyText="Belum ada riwayat mutasi stok."
|
||||||
|
pagination={pagination}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
onPerPageChange={handlePerPageChange}
|
||||||
|
onSearchChange={handleSearchChange}
|
||||||
|
searchValue={search}
|
||||||
|
toolbar={filterToolbar}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -14,6 +14,7 @@
|
|||||||
use App\Http\Controllers\Admin\Manage\InvoiceController;
|
use App\Http\Controllers\Admin\Manage\InvoiceController;
|
||||||
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\StockMutationController as ManageStockMutationController;
|
||||||
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
use App\Http\Controllers\Admin\Manage\StokOpnameController;
|
||||||
use App\Http\Controllers\Admin\Manage\TransactionController;
|
use App\Http\Controllers\Admin\Manage\TransactionController;
|
||||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||||
@ -94,6 +95,8 @@
|
|||||||
Route::patch('stok-opnames/{stokOpname}/reject', [StokOpnameController::class, 'reject'])->name('stok-opnames.reject')->middleware('permission:stok_opnames.verify');
|
Route::patch('stok-opnames/{stokOpname}/reject', [StokOpnameController::class, 'reject'])->name('stok-opnames.reject')->middleware('permission:stok_opnames.verify');
|
||||||
Route::patch('stok-opnames/{stokOpname}/cancel', [StokOpnameController::class, 'cancel'])->name('stok-opnames.cancel')->middleware('permission:stok_opnames.update');
|
Route::patch('stok-opnames/{stokOpname}/cancel', [StokOpnameController::class, 'cancel'])->name('stok-opnames.cancel')->middleware('permission:stok_opnames.update');
|
||||||
|
|
||||||
|
Route::get('stock-mutations', [ManageStockMutationController::class, 'index'])->name('stock-mutations.index')->middleware('permission:stock_mutations.view');
|
||||||
|
|
||||||
Route::resource('invoices', InvoiceController::class)->except(['show'])->middleware('permission:invoices.view|invoices.create|invoices.update|invoices.delete');
|
Route::resource('invoices', InvoiceController::class)->except(['show'])->middleware('permission:invoices.view|invoices.create|invoices.update|invoices.delete');
|
||||||
Route::get('invoices/{invoice}/print', [InvoiceController::class, 'print'])->name('invoices.print')->middleware('permission:invoices.view');
|
Route::get('invoices/{invoice}/print', [InvoiceController::class, 'print'])->name('invoices.print')->middleware('permission:invoices.view');
|
||||||
});
|
});
|
||||||
@ -160,4 +163,4 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__ . '/settings.php';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user