dress/resources/js/pages/admin/manage/order/create.tsx

643 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Head, Link, useForm, router } from '@inertiajs/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Field, FieldError } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import * as orderRoutes from '@/routes/order';
import React, { useMemo, useState } from 'react';
import { toast } from 'sonner';
import { Product, ProductPrice } from '@/types';
import { OrderItem } from '@/types/order';
import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X, Tag, ArrowLeft, Check, Loader, Save } from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
import {
Sheet,
SheetContent,
SheetTrigger,
SheetClose,
} from "@/components/ui/sheet";
import { NumericFormat } from 'react-number-format';
type CartItem = OrderItem & { id: number };
type EnumOption = { value: string, label: string };
export default function OrderCreate({ products, cartItems, orderStatus, orderChannels, paymentMethods, priceTypes }: {
products: Product[],
cartItems: CartItem[],
orderStatus: EnumOption[],
orderChannels: EnumOption[],
paymentMethods: EnumOption[],
priceTypes: EnumOption[]
}) {
const [search, setSearch] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [qtyDialogIndex, setQtyDialogIndex] = useState<number | null>(null);
const [qtyInputValue, setQtyInputValue] = useState('');
// Default price type for the catalog
const [globalPriceType, setGlobalPriceType] = useState<string>('retail');
const categories = useMemo(() => {
const map = new Map<number, string>();
products.forEach(p => p.categories?.forEach(c => map.set(c.id, c.name)));
return Array.from(map.entries()).map(([id, name]) => ({ id, name }));
}, [products]);
const { data, setData, post, processing, errors, transform } = useForm({
invoice_number: '',
customer_name: '',
discount: 0,
payment: 0,
payment_method: 'cash',
order_status: 'delivered',
order_channel: 'store',
items: [] as any[],
});
const filteredProducts = products.filter(p => {
const matchesSearch = p.name.toLowerCase().includes(search.toLowerCase());
const matchesCategory = selectedCategory === 'all' || p.categories?.some(c => String(c.id) === selectedCategory);
return matchesSearch && matchesCategory;
});
const getCartItem = (productId: number, priceType: string) =>
cartItems.find(item => item.product_id === productId && item.price_type === priceType);
const getProductPrice = (product: Product, priceType: string) => {
return product.prices?.find(p => p.price_type === priceType)?.price || 0;
};
const addToCart = (product: Product, priceType: string) => {
const price = getProductPrice(product, priceType);
router.post(orderRoutes.addToCart().url, {
product_id: product.id,
qty: 1,
price: price,
price_type: priceType
}, {
preserveScroll: true,
});
};
const decreaseQuantity = (item: CartItem, e: React.MouseEvent) => {
e.stopPropagation();
router.post(orderRoutes.addToCart().url, {
product_id: item.product_id,
qty: -1,
price: item.price,
price_type: item.price_type
}, {
preserveScroll: true,
});
};
const increaseQuantity = (item: CartItem, e: React.MouseEvent) => {
e.stopPropagation();
router.post(orderRoutes.addToCart().url, {
product_id: item.product_id,
qty: 1,
price: item.price,
price_type: item.price_type
}, {
preserveScroll: true,
});
};
const removeFromCart = (itemId: number) => {
router.delete(orderRoutes.removeFromCart(itemId).url, {
preserveScroll: true,
});
};
const updateCartQuantity = (itemId: number, qty: number) => {
if (qty < 1) return;
router.patch(orderRoutes.updateCartItem(itemId).url, {
qty
}, {
preserveScroll: true,
});
};
const openQtyDialog = (item: CartItem, e: React.MouseEvent) => {
e.stopPropagation();
setQtyInputValue(String(item.qty));
setQtyDialogIndex(item.id);
};
const confirmQty = () => {
const val = parseInt(qtyInputValue);
if (!isNaN(val) && val >= 1 && qtyDialogIndex !== null) {
updateCartQuantity(qtyDialogIndex, val);
}
setQtyDialogIndex(null);
};
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (cartItems.length === 0) {
toast.error('Pilih minimal satu produk');
return;
}
transform((data) => ({
...data,
items: cartItems.map(item => ({
product_id: item.product_id,
qty: item.qty,
price: item.price,
total: item.total,
price_type: item.price_type
}))
}));
post(orderRoutes.store().url, {
onSuccess: (response: any) => {
toast.success(response.props.flash.success);
},
});
};
const subtotal = useMemo(() => {
return cartItems.reduce((acc, item) => acc + item.total, 0);
}, [cartItems]);
const total = subtotal - (Number(data.discount) || 0);
const change = (Number(data.payment) || 0) - total;
const cartTotalItems = cartItems.reduce((a, i) => a + i.qty, 0);
const PriceTypeLabel = ({ type }: { type: string }) => {
const option = priceTypes.find(opt => opt.value === type);
return option ? option.label : type;
};
const CartFormContent = () => (
<div className="flex flex-col h-full">
<CardHeader className="border-b py-4 flex-row items-center justify-between shrink-0">
<CardTitle className="flex items-center gap-2 text-base">
<ShoppingCart className="h-4 w-4" />
Keranjang
</CardTitle>
<Badge className="rounded-full font-bold">
{cartTotalItems} pcs
</Badge>
</CardHeader>
<ScrollArea className="flex-1 min-h-0">
<CardContent className="p-0">
{cartItems.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Keranjang masih kosong</p>
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
</div>
) : (
<div className="divide-y divide-border">
{cartItems.map((item) => (
<div key={item.id} className="p-4 space-y-2.5">
<div className="flex gap-3">
<div className="h-11 w-11 rounded-md bg-muted overflow-hidden shrink-0 border">
{item.product?.thumbnail_url ? (
<img src={item.product.thumbnail_url} className="h-full w-full object-cover" alt={item.product.name} />
) : (
<div className="h-full w-full flex items-center justify-center">
<ImagePlus className="h-4 w-4 text-muted-foreground/50" />
</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-semibold leading-tight line-clamp-1">{item.product?.name}</h4>
<div className="flex items-center gap-1.5 mt-0.5">
<Badge variant="outline" className="text-[10px] px-1 py-0 h-4 bg-primary/5 border-primary/20 text-primary">
<PriceTypeLabel type={item.price_type} />
</Badge>
<p className="text-xs text-primary font-medium">
Rp {item.total.toLocaleString('id-ID')}
</p>
</div>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/20"
onClick={() => removeFromCart(item.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<div className="flex items-center bg-muted/50 rounded-lg border px-1">
<button type="button" onClick={(e) => decreaseQuantity(item, e)} className="p-1 hover:text-primary"><Minus className="h-3 w-3" /></button>
<span className="px-2 font-medium text-foreground">{item.qty}</span>
<button type="button" onClick={(e) => increaseQuantity(item, e)} className="p-1 hover:text-primary"><Plus className="h-3 w-3" /></button>
</div>
<span>×</span>
<span className="font-mono">
Rp {item.price.toLocaleString('id-ID')}
</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</ScrollArea>
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
<div className="grid grid-cols-2 gap-3">
<Field>
<Label htmlFor='customer_name' className="text-xs text-muted-foreground" required>Pelanggan</Label>
<Input id="customer_name" className="h-8 text-xs" value={data.customer_name} onChange={e => setData('customer_name', e.target.value)} placeholder="Nama Pelanggan" />
<FieldError error={errors.customer_name} label="Pelanggan" className="text-xs" />
</Field>
<Field>
<Label className="text-xs text-muted-foreground" required>Channel</Label>
<Select value={data.order_channel} onValueChange={val => setData('order_channel', val as any)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{orderChannels.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError error={errors.order_channel} label="Channel" className="text-xs" />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<Label className="text-xs text-muted-foreground" required>Metode Bayar</Label>
<Select value={data.payment_method} onValueChange={val => setData('payment_method', val as any)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{paymentMethods.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError error={errors.payment_method} label="Metode Bayar" className="text-xs" />
</Field>
<Field>
<Label className="text-xs text-muted-foreground" required>Status</Label>
<Select value={data.order_status} onValueChange={val => setData('order_status', val as any)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{orderStatus.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<FieldError error={errors.order_status} label="Status" className="text-xs" />
</Field>
</div>
<div className="space-y-1.5 pt-2 border-t border-dashed">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Subtotal</span>
<span className="font-medium">Rp {subtotal.toLocaleString('id-ID')}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">Potongan / Diskon</span>
<div className="flex items-center gap-2">
<NumericFormat
id="discount"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={data.discount}
onValueChange={(values) => {
setData('discount', values.floatValue || 0)
}}
placeholder="Rp 0"
autoComplete='off'
/>
<FieldError error={errors.discount} label="Potongan / Diskon" className="text-xs" />
</div>
</div>
<div className="flex justify-between text-lg font-bold border-t pt-2">
<span className="">Total</span>
<span className="font-medium">Rp {total.toLocaleString('id-ID')}</span>
</div>
</div>
<div className="space-y-2">
<Field>
<Label className="text-xs text-muted-foreground">Uang Bayar</Label>
<NumericFormat
id="payment"
customInput={Input}
thousandSeparator="."
decimalSeparator=","
prefix="Rp "
value={data.payment}
onValueChange={(values) => {
setData('payment', values.floatValue || 0)
}}
placeholder="Rp 0"
autoComplete='off'
/>
</Field>
{change >= 0 && (Number(data.payment) || 0) > 0 && (
<div className="flex justify-between items-center p-2 rounded bg-green-500/10 border border-green-500/20 text-green-600 dark:text-green-400">
<span className="text-xs font-bold uppercase">Kembalian</span>
<span className="text-lg font-bold">Rp {change.toLocaleString('id-ID')}</span>
</div>
)}
</div>
<Button
type="submit"
className="w-full h-10 font-semibold shadow"
disabled={processing || cartItems.length === 0}
onClick={() => {
if (cartItems.length > 0) {
onSubmit({ preventDefault: () => { } } as any)
}
}}
>
{processing ? <Loader className="h-4 w-4 animate-spin" /> : <Save
className="h-4 w-4" />}
{processing ? 'Menyimpan...' : 'Simpan'}
</Button>
</div>
</div>
);
return (
<div className="flex flex-col gap-6 p-6 lg:h-[calc(100vh-64px)] lg:overflow-hidden">
<Head title="Buat Pesanan" />
<div className="flex items-center justify-between shrink-0">
<h1 className="text-3xl font-bold tracking-tight">Tambah Pesanan</h1>
<Link href={orderRoutes.index().url}>
<Button variant='outline' className="gap-2">
<ArrowLeft className="size-4" />
Kembali
</Button>
</Link>
</div>
<div className="flex-1 min-h-0 grid grid-cols-1 lg:grid-cols-12 gap-6 pb-20 lg:pb-0">
<div className="lg:col-span-8 flex flex-col gap-5 h-full min-h-0">
<div className="flex flex-col sm:flex-row gap-2 shrink-0">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
<Input
placeholder="Cari nama produk..."
className="pl-10 h-10"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="flex gap-2">
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
<SelectTrigger className="w-full sm:w-40 h-10">
<SelectValue placeholder="Kategori" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua</SelectItem>
{categories.map(cat => (
<SelectItem key={cat.id} value={String(cat.id)}>{cat.name}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={globalPriceType} onValueChange={setGlobalPriceType}>
<SelectTrigger className="w-full sm:w-40 h-10 bg-primary/5 text-primary border-primary/20 font-bold">
<Tag className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
{priceTypes.filter(t => t.value !== 'purchase').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<ScrollArea className="flex-1 h-full pr-4">
<div className="grid grid-cols-2 sm:grid-cols-3 xl:grid-cols-4 gap-4 pb-10">
{filteredProducts.map((product) => {
const cartItem = getCartItem(product.id, globalPriceType);
const currentPrice = getProductPrice(product, globalPriceType);
return (
<Card
key={product.id}
className={cn(
"group cursor-pointer transition-all duration-200 bg-card border p-0",
"hover:shadow-lg",
cartItem
? "border-primary shadow-md"
: "shadow-sm hover:border-primary/50"
)}
onClick={() => addToCart(product, globalPriceType)}
>
<CardContent className="p-4 flex flex-col gap-3">
<div className="-mx-4 -mt-4 aspect-[4/3] overflow-hidden bg-muted/50 rounded-t-xl relative">
{product.thumbnail_url ? (
<img
src={product.thumbnail_url}
alt={product.name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<ImagePlus className="h-10 w-10 text-muted-foreground/30" />
</div>
)}
{cartItem && (
<div className="absolute top-2 right-2 flex items-center justify-center bg-primary text-primary-foreground text-[10px] font-bold h-6 min-w-6 px-1 rounded-full shadow-lg">
{cartItem.qty}
</div>
)}
</div>
<div>
<div className="flex flex-wrap gap-1 mb-1.5">
{product.categories?.map(cat => (
<Badge key={cat.id} variant="outline" className="text-[10px] h-4 px-1.5 bg-muted/50">
{cat.name}
</Badge>
))}
</div>
<h3 className="font-bold text-sm leading-snug line-clamp-2 min-h-[2.5rem]">
{product.name}
</h3>
</div>
{/* Price + Stepper */}
<div
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-auto pt-2 border-t"
onClick={e => e.stopPropagation()}
>
<div className="flex flex-col flex-1 min-w-0">
<span className="text-[10px] uppercase font-bold text-muted-foreground">Harga</span>
<span className="text-sm font-bold text-primary tabular-nums truncate block">
Rp {currentPrice.toLocaleString('id-ID')}
</span>
</div>
<div className="flex items-center gap-1 bg-muted/50 rounded-xl sm:rounded-full p-1 sm:p-0.5 shrink-0 w-full sm:w-auto justify-between sm:justify-end border border-transparent hover:border-primary/20 transition-colors">
<button
type="button"
className={cn(
"h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full flex items-center justify-center transition-all",
cartItem
? "bg-background text-primary shadow-sm hover:bg-primary hover:text-primary-foreground"
: "text-muted-foreground/30 cursor-not-allowed"
)}
disabled={!cartItem}
onClick={(e) => cartItem && decreaseQuantity(cartItem, e)}
>
<Minus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
</button>
<button
type="button"
className={cn(
"flex-1 sm:flex-none sm:min-w-[36px] px-1 text-center text-sm font-bold tabular-nums transition-colors",
cartItem ? "text-foreground hover:text-primary cursor-pointer" : "text-muted-foreground/40 cursor-default"
)}
onClick={(e) => {
if (!cartItem) return;
openQtyDialog(cartItem, e);
}}
>
{cartItem?.qty ?? 0}
</button>
<button
type="button"
className="h-8 w-8 sm:h-6 sm:w-6 rounded-lg sm:rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/80 transition-all shadow-sm"
onClick={(e) => {
e.stopPropagation();
cartItem ? increaseQuantity(cartItem, e) : addToCart(product, globalPriceType);
}}
>
<Plus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
</button>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
{filteredProducts.length === 0 && (
<div className="col-span-full py-20 text-center">
<Empty>
<EmptyHeader>
<EmptyTitle>Produk tidak ditemukan</EmptyTitle>
<EmptyDescription>Coba kata kunci lain atau kategori berbeda.</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
)}
</ScrollArea>
</div>
<div className="hidden lg:block lg:col-span-4 h-full min-h-0">
<Card className="border shadow-2xl bg-card overflow-hidden h-full flex flex-col border-primary/10">
{CartFormContent()}
</Card>
</div>
<div className="lg:hidden fixed bottom-6 left-0 right-0 px-6 z-50 pointer-events-none">
<div className="max-w-md mx-auto pointer-events-auto">
<Sheet>
<SheetTrigger asChild>
<Button
size="lg"
className="w-full rounded-full shadow-2xl h-14 flex items-center justify-between px-6 bg-primary animate-in fade-in slide-in-from-bottom-4 duration-300"
>
<div className="flex items-center gap-3">
<div className="bg-primary-foreground/20 rounded-full h-8 w-8 flex items-center justify-center">
<ShoppingCart className="h-4 w-4" />
</div>
<div className="text-left leading-tight">
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Keranjang</p>
<p className="text-sm font-bold">{cartTotalItems} Item</p>
</div>
</div>
<div className="text-right">
<p className="text-[10px] opacity-80 uppercase font-bold tracking-wider">Total</p>
<p className="text-sm font-bold">Rp {total.toLocaleString('id-ID')}</p>
</div>
</Button>
</SheetTrigger>
<SheetContent side="bottom" showCloseButton={false} className="p-0 !h-[90vh] rounded-t-[2.5rem] flex flex-col overflow-hidden">
{CartFormContent()}
</SheetContent>
</Sheet>
</div>
</div>
</div>
{/* Quantity Input Dialog */}
<Dialog open={qtyDialogIndex !== null} onOpenChange={(open) => { if (!open) setQtyDialogIndex(null); }}>
<DialogContent className="max-w-xs">
<DialogHeader>
<DialogTitle>Ubah Jumlah</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label className="text-sm mb-2 block">
{qtyDialogIndex !== null ? cartItems.find(i => i.id === qtyDialogIndex)?.product?.name : ''}
</Label>
<Input
type="number"
min="1"
value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
className="text-lg font-bold"
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" className="gap-2" onClick={() => setQtyDialogIndex(null)}>
<X className="size-4" />
Batal
</Button>
<Button className="gap-2" onClick={confirmQty}>
<Check className="size-4" />
Konfirmasi
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
OrderCreate.layout = {
breadcrumbs: [
{ title: 'Kelola' },
],
};