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_VIEW_STOCK_MUTATIONS = 'products.view_stock_mutations';
|
||||
|
||||
// Stock Mutations (global log)
|
||||
case STOCK_MUTATIONS_VIEW = 'stock_mutations.view';
|
||||
|
||||
// Orders
|
||||
case ORDERS_VIEW = 'orders.view';
|
||||
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\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Spatie\Activitylog\Support\LogOptions;
|
||||
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'])]
|
||||
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
|
||||
{
|
||||
return $this->morphTo();
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
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()
|
||||
->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);
|
||||
|
||||
$this->applyStock($data['items'], $stockType, 1);
|
||||
$this->applyStock($data['items'], $stockType, 1, source: $restock, description: 'Restock stok masuk');
|
||||
$this->syncPhoto($restock, $data);
|
||||
|
||||
NotificationService::notify(
|
||||
@ -159,7 +160,7 @@ public function update(Restock $restock, array $data): Restock
|
||||
$restock->load('restockItems');
|
||||
|
||||
$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();
|
||||
@ -181,7 +182,7 @@ public function update(Restock $restock, array $data): Restock
|
||||
'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);
|
||||
|
||||
return $restock;
|
||||
@ -203,7 +204,7 @@ public function destroy(Restock $restock): bool
|
||||
$restock->load('restockItems');
|
||||
|
||||
$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();
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@ -131,6 +132,8 @@ public function verify(StokOpname $stokOpname, array $data): StokOpname
|
||||
abs($item->difference),
|
||||
$item->difference > 0 ? 1 : -1,
|
||||
$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()
|
||||
->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\ProductStockQuality;
|
||||
use App\Enums\Role;
|
||||
use App\Models\CashAccount;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Order;
|
||||
use App\Models\OrderItem;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\User;
|
||||
use App\Services\Concerns\HasStockAdjustment;
|
||||
use App\Services\Concerns\HandlesCashTransactions;
|
||||
use App\Services\Concerns\HasStockAdjustment;
|
||||
use App\Services\Concerns\LogsFormHistory;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransactionService
|
||||
{
|
||||
use HasStockAdjustment, HandlesCashTransactions, LogsFormHistory, RegistersMedia;
|
||||
use HandlesCashTransactions, HasStockAdjustment, LogsFormHistory, RegistersMedia;
|
||||
|
||||
private const SELLING_PRICE_MAP = [
|
||||
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()
|
||||
->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);
|
||||
|
||||
$this->applyStock($data['items'], $stockType, -1);
|
||||
$this->applyStock($data['items'], $stockType, -1, source: $order, description: 'Penjualan - stok keluar');
|
||||
|
||||
if ($paymentType === PaymentType::CASH->value) {
|
||||
$cashTransaction = $this->creditCash(
|
||||
@ -287,8 +289,8 @@ public function update(Order $order, array $data): Order
|
||||
$oldPaymentType = $order->payment_type;
|
||||
$oldStockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||
|
||||
$order->orderItems->each(function (OrderItem $item) use ($oldStockType) {
|
||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType);
|
||||
$order->orderItems->each(function (OrderItem $item) use ($oldStockType, $order) {
|
||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $oldStockType, source: $order, description: 'Pembatalan stok transaksi (revisi)');
|
||||
});
|
||||
|
||||
$order->orderItems()->delete();
|
||||
@ -356,7 +358,7 @@ public function update(Order $order, array $data): Order
|
||||
$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 ($order->cash_transaction_id) {
|
||||
@ -376,7 +378,7 @@ public function update(Order $order, array $data): Order
|
||||
$difference = $totalAmount - $oldAmount;
|
||||
|
||||
if ($difference !== 0) {
|
||||
$cashAccount = \App\Models\CashAccount::firstOrFail();
|
||||
$cashAccount = CashAccount::firstOrFail();
|
||||
$newBalance = $cashAccount->balance + $difference;
|
||||
$cashAccount->update(['balance' => $newBalance]);
|
||||
|
||||
@ -418,8 +420,8 @@ public function destroy(Order $order): bool
|
||||
if (! in_array($order->status, [OrderStatus::CANCELLED, OrderStatus::REFUNDED])) {
|
||||
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||
|
||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
||||
$order->orderItems->each(function (OrderItem $item) use ($stockType, $order) {
|
||||
$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');
|
||||
$stockType = $order->orderItems->first()?->stock_quality?->value ?? ProductStockQuality::GOOD->value;
|
||||
|
||||
$order->orderItems->each(function (OrderItem $item) use ($stockType) {
|
||||
$this->adjustVariantStock($item->product_variant_id, $item->quantity, 1, $stockType);
|
||||
$statusLabel = mb_strtolower(OrderStatus::from($status)->label());
|
||||
|
||||
$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) {
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
use App\Enums\ProductStockQuality;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Services\StockMutationService;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@ -16,12 +17,14 @@ trait HasStockAdjustment
|
||||
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) {
|
||||
$model->increment($field, $quantity);
|
||||
} else {
|
||||
if ($model->{$field} < $quantity) {
|
||||
if ($before < $quantity) {
|
||||
$label = match ($field) {
|
||||
'stock' => 'stok bagus',
|
||||
'reject_stock' => 'stok reject',
|
||||
@ -34,9 +37,20 @@ private function adjustStock(Model $model, string $field, int $quantity, int $si
|
||||
}
|
||||
$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';
|
||||
|
||||
@ -45,14 +59,16 @@ private function adjustVariantStock(int $variantId, int $quantity, int $sign, st
|
||||
field: $field,
|
||||
quantity: $quantity,
|
||||
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) {
|
||||
$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;
|
||||
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductVariant;
|
||||
use App\Models\StockMutation;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@ -27,6 +28,86 @@ public function paginated(Model $model, int $perPage = 20): LengthAwarePaginator
|
||||
->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
|
||||
{
|
||||
$userId = auth()->id();
|
||||
|
||||
@ -23,6 +23,7 @@ public function run(): void
|
||||
'customers' => ['view', 'create', 'update', 'delete'],
|
||||
'products' => ['view', 'create', 'update', 'delete', 'toggle_status', 'toggle_featured', 'transfer_stock', 'view_stock_mutations'],
|
||||
'stocks' => ['view'],
|
||||
'stock_mutations' => ['view'],
|
||||
'orders' => ['view', 'create', 'update', 'delete', 'send', 'complete', 'cancel'],
|
||||
'cuttings' => ['view', 'create', 'update', 'delete', 'complete'],
|
||||
'cash' => ['view', 'deposit', 'withdraw', 'update', 'delete'],
|
||||
@ -97,6 +98,8 @@ public function run(): void
|
||||
|
||||
'stok_opnames.view',
|
||||
|
||||
'stock_mutations.view',
|
||||
|
||||
'attendances.view',
|
||||
'attendances.create',
|
||||
'attendances.delete',
|
||||
@ -399,7 +402,7 @@ public function run(): void
|
||||
|
||||
'payroll.view',
|
||||
|
||||
'activity_logs.view'
|
||||
'activity_logs.view',
|
||||
], true);
|
||||
})),
|
||||
|
||||
@ -447,6 +450,8 @@ public function run(): void
|
||||
'stok_opnames.delete',
|
||||
'stok_opnames.submit',
|
||||
|
||||
'stock_mutations.view',
|
||||
|
||||
'employee_advances.view',
|
||||
'employee_advances.create',
|
||||
'employee_advances.update',
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
ClipboardCheck,
|
||||
DollarSign,
|
||||
HandCoins,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
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 purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
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 transactionsIndex } from '@/routes/admin/manage/transactions';
|
||||
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: 'Restock', href: restocksIndex.url(), icon: RefreshCw, permission: 'restocks.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' },
|
||||
];
|
||||
|
||||
|
||||
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\PurchaseController;
|
||||
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\TransactionController;
|
||||
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}/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::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