666 lines
34 KiB
TypeScript
666 lines
34 KiB
TypeScript
import { Head, Link, useForm } from '@inertiajs/react';
|
||
import { ArrowLeft, Check, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Tag, Trash2, X } from 'lucide-react';
|
||
import React, { useMemo, useState } from 'react';
|
||
import { NumericFormat } from 'react-number-format';
|
||
import { toast } from 'sonner';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Field, FieldError } from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetContent,
|
||
SheetTrigger
|
||
} from "@/components/ui/sheet";
|
||
import { cn } from '@/lib/utils';
|
||
import { formatCurrency, formatNumber } from '@/lib/formatters';
|
||
import * as orderRoutes from '@/routes/order';
|
||
import type { Product } from '@/types';
|
||
import type { Order } from '@/types/order';
|
||
|
||
type EnumOption = { value: string, label: string };
|
||
|
||
export default function OrderEdit({ order, products, orderStatus, orderChannels, paymentMethods, priceTypes }: {
|
||
order: Order,
|
||
products: Product[],
|
||
orderStatus: EnumOption[],
|
||
orderChannels: EnumOption[],
|
||
paymentMethods: EnumOption[],
|
||
priceTypes: EnumOption[]
|
||
}) {
|
||
const [search, setSearch] = useState('');
|
||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||
const [qtyDialogIndex, setQtyDialogIndex] = useState<{ product_id: number, price_type: string } | null>(null);
|
||
const [qtyInputValue, setQtyInputValue] = useState('');
|
||
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, patch, processing, errors, transform } = useForm({
|
||
customer_name: order.customer_name,
|
||
subtotal: order.subtotal,
|
||
discount: order.discount,
|
||
payment: order.payment,
|
||
payment_method: order.payment_method,
|
||
order_status: order.order_status,
|
||
order_channel: order.order_channel,
|
||
items: (order.items?.map(item => ({
|
||
product_id: item.product_id,
|
||
qty: item.qty,
|
||
price: item.price,
|
||
total: item.total,
|
||
price_type: item.price_type,
|
||
product: products.find(p => p.id === item.product_id) || item.product
|
||
})) ?? []) 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 findItemIndex = (productId: number, priceType: string) =>
|
||
data.items.findIndex(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 index = findItemIndex(product.id, priceType);
|
||
const price = getProductPrice(product, priceType);
|
||
|
||
if (index > -1) {
|
||
const newItems = [...data.items];
|
||
newItems[index].qty += 1;
|
||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||
setData('items', newItems);
|
||
} else {
|
||
setData('items', [
|
||
...data.items,
|
||
{
|
||
product_id: product.id,
|
||
qty: 1,
|
||
price: price,
|
||
total: price,
|
||
price_type: priceType,
|
||
product: product
|
||
}
|
||
]);
|
||
}
|
||
};
|
||
|
||
const decreaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const index = findItemIndex(productId, priceType);
|
||
|
||
if (index === -1) {
|
||
return;
|
||
}
|
||
|
||
const newItems = [...data.items];
|
||
|
||
if (newItems[index].qty <= 1) {
|
||
newItems.splice(index, 1);
|
||
} else {
|
||
newItems[index].qty -= 1;
|
||
newItems[index].total = newItems[index].qty * newItems[index].price;
|
||
}
|
||
|
||
setData('items', newItems);
|
||
};
|
||
|
||
const increaseQuantity = (productId: number, priceType: string, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const product = products.find(p => p.id === productId);
|
||
|
||
if (product) {
|
||
addToCart(product, priceType);
|
||
}
|
||
};
|
||
|
||
const removeFromCart = (productId: number, priceType: string) => {
|
||
const newItems = data.items.filter(item => !(item.product_id === productId && item.price_type === priceType));
|
||
setData('items', newItems);
|
||
};
|
||
|
||
const updateCartQuantity = (productId: number, priceType: string, qty: number) => {
|
||
if (qty < 1) {
|
||
return;
|
||
}
|
||
|
||
const index = findItemIndex(productId, priceType);
|
||
|
||
if (index > -1) {
|
||
const newItems = [...data.items];
|
||
newItems[index].qty = qty;
|
||
newItems[index].total = qty * newItems[index].price;
|
||
setData('items', newItems);
|
||
}
|
||
};
|
||
|
||
const openQtyDialog = (item: any, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setQtyInputValue(String(item.qty));
|
||
setQtyDialogIndex({ product_id: item.product_id, price_type: item.price_type });
|
||
};
|
||
|
||
const confirmQty = () => {
|
||
const val = parseInt(qtyInputValue);
|
||
|
||
if (!isNaN(val) && val >= 1 && qtyDialogIndex) {
|
||
updateCartQuantity(qtyDialogIndex.product_id, qtyDialogIndex.price_type, val);
|
||
}
|
||
|
||
setQtyDialogIndex(null);
|
||
};
|
||
|
||
const onSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
|
||
if (data.items.length === 0) {
|
||
toast.error('Pilih minimal satu produk');
|
||
|
||
return;
|
||
}
|
||
|
||
transform((data) => ({
|
||
...data,
|
||
subtotal: subtotal,
|
||
}));
|
||
|
||
patch(orderRoutes.update(order.id).url, {
|
||
onSuccess: (response: any) => {
|
||
const flash = response.props.flash;
|
||
if (flash?.error) {
|
||
toast.error(flash.error);
|
||
} else if (flash?.success) {
|
||
toast.success(flash.success);
|
||
}
|
||
},
|
||
});
|
||
};
|
||
|
||
const subtotal = useMemo(() => {
|
||
return data.items.reduce((acc, item) => acc + item.total, 0);
|
||
}, [data.items]);
|
||
|
||
const total = subtotal - data.discount;
|
||
const change = data.payment - total;
|
||
const cartTotalItems = data.items.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">
|
||
{data.items.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>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{data.items.map((item, idx) => (
|
||
<div key={idx} 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">
|
||
{formatCurrency(item.total)}
|
||
</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.product_id, item.price_type)}
|
||
>
|
||
<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.product_id, item.price_type, 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.product_id, item.price_type, e)} className="p-1 hover:text-primary"><Plus className="h-3 w-3" /></button>
|
||
</div>
|
||
<span>×</span>
|
||
<span className="font-mono">
|
||
{formatCurrency(item.price)}
|
||
</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} autoComplete="off" onChange={e => setData('customer_name', e.target.value)} />
|
||
<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 text-xs">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted-foreground">Subtotal</span>
|
||
<span className="font-medium">{formatCurrency(subtotal)}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<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'
|
||
/>
|
||
</div>
|
||
<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">{formatCurrency(total)}</span>
|
||
</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 && data.payment > 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">{formatCurrency(change)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<Button
|
||
type="submit"
|
||
className="w-full h-10 font-semibold shadow gap-2"
|
||
disabled={processing || data.items.length === 0}
|
||
onClick={() => {
|
||
if (data.items.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="Ubah Pesanan" />
|
||
|
||
<div className="flex items-center justify-between shrink-0">
|
||
<h1 className="text-3xl font-bold tracking-tight">Ubah 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 index = findItemIndex(product.id, globalPriceType);
|
||
const cartItem = index > -1 ? data.items[index] : null;
|
||
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">
|
||
{formatCurrency(currentPrice)}
|
||
</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(product.id, globalPriceType, 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(product.id, globalPriceType, e) : addToCart(product, globalPriceType);
|
||
}}
|
||
>
|
||
<Plus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
</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"
|
||
>
|
||
<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">{formatCurrency(total)}</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 ? data.items.find(i => i.product_id === qtyDialogIndex.product_id && i.price_type === qtyDialogIndex.price_type)?.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>
|
||
);
|
||
}
|
||
|
||
OrderEdit.layout = {
|
||
breadcrumbs: [
|
||
{ title: 'Kelola' },
|
||
],
|
||
};
|