'use no memo'; import { Form, Head, Link, usePage } from '@inertiajs/react'; import { ArrowLeft, Minus, Plus, Search, ShoppingCart, Trash2 } from 'lucide-react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmDialog } from '@/components/dialogs'; import { ImagePreviewModal } from '@/components/dialogs'; import { FileUpload } from '@/components/inputs'; import { NumberInput } from '@/components/inputs'; import { RupiahInput } from '@/components/inputs'; import { Input } from '@/components/ui/input'; 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 { FieldDescription } from '@/components/ui/field'; import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle, } from '@/components/ui/sheet'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { useCan } from '@/hooks/use-can'; import { useTransactionDraftSave } from '@/hooks/use-transaction-draft'; import { formatNumber } from '@/lib/format'; import { loadTransactionDraft } from '@/lib/transaction-draft'; import { getTemporaryUrl } from '@/lib/upload'; import { formatCurrency } from '@/lib/utils'; import { store, index as transactionIndex } from '@/routes/admin/manage/transactions'; import type { ProductForTransaction, ProductVariantForTransaction, TransactionCreateData } from './columns'; type CartLine = { key: string; photoUrl: string | null; title: string; subtitle: string; price: number; quantity: number; onAdjust: (delta: number) => void; onSet: (value: number) => void; onRemove: () => void; }; type Props = { products: TransactionCreateData['products']; customers: TransactionCreateData['customers']; employees: TransactionCreateData['employees']; channelOptions: TransactionCreateData['channelOptions']; paymentTypeOptions: TransactionCreateData['paymentTypeOptions']; priceTypeOptions: TransactionCreateData['priceTypeOptions']; }; const SELLING_PRICE_TYPES = ['distributor', 'agent', 'sub_agent', 'wholesale', 'retail', 'tiktok', 'shopee', 'reject_selling']; export default function TransactionCreate({ products, customers, employees, channelOptions, paymentTypeOptions, priceTypeOptions, }: Props) { const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const userId = auth.user?.id; const { hasRole } = useCan(); const isCashier = hasRole('cashier'); const draft = loadTransactionDraft('create', userId); const [stockType, setStockType] = useState<'good' | 'reject'>( draft?.stockType === 'reject' ? 'reject' : 'good', ); const [channel, setChannel] = useState(draft?.channel ?? 'store'); const [priceType, setPriceType] = useState(draft?.priceType ?? (isCashier ? 'retail' : 'retail')); const [paymentType, setPaymentType] = useState(draft?.paymentType ?? 'cash'); const [customerId, setCustomerId] = useState(draft?.customerId ?? null); const [marketingId, setMarketingId] = useState(draft?.marketingId ?? null); const [discount, setDiscount] = useState(draft?.discount ?? 0); const [negoPrice, setNegoPrice] = useState(draft?.negoPrice ?? null); const [isCompleted, setIsCompleted] = useState(draft?.isCompleted ?? false); const [notes, setNotes] = useState(draft?.notes ?? ''); const [photo, setPhoto] = useState(draft?.photo ?? null); const [photoUrl, setPhotoUrl] = useState( draft?.photo ? getTemporaryUrl(draft.photo) : null, ); const [uploading, setUploading] = useState(false); const [selectedProductId, setSelectedProductId] = useState(draft?.selectedProductId ?? ''); const [quantities, setQuantities] = useState>(() => Object.fromEntries( Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ Number(id), qty, ]), ), ); const [cartOpen, setCartOpen] = useState(false); const [previewKey, setPreviewKey] = useState(null); const [cartRemoveKey, setCartRemoveKey] = useState(null); const [tiktokOrderId, setTiktokOrderId] = useState(draft?.tiktokOrderId ?? ''); const [shopeeOrderId, setShopeeOrderId] = useState(draft?.shopeeOrderId ?? ''); const [variantSearch, setVariantSearch] = useState(''); const [fetchedVariants, setFetchedVariants] = useState>(new Map()); const [loadingVariants, setLoadingVariants] = useState(false); const quantitiesRef = useRef(quantities); useEffect(() => { quantitiesRef.current = quantities; }, [quantities]); const draftData = useMemo( () => ({ stockType, channel, priceType, paymentType, customerId, marketingId, discount, negoPrice, isCompleted, tiktokOrderId: channel === 'tiktok' ? tiktokOrderId : '', shopeeOrderId: channel === 'shopee' ? shopeeOrderId : '', selectedProductId, quantities: Object.fromEntries( Object.entries(quantities).map(([id, qty]) => [ String(id), qty, ]), ), notes, photo: photo ?? undefined, }), [ stockType, channel, priceType, paymentType, customerId, marketingId, discount, negoPrice, isCompleted, tiktokOrderId, shopeeOrderId, selectedProductId, quantities, notes, photo, ], ); useTransactionDraftSave('create', draftData, userId); const selectedProduct = useMemo( () => products.find((p) => String(p.id) === selectedProductId) ?? null, [products, selectedProductId], ); const selectedProductVariants = useMemo( () => (selectedProduct ? fetchedVariants.get(selectedProduct.id) ?? [] : []), [selectedProduct, fetchedVariants], ); useEffect(() => { if (!selectedProduct) return; if (fetchedVariants.has(selectedProduct.id)) return; setLoadingVariants(true); fetch(`/admin/master/products/${selectedProduct.id}/variants`) .then((res) => res.json()) .then((data) => { setFetchedVariants((prev) => { const next = new Map(prev); next.set(selectedProduct.id, data.variants); return next; }); }) .catch(() => { toast.error('Gagal memuat varian produk.'); }) .finally(() => setLoadingVariants(false)); }, [selectedProduct, fetchedVariants]); const groupedVariants = useMemo(() => { if (!variantSearch) return []; if (!selectedProduct) return []; const search = variantSearch.toLowerCase(); return [{ ...selectedProduct, product_variants: selectedProductVariants.filter((v) => v.name.toLowerCase().includes(search), ), }]; }, [selectedProduct, selectedProductVariants, variantSearch]); const variantById = useMemo(() => { const map = new Map(); for (const [, variants] of fetchedVariants) { for (const v of variants) { map.set(v.id, v); } } return map; }, [fetchedVariants]); const productByVariantId = useMemo(() => { const map = new Map(); for (const [productId, variants] of fetchedVariants) { const product = products.find((p) => p.id === productId); if (!product) continue; for (const v of variants) { map.set(v.id, product.name); } } return map; }, [fetchedVariants, products]); useEffect(() => { if (channel === 'tiktok') { setPriceType('tiktok'); setPaymentType('marketplace'); } else if (channel === 'shopee') { setPriceType('shopee'); setPaymentType('marketplace'); } else if (isCashier) { setPriceType('retail'); } }, [channel, isCashier]); useEffect(() => { if (stockType === 'reject') { setPriceType('reject_selling'); } else if (isCashier) { setPriceType('retail'); } else if (priceType === 'reject_selling') { setPriceType('retail'); } }, [stockType, isCashier]); const showPhoto = paymentType === 'transfer' || paymentType === 'qris'; const availablePriceTypes = useMemo(() => { if (stockType === 'reject') { return priceTypeOptions.filter((o) => o.value === 'reject_selling'); } if (isCashier) { return priceTypeOptions.filter((o) => o.value === 'retail'); } return priceTypeOptions.filter((o) => SELLING_PRICE_TYPES.includes(o.value) && o.value !== 'retail'); }, [stockType, priceTypeOptions, isCashier]); const getUnitPrice = useCallback( (variantId: number) => { const variant = variantById.get(variantId); if (!variant) { return 0; } if (stockType === 'reject') { return variant.prices?.reject_selling ?? 0; } if (isCashier) { return variant.prices?.retail ?? 0; } return variant.prices?.[priceType] ?? 0; }, [variantById, stockType, priceType, isCashier], ); const subtotal = Object.entries(quantities).reduce( (sum, [variantId, quantity]) => { const unitPrice = getUnitPrice(Number(variantId)); return sum + unitPrice * quantity; }, 0, ); const total = negoPrice ?? (subtotal - discount); const updateQuantity = useCallback((variantId: number, value: number) => { setQuantities((prev) => ({ ...prev, [variantId]: Math.max(0, value), })); }, []); const incrementQuantity = useCallback( (variantId: number, amount: number) => { if (amount > 0 && getUnitPrice(variantId) <= 0) { toast.error('Harga produk ini belum diatur.'); return; } setQuantities((prev) => ({ ...prev, [variantId]: Math.max(0, (prev[variantId] ?? 0) + amount), })); }, [getUnitPrice], ); const cartItems: CartLine[] = (() => { const lines: CartLine[] = []; for (const [variantId, quantity] of Object.entries(quantities)) { if (quantity <= 0) { continue; } const id = Number(variantId); const variant = variantById.get(id); if (variant) { const unitPrice = getUnitPrice(id); const productName = productByVariantId.get(id) ?? ''; lines.push({ key: `variant-${id}`, photoUrl: variant.photo_url, title: `${productName} — ${variant.name}`, subtitle: `${formatCurrency(unitPrice)} / pcs`, price: unitPrice, quantity, onAdjust: (delta) => incrementQuantity(id, delta), onSet: (value) => updateQuantity(id, value), onRemove: () => updateQuantity(id, 0), }); } } return lines; })(); function formatQuantity(value: number): string { return formatNumber(value, { maximumFractionDigits: 4 }); } function getPayload() { return { stock_type: stockType === 'good' && isCashier ? 'retail' : stockType, channel, price_type: stockType === 'reject' ? 'reject_selling' : (isCashier ? 'retail' : priceType), payment_type: paymentType, customer_id: customerId, marketing_id: marketingId, discount, nego_price: negoPrice, is_completed: isCompleted, tiktok_order_id: channel === 'tiktok' ? tiktokOrderId || null : null, shopee_order_id: channel === 'shopee' ? shopeeOrderId || null : null, items: Object.entries(quantitiesRef.current) .map(([variantId, quantity]) => ({ product_variant_id: Number(variantId), quantity: Number(quantity), })) .filter((item) => item.quantity > 0), notes: notes || null, photo_key: showPhoto ? photo : null, }; } return ( <>

