diff --git a/.ai/CONTEXT.md b/.ai/CONTEXT.md index a6735b6..1d31db4 100644 --- a/.ai/CONTEXT.md +++ b/.ai/CONTEXT.md @@ -65,7 +65,7 @@ ## Module Overview | 7 | Cutting | `admin/manage/cuttings` | `Admin/Manage/CuttingController` | `Admin/Manage/CuttingService` | | 8 | Transaksi | `admin/manage/transactions` | `Admin/Manage/TransactionController` | `Admin/Manage/TransactionService` | | 9 | Restock | `admin/manage/restocks` | `Admin/Manage/RestockController` | `Admin/Manage/RestockService` | -| 10 | Stok Opname | `admin/manage/stok-opnames` | (via StockMutationController) | — | +| 10 | Stok Opname | `admin/manage/stok-opnames` | `Admin/Manage/StokOpnameController` | `Admin/Manage/StokOpnameService` | | 11 | Kas Toko | `admin/finance/cash-accounts` | `Admin/Finance/CashAccountController` | `Admin/Finance/Cash/CashAccountService`, `Admin/Finance/Cash/CashTransactionService` | | 12 | Pengeluaran | `admin/finance/expenses` | `Admin/Finance/ExpenseController` | `Admin/Finance/ExpenseService` | | 13 | Kasbon | `admin/finance/employee-advances` | `Admin/Finance/EmployeeAdvanceController` | `Admin/Finance/EmployeeAdvanceService` | diff --git a/app/Http/Controllers/Admin/Manage/StokOpnameController.php b/app/Http/Controllers/Admin/Manage/StokOpnameController.php new file mode 100644 index 0000000..1a9fdc5 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/StokOpnameController.php @@ -0,0 +1,121 @@ + $this->service->paginated( + ...$request->validatedWithDefaults(), + ), + 'highlight' => $request->input('highlight'), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/stok-opname/create', [ + 'products' => $this->productVariantService->getForStokOpname(), + ]); + } + + public function store(StokOpnameRequest $request): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->store($request->validated()), + 'Stok opname berhasil ditambahkan.', + 'admin.manage.stok-opnames.index', + 'admin.manage.stok-opnames.create' + ); + } + + public function edit(StokOpname $stokOpname): Response + { + return Inertia::render('admin/manage/stok-opname/edit', [ + 'stokOpname' => $this->service->getForEdit($stokOpname), + 'products' => $this->productVariantService->getForStokOpname(), + ]); + } + + public function update(StokOpnameRequest $request, StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->update($stokOpname, $request->validated()), + 'Stok opname berhasil diperbarui.', + 'admin.manage.stok-opnames.index', + 'admin.manage.stok-opnames.edit', + ['stokOpname' => $stokOpname] + ); + } + + public function destroy(StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->destroy($stokOpname), + 'Stok opname berhasil dihapus.', + 'admin.manage.stok-opnames.index' + ); + } + + public function submit(StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->submit($stokOpname), + 'Stok opname berhasil disubmit.', + 'admin.manage.stok-opnames.index' + ); + } + + public function verify(StokOpnameVerifyRequest $request, StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->verify($stokOpname, $request->validated()), + 'Stok opname berhasil diverifikasi.', + 'admin.manage.stok-opnames.index' + ); + } + + public function reject(StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->reject($stokOpname), + 'Stok opname ditolak.', + 'admin.manage.stok-opnames.index' + ); + } + + public function cancel(StokOpname $stokOpname): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->cancel($stokOpname), + 'Stok opname berhasil dibatalkan.', + 'admin.manage.stok-opnames.index' + ); + } + + public function items(StokOpname $stokOpname): JsonResponse + { + return response()->json([ + 'items' => $this->service->getItems($stokOpname), + ]); + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php new file mode 100644 index 0000000..36b8a78 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameRequest.php @@ -0,0 +1,40 @@ +user()->can('stok_opnames.create') + || $this->user()->can('stok_opnames.update'); + } + + public function rules(): array + { + return [ + 'opname_date' => ['required', 'date'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_variant_id' => ['required', 'integer', Rule::exists('product_variants', 'id')], + 'items.*.stock_quality' => ['required', Rule::in(ProductStockQuality::values())], + 'items.*.physical_stock' => ['required', 'integer', 'min:0'], + 'notes' => ['nullable', 'string', 'max:100'], + ]; + } + + public function attributes(): array + { + return [ + 'opname_date' => 'Tanggal Opname', + 'items' => 'Item produk', + 'items.*.product_variant_id' => 'Varian produk', + 'items.*.stock_quality' => 'Jenis stok', + 'items.*.physical_stock' => 'Stok fisik', + 'notes' => 'Keterangan', + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php b/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php new file mode 100644 index 0000000..d136d26 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/StokOpnameVerifyRequest.php @@ -0,0 +1,27 @@ +user()->can('stok_opnames.verify'); + } + + public function rules(): array + { + return [ + 'verification_notes' => ['nullable', 'string', 'max:100'], + ]; + } + + public function attributes(): array + { + return [ + 'verification_notes' => 'Catatan Verifikasi', + ]; + } +} diff --git a/app/Services/Admin/Manage/StokOpnameService.php b/app/Services/Admin/Manage/StokOpnameService.php new file mode 100644 index 0000000..c0b9c3f --- /dev/null +++ b/app/Services/Admin/Manage/StokOpnameService.php @@ -0,0 +1,293 @@ +select(['id', 'created_by_id', 'verified_by_id', 'opname_date', 'status', 'notes', 'verification_notes', 'created_at']) + ->with([ + 'createdBy:id', + 'createdBy.userProfile:id,user_id,full_name', + 'verifiedBy:id', + 'verifiedBy.userProfile:id,user_id,full_name', + ]) + ->selectRaw("{$itemsCountQuery} as items_count") + ->selectRaw("{$totalDifferenceQuery} as total_difference") + ->selectRaw("{$productNamesQuery} as product_names") + ->when($highlight, fn ($q) => $q->where('id', $highlight)) + ->when($status, fn ($q) => $q->where('status', $status)) + ->when($search, function ($q) use ($search) { + $q->whereHas('stokOpnameItems.productVariant.product', fn ($sq) => $sq->where('name', 'like', "%{$search}%")) + ->orWhere('notes', 'like', "%{$search}%"); + }) + ->orderBy($sort, $direction) + ->paginate($perPage); + } + + public function store(array $data): StokOpname + { + return DB::transaction(function () use ($data) { + $stokOpname = StokOpname::create([ + 'created_by_id' => auth()->id(), + 'opname_date' => $data['opname_date'], + 'status' => StokOpnameStatus::DRAFT, + 'notes' => $data['notes'] ?? null, + ]); + + $this->createItems($stokOpname, $data['items']); + + NotificationService::notify( + roles: [Role::OWNER, Role::DEVELOPER], + title: 'Stok Opname Baru', + body: 'Stok opname baru berhasil dibuat oleh '.auth()->user()->full_name.'.', + url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]), + ); + + return $stokOpname; + }); + } + + public function update(StokOpname $stokOpname, array $data): StokOpname + { + $this->assertDraft($stokOpname); + + return DB::transaction(function () use ($stokOpname, $data) { + $stokOpname->stokOpnameItems()->delete(); + + $stokOpname->update([ + 'opname_date' => $data['opname_date'], + 'notes' => $data['notes'] ?? null, + ]); + + $this->createItems($stokOpname, $data['items']); + + return $stokOpname; + }); + } + + public function destroy(StokOpname $stokOpname): void + { + $this->assertDraft($stokOpname); + + DB::transaction(function () use ($stokOpname) { + $stokOpname->stokOpnameItems()->delete(); + $stokOpname->delete(); + }); + } + + public function submit(StokOpname $stokOpname): StokOpname + { + $this->assertDraft($stokOpname); + + $stokOpname->update(['status' => StokOpnameStatus::IN_PROGRESS]); + + NotificationService::notify( + roles: [Role::OWNER, Role::DEVELOPER], + title: 'Stok Opname Disubmit', + body: 'Stok opname berhasil disubmit oleh '.auth()->user()->full_name.'.', + url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]), + ); + + return $stokOpname; + } + + public function verify(StokOpname $stokOpname, array $data): StokOpname + { + $this->assertInProgress($stokOpname); + + return DB::transaction(function () use ($stokOpname, $data) { + $stokOpname->load('stokOpnameItems'); + + foreach ($stokOpname->stokOpnameItems as $item) { + if ($item->difference != 0) { + $this->adjustVariantStock( + $item->product_variant_id, + abs($item->difference), + $item->difference > 0 ? 1 : -1, + $item->stock_quality->value, + ); + } + } + + $stokOpname->update([ + 'status' => StokOpnameStatus::VERIFIED, + 'verified_by_id' => auth()->id(), + 'verification_notes' => $data['verification_notes'] ?? null, + ]); + + NotificationService::notify( + roles: [Role::STOK_OPNAME, Role::ADMIN_TOKO], + title: 'Stok Opname Diverifikasi', + body: 'Stok opname berhasil diverifikasi oleh '.auth()->user()->full_name.'.', + url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]), + ); + + return $stokOpname; + }); + } + + public function reject(StokOpname $stokOpname): StokOpname + { + $this->assertInProgress($stokOpname); + + $stokOpname->update([ + 'status' => StokOpnameStatus::DRAFT, + 'verification_notes' => null, + ]); + + NotificationService::notify( + roles: [Role::STOK_OPNAME], + title: 'Stok Opname Ditolak', + body: 'Stok opname ditolak oleh '.auth()->user()->full_name.'. Silakan periksa dan submit ulang.', + url: route('admin.manage.stok-opnames.index', ['highlight' => $stokOpname->id]), + ); + + return $stokOpname; + } + + public function cancel(StokOpname $stokOpname): StokOpname + { + if ($stokOpname->status === StokOpnameStatus::VERIFIED) { + throw ValidationException::withMessages([ + 'status' => 'Stok opname yang sudah diverifikasi tidak dapat dibatalkan.', + ]); + } + + $stokOpname->update(['status' => StokOpnameStatus::CANCELLED]); + + return $stokOpname; + } + + public function getForEdit(StokOpname $stokOpname): array + { + $stokOpname->load([ + 'stokOpnameItems' => fn ($q) => $q + ->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes']), + 'stokOpnameItems.productVariant:id,product_id,name,stock,reject_stock,retail_stock', + 'stokOpnameItems.productVariant.product:id,name', + ]); + + return [ + 'id' => $stokOpname->id, + 'opname_date' => $stokOpname->opname_date->format('Y-m-d'), + 'status' => $stokOpname->status->value, + 'notes' => $stokOpname->notes, + 'items' => $stokOpname->stokOpnameItems->map(fn ($item) => [ + 'id' => $item->id, + 'product_variant_id' => $item->product_variant_id, + 'stock_quality' => $item->stock_quality->value, + 'system_stock' => $item->system_stock, + 'physical_stock' => $item->physical_stock, + 'difference' => $item->difference, + 'notes' => $item->notes, + 'variant_name' => $item->productVariant?->name, + 'product_name' => $item->productVariant?->product?->name, + ]), + ]; + } + + public function getItems(StokOpname $stokOpname): \Illuminate\Support\Collection + { + return $stokOpname->stokOpnameItems() + ->select(['id', 'stok_opname_id', 'product_variant_id', 'stock_quality', 'system_stock', 'physical_stock', 'difference', 'notes']) + ->with(['productVariant:id,product_id,name', 'productVariant.product:id,name']) + ->orderByRaw('(SELECT name FROM product_variants WHERE product_variants.id = stok_opname_items.product_variant_id)') + ->get() + ->map(function (StokOpnameItem $item) { + $media = $item->productVariant?->getFirstMedia('images'); + $photoUrl = $media ? $this->s3Service->getTemporaryUrl($media->getPath()) : null; + $photoConversionUrl = $media + ? ($media->getGeneratedConversions()->contains('thumb') + ? $this->s3Service->getTemporaryUrl($media->getPath('thumb')) + : $this->s3Service->getTemporaryUrl($media->getPath())) + : null; + + return [ + 'id' => $item->id, + 'product_variant_id' => $item->product_variant_id, + 'stock_quality' => $item->stock_quality, + 'system_stock' => $item->system_stock, + 'physical_stock' => $item->physical_stock, + 'difference' => $item->difference, + 'notes' => $item->notes, + 'variant_name' => $item->productVariant?->name, + 'product_name' => $item->productVariant?->product?->name, + 'photo_url' => $photoUrl, + 'photo_conversion_url' => $photoConversionUrl, + ]; + }); + } + + private function createItems(StokOpname $stokOpname, array $items): void + { + $now = now(); + + $rows = collect($items)->map(function ($item) use ($stokOpname, $now) { + $variant = ProductVariant::findOrFail($item['product_variant_id']); + $stockQuality = ProductStockQuality::from($item['stock_quality']); + $systemStock = match ($stockQuality) { + ProductStockQuality::GOOD => $variant->stock, + ProductStockQuality::REJECT => $variant->reject_stock, + ProductStockQuality::RETAIL => $variant->retail_stock, + }; + + return [ + 'stok_opname_id' => $stokOpname->id, + 'product_variant_id' => $item['product_variant_id'], + 'stock_quality' => $stockQuality->value, + 'system_stock' => $systemStock, + 'physical_stock' => (int) $item['physical_stock'], + 'difference' => (int) $item['physical_stock'] - $systemStock, + 'notes' => $item['notes'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + })->toArray(); + + DB::table('stok_opname_items')->insert($rows); + } + + private function assertDraft(StokOpname $stokOpname): void + { + if ($stokOpname->status !== StokOpnameStatus::DRAFT) { + throw ValidationException::withMessages([ + 'status' => 'Hanya stok opname dengan status draft yang dapat diubah.', + ]); + } + } + + private function assertInProgress(StokOpname $stokOpname): void + { + if ($stokOpname->status !== StokOpnameStatus::IN_PROGRESS) { + throw ValidationException::withMessages([ + 'status' => 'Hanya stok opname dengan status dalam proses yang dapat diproses.', + ]); + } + } +} diff --git a/resources/js/hooks/use-stok-opname-draft.ts b/resources/js/hooks/use-stok-opname-draft.ts new file mode 100644 index 0000000..3041961 --- /dev/null +++ b/resources/js/hooks/use-stok-opname-draft.ts @@ -0,0 +1,9 @@ +import { createDraftHook } from '@/hooks/use-draft-save'; +import { clearStokOpnameDraft, loadStokOpnameDraft, saveStokOpnameDraft } from '@/lib/stok-opname-draft'; +import type { StokOpnameDraftData } from '@/lib/stok-opname-draft'; + +export const useStokOpnameDraftSave = createDraftHook({ + save: saveStokOpnameDraft, + load: loadStokOpnameDraft, + clear: clearStokOpnameDraft, +}); diff --git a/resources/js/lib/stok-opname-draft.ts b/resources/js/lib/stok-opname-draft.ts new file mode 100644 index 0000000..d2e3d20 --- /dev/null +++ b/resources/js/lib/stok-opname-draft.ts @@ -0,0 +1,34 @@ +import { createDraftStore } from '@/lib/draft-store'; + +export type StockType = 'good' | 'reject' | 'retail'; + +export type StokOpnameDraftData = { + selectedProductId: string; + physicalStocks: Record>; + notes: string; +}; + +export const stokOpnameDraftStore = + createDraftStore('stok-opname-draft'); + +export function saveStokOpnameDraft( + type: 'create' | 'edit', + data: StokOpnameDraftData, + userId?: number, +): boolean { + return stokOpnameDraftStore.save(type, data, userId); +} + +export function loadStokOpnameDraft( + type: 'create' | 'edit', + userId?: number, +): StokOpnameDraftData | null { + return stokOpnameDraftStore.load(type, userId); +} + +export function clearStokOpnameDraft( + type: 'create' | 'edit', + userId?: number, +): void { + stokOpnameDraftStore.clear(type, userId); +} diff --git a/resources/js/pages/admin/manage/stok-opname/columns.tsx b/resources/js/pages/admin/manage/stok-opname/columns.tsx new file mode 100644 index 0000000..3d3cea3 --- /dev/null +++ b/resources/js/pages/admin/manage/stok-opname/columns.tsx @@ -0,0 +1,79 @@ +export type StokOpnameStatus = 'draft' | 'in_progress' | 'completed' | 'verified' | 'cancelled'; + +export type StockType = 'good' | 'reject' | 'retail'; + +export type StokOpnameItem = { + id: number; + product_variant_id: number; + stock_quality: StockType; + system_stock: number; + physical_stock: number; + difference: number; + notes: string | null; + variant_name: string; + product_name: string; + photo_url: string | null; + photo_conversion_url: string | null; +}; + +export type StokOpname = { + id: number; + created_by_id: number; + verified_by_id: number | null; + opname_date: string; + status: StokOpnameStatus; + notes: string | null; + verification_notes: string | null; + created_at: string; + items_count: number; + total_difference: number; + product_names: string | null; + created_by: { + id: number; + user_profile: { + full_name: string; + }; + }; + verified_by: { + id: number; + user_profile: { + full_name: string; + }; + } | null; +}; + +export type StokOpnameForEdit = { + id: number; + opname_date: string; + status: StokOpnameStatus; + notes: string | null; + items: { + id: number; + product_variant_id: number; + stock_quality: StockType; + system_stock: number; + physical_stock: number; + difference: number; + notes: string | null; + variant_name: string; + product_name: string; + }[]; +}; + +export type ProductForStokOpname = { + id: number; + name: string; + status: string; + product_variants: { + id: number; + name: string; + stock: number; + reject_stock: number; + retail_stock: number; + photo_url: string | null; + }[]; +}; + +export type StokOpnameCreateData = { + products: ProductForStokOpname[]; +}; diff --git a/resources/js/pages/admin/manage/stok-opname/create.tsx b/resources/js/pages/admin/manage/stok-opname/create.tsx new file mode 100644 index 0000000..60bf043 --- /dev/null +++ b/resources/js/pages/admin/manage/stok-opname/create.tsx @@ -0,0 +1,591 @@ +'use no memo'; + +import { NumberInput } from '@/components/inputs'; +import { InputError } from '@/components/ui'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox'; +import { Label } from '@/components/ui/label'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Textarea } from '@/components/ui/textarea'; +import { useStokOpnameDraftSave } from '@/hooks/use-stok-opname-draft'; +import { formatNumber } from '@/lib/format'; +import { loadStokOpnameDraft } from '@/lib/stok-opname-draft'; +import { index as stokOpnameIndex, store } from '@/routes/admin/manage/stok-opnames'; +import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { ArrowLeft, Minus, Plus, ShoppingCart, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import type { ProductForStokOpname, StokOpnameCreateData } from './columns'; + +type StockType = 'good' | 'reject' | 'retail'; + +type CartLine = { + key: string; + photoUrl: string | null; + title: string; + subtitle: string; + quantity: number; + onAdjust: (delta: number) => void; + onSet: (value: number) => void; + onRemove: () => void; +}; + +type Props = { + products: StokOpnameCreateData['products']; +}; + +export default function StokOpnameCreate({ products }: Props) { + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + const draft = loadStokOpnameDraft('create', userId); + + const [selectedProductId, setSelectedProductId] = useState( + draft?.selectedProductId ?? '', + ); + + const [physicalStocks, setPhysicalStocks] = useState>>(() => { + if (draft?.physicalStocks) { + return draft.physicalStocks; + } + return {}; + }); + const [notes, setNotes] = useState(draft?.notes ?? ''); + const [cartOpen, setCartOpen] = useState(false); + + const draftData = useMemo( + () => ({ + selectedProductId, + physicalStocks, + notes, + }), + [selectedProductId, physicalStocks, notes], + ); + + useStokOpnameDraftSave('create', draftData, userId); + + const physicalStocksRef = useRef(physicalStocks); + + useEffect(() => { + physicalStocksRef.current = physicalStocks; + }, [physicalStocks]); + + const selectedProduct = useMemo( + () => products.find((p) => String(p.id) === selectedProductId) ?? null, + [products, selectedProductId], + ); + + const variantById = useMemo( + () => + new Map( + products.flatMap((p: ProductForStokOpname) => + p.product_variants.map((v) => [v.id, v]), + ), + ), + [products], + ); + + const updatePhysicalStock = useCallback((variantId: number, stockType: StockType, value: number) => { + setPhysicalStocks((prev) => ({ + ...prev, + [variantId]: { + ...prev[variantId], + [stockType]: Math.max(0, value), + }, + })); + }, []); + + const hasAnyStock = useCallback((variantId: number) => { + const stocks = physicalStocks[variantId]; + if (!stocks) return false; + return stocks.good > 0 || stocks.reject > 0 || stocks.retail > 0; + }, [physicalStocks]); + + const cartItems: CartLine[] = (() => { + const lines: CartLine[] = []; + + for (const [variantId, stocks] of Object.entries(physicalStocks)) { + const id = Number(variantId); + const variant = variantById.get(id); + if (!variant) continue; + + for (const [stockType, quantity] of Object.entries(stocks)) { + if (quantity <= 0) continue; + + const type = stockType as StockType; + const typeLabel = type === 'good' ? 'Bagus' : type === 'reject' ? 'Reject' : 'Ecer'; + + lines.push({ + key: `variant-${id}-${type}`, + photoUrl: variant.photo_url, + title: variant.name, + subtitle: `${typeLabel}: ${formatNumber(quantity)}`, + quantity, + onAdjust: (delta) => updatePhysicalStock(id, type, quantity + delta), + onSet: (value) => updatePhysicalStock(id, type, value), + onRemove: () => updatePhysicalStock(id, type, 0), + }); + } + } + + return lines.sort((a, b) => a.title.localeCompare(b.title)); + })(); + + function formatQuantity(value: number): string { + return formatNumber(value, { maximumFractionDigits: 4 }); + } + + function getPayload() { + const items: Array<{ product_variant_id: number; stock_quality: StockType; physical_stock: number }> = []; + + for (const [variantId, stocks] of Object.entries(physicalStocksRef.current)) { + const variant = variantById.get(Number(variantId)); + if (!variant) continue; + + for (const [stockType, quantity] of Object.entries(stocks)) { + if (quantity <= 0) continue; + + items.push({ + product_variant_id: Number(variantId), + stock_quality: stockType as StockType, + physical_stock: quantity, + }); + } + } + + return { + opname_date: new Date().toISOString().split('T')[0], + items, + notes: notes || null, + }; + } + + return ( + <> + + +
+
+

