From fff815ae77663debb380ab3f8ba2aef578f15b11 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Thu, 3 Sep 2026 19:46:05 +0700 Subject: [PATCH] feat: implement stock mutation management with filtering and pagination --- app/Enums/Permission.php | 3 + .../Admin/Manage/StockMutationController.php | 30 +++ app/Models/StockMutation.php | 11 +- app/Services/Admin/Manage/RestockService.php | 11 +- .../Admin/Manage/StokOpnameService.php | 5 +- .../Admin/Manage/TransactionService.php | 28 +- app/Services/Concerns/HasStockAdjustment.php | 28 +- app/Services/StockMutationService.php | 81 ++++++ database/seeders/RolePermissionSeeder.php | 7 +- .../js/components/layout/app-sidebar.tsx | 3 + .../admin/manage/stock-mutation/columns.tsx | 178 +++++++++++++ .../admin/manage/stock-mutation/index.tsx | 240 ++++++++++++++++++ routes/web.php | 5 +- 13 files changed, 602 insertions(+), 28 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/StockMutationController.php create mode 100644 resources/js/pages/admin/manage/stock-mutation/columns.tsx create mode 100644 resources/js/pages/admin/manage/stock-mutation/index.tsx diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 2621670..2b6ac72 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -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'; diff --git a/app/Http/Controllers/Admin/Manage/StockMutationController.php b/app/Http/Controllers/Admin/Manage/StockMutationController.php new file mode 100644 index 0000000..e7fc5ef --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/StockMutationController.php @@ -0,0 +1,30 @@ +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(), + ]); + } +} diff --git a/app/Models/StockMutation.php b/app/Models/StockMutation.php index 3c2e40c..3bfbcb6 100644 --- a/app/Models/StockMutation.php +++ b/app/Models/StockMutation.php @@ -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(); diff --git a/app/Services/Admin/Manage/RestockService.php b/app/Services/Admin/Manage/RestockService.php index fc2a011..f7f0fdd 100644 --- a/app/Services/Admin/Manage/RestockService.php +++ b/app/Services/Admin/Manage/RestockService.php @@ -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(); diff --git a/app/Services/Admin/Manage/StokOpnameService.php b/app/Services/Admin/Manage/StokOpnameService.php index c0b9c3f..148dacf 100644 --- a/app/Services/Admin/Manage/StokOpnameService.php +++ b/app/Services/Admin/Manage/StokOpnameService.php @@ -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']) diff --git a/app/Services/Admin/Manage/TransactionService.php b/app/Services/Admin/Manage/TransactionService.php index cfc5a43..2146006 100644 --- a/app/Services/Admin/Manage/TransactionService.php +++ b/app/Services/Admin/Manage/TransactionService.php @@ -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) { diff --git a/app/Services/Concerns/HasStockAdjustment.php b/app/Services/Concerns/HasStockAdjustment.php index fa2ed0a..406314d 100644 --- a/app/Services/Concerns/HasStockAdjustment.php +++ b/app/Services/Concerns/HasStockAdjustment.php @@ -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); } }); } diff --git a/app/Services/StockMutationService.php b/app/Services/StockMutationService.php index 0301289..80702c5 100644 --- a/app/Services/StockMutationService.php +++ b/app/Services/StockMutationService.php @@ -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(); diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 1513994..2ba4e07 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -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', diff --git a/resources/js/components/layout/app-sidebar.tsx b/resources/js/components/layout/app-sidebar.tsx index 19c4fed..5cbf33f 100644 --- a/resources/js/components/layout/app-sidebar.tsx +++ b/resources/js/components/layout/app-sidebar.tsx @@ -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' }, ]; diff --git a/resources/js/pages/admin/manage/stock-mutation/columns.tsx b/resources/js/pages/admin/manage/stock-mutation/columns.tsx new file mode 100644 index 0000000..62b6300 --- /dev/null +++ b/resources/js/pages/admin/manage/stock-mutation/columns.tsx @@ -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 = { + good: 'Bagus', + reject: 'Reject', + retail: 'Ecer', + }; + + return labels[quality] ?? quality; +} + +function getQualityColor(quality: string): string { + const colors: Record = { + 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[] = [ + { + accessorKey: 'formatted_created_at', + header: () => Tanggal, + cell: ({ row }) => ( + + {row.getValue('formatted_created_at') as string} + + ), + }, + { + id: 'product', + header: () => Produk, + cell: ({ row }) => { + const stockable = row.original.stockable; + + return ( +
+ + {stockable?.product?.name ?? '-'} + + + {stockable?.name ?? '-'} + +
+ ); + }, + }, + { + id: 'type', + header: () => Tipe, + cell: ({ row }) => { + const mutation = row.original; + const isIn = mutation.type === 'in'; + + return ( + + {isIn ? ( + + ) : ( + + )} + {isIn ? 'Masuk' : 'Keluar'} + + ); + }, + }, + { + id: 'stock_quality', + header: () => Kualitas, + cell: ({ row }) => { + const quality = row.original.stock_quality; + + return ( + + {getQualityLabel(quality)} + + ); + }, + }, + { + id: 'quantity', + header: () => Qty, + cell: ({ row }) => { + const mutation = row.original; + const isPositive = mutation.quantity > 0; + + return ( + + {isPositive ? '+' : ''} + {mutation.formatted_quantity} + + ); + }, + }, + { + id: 'stock_change', + header: () => Stok, + cell: ({ row }) => { + const mutation = row.original; + + return ( + + {mutation.formatted_stock_before} →{' '} + {mutation.formatted_stock_after} + + ); + }, + }, + { + accessorKey: 'description', + header: () => Keterangan, + cell: ({ row }) => ( + + {(row.getValue('description') as string | null) ?? '-'} + + ), + }, + { + id: 'user', + header: () => Oleh, + cell: ({ row }) => { + const user = row.original.user; + + return ( + {user?.user_profile?.full_name ?? user?.username ?? '-'} + ); + }, + }, +]; diff --git a/resources/js/pages/admin/manage/stock-mutation/index.tsx b/resources/js/pages/admin/manage/stock-mutation/index.tsx new file mode 100644 index 0000000..02fa426 --- /dev/null +++ b/resources/js/pages/admin/manage/stock-mutation/index.tsx @@ -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 = ( + +
+ + p.name} + value={selectedProduct} + onValueChange={(value) => + applyFilter('product_id', value ? String(value.id) : '') + } + > + + + + Tidak ada produk ditemukan. + + + {(product) => ( + + {product.name} + + )} + + + +
+
+ + +
+
+ + +
+
+ + + applyFilter( + 'date_from', + date ? format(date, 'yyyy-MM-dd') : '', + ) + } + placeholder="Pilih tanggal mulai" + /> +
+
+ + + applyFilter( + 'date_to', + date ? format(date, 'yyyy-MM-dd') : '', + ) + } + placeholder="Pilih tanggal akhir" + /> +
+
+ ); + + return ( + <> + + +
+ + Catatan seluruh pergerakan stok produk: restock, penjualan, stok opname, transfer kualitas, dan penyesuaian varian. +

+ } + /> + + +
+ + ); +} diff --git a/routes/web.php b/routes/web.php index 4d54af9..d21891b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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';