Tambah Transaksi

({ ...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} )}
setVariantSearch(e.target.value)} className="pl-8" />
{variantSearch && groupedVariants.length > 0 && (
{groupedVariants.map((p) => (

{p.name}

{p.product_variants.map((variant) => { const currentStock = stockType === 'reject' ? variant.reject_stock : (isCashier ? variant.retail_stock : variant.stock); return (
0 ? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' : 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' } >
{variant.photo_url ? ( {variant.name} ) : (
N/A
)}

{variant.name}

Stok: {formatQuantity(Number(currentStock))} pcs · {getUnitPrice(variant.id) <= 0 ? ( Harga belum diatur ) : ( formatCurrency( stockType === 'reject' ? (variant.prices?.reject_selling ?? 0) : (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)), ) )}

updateQuantity(variant.id, val)} />
); })}
))}
)} {variantSearch && groupedVariants.length === 0 && (

Tidak ada varian ditemukan.

)} {!variantSearch && selectedProduct && (
{loadingVariants ? (
Memuat varian...
) : selectedProductVariants.length === 0 ? (
Tidak ada varian ditemukan.
) : ( selectedProductVariants.map( (variant) => { const currentStock = stockType === 'reject' ? variant.reject_stock : (isCashier ? variant.retail_stock : variant.stock); return (
0 ? 'flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary p-3' : 'flex flex-wrap items-center justify-between gap-2 rounded-lg border p-3' } >
{variant.photo_conversion_url ?? variant.photo_url ? ( { ) : (
N/A
)}

{ variant.name }

Stok:{' '} {formatQuantity( Number( currentStock, ), )}{' '} pcs ·{' '} {getUnitPrice(variant.id) <= 0 ? ( Harga belum diatur ) : ( formatCurrency( stockType === 'reject' ? (variant.prices?.reject_selling ?? 0) : (isCashier ? (variant.prices?.retail ?? 0) : (variant.prices?.[priceType] ?? 0)), ) )}

updateQuantity( variant.id, val, ) } />
); }, ) )}
)}
Ringkasan
setStockType( value as 'good' | 'reject', ) } className="flex flex-wrap gap-4" >
{channel === 'tiktok' && (
setTiktokOrderId(e.target.value)} placeholder="Masukkan ID pesanan TikTok" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" />
)} {channel === 'shopee' && (
setShopeeOrderId(e.target.value)} placeholder="Masukkan ID pesanan Shopee" className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50" />
)} {!isCashier && (
)}
{showPhoto && (
{ setPhoto(key); setPhotoUrl( key ? getTemporaryUrl( key, ) : null, ); }} folder="transaction" existingUrl={photoUrl} onUploadingChange={setUploading} />
)}
c.name} value={customers.find((c) => c.id === customerId) ?? null} onValueChange={(value) => setCustomerId(value ? value.id : null) } > Tidak ada pelanggan. {(c) => ( {c.name} )}
e.user_profile?.full_name ?? '-' } value={employees.find((e) => e.id === marketingId) ?? null} onValueChange={(value) => setMarketingId(value ? value.id : null) } > Tidak ada karyawan. {(e) => ( {e.user_profile?.full_name ?? '-'} )}
Subtotal {formatCurrency(subtotal)}
setNegoPrice(val || null) } placeholder="0" /> Harga akhir setelah dinego dengan customer.
Total {formatCurrency( total, )}