+ Tambah Stok Opname +

+ +
+ +
({ + ...formData, + ...getPayload(), + })} + onError={() => { + toast.error('Ada data yang belum sesuai, silakan periksa kembali input Anda.'); + }} + > + {({ errors, processing }) => ( +
+
+ + + Pilih Produk + + +
+ + + p.name + } + value={selectedProduct} + onValueChange={(value) => + setSelectedProductId( + value + ? String(value.id) + : '', + ) + } + > + + + + Tidak ada produk + ditemukan. + + + {(p) => ( + + {p.name} + + )} + + + + +
+ + {selectedProduct && ( +
+ {selectedProduct.product_variants.map( + (variant) => { + const isActive = hasAnyStock(variant.id); + + return ( +
+
+ {variant.photo_url ? ( + {variant.name} + ) : ( +
+ N/A +
+ )} +
+

+ {variant.name} +

+
+
+

+ Bagus: {formatQuantity(variant.stock)} +

+
+ + + updatePhysicalStock(variant.id, 'good', val) + } + /> + +
+
+
+

+ Reject: {formatQuantity(variant.reject_stock)} +

+
+ + + updatePhysicalStock(variant.id, 'reject', val) + } + /> + +
+
+
+

+ Ecer: {formatQuantity(variant.retail_stock)} +

+
+ + + updatePhysicalStock(variant.id, 'retail', val) + } + /> + +
+
+
+
+
+
+ ); + }, + )} +
+ )} +
+
+
+ +
+ + + Ringkasan + + +
+
+ + Jumlah Item + + + {cartItems.length} + +
+
+ +
+ +