feat: implement stock mutation management with filtering and pagination

This commit is contained in:
Yoga Pangestu 2026-09-03 19:46:05 +07:00
parent 0553324264
commit fff815ae77
13 changed files with 602 additions and 28 deletions

View File

@ -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';

View File

@ -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(),
]);
}
}

View File

@ -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();

View File

@ -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();

View File

@ -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'])

View File

@ -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) {

View File

@ -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);
}
});
}

View File

@ -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();

View File

@ -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',

View File

@ -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' },
];

View 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>
);
},
},
];

View 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>
</>
);
}

View File

@ -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';