857 lines
51 KiB
TypeScript
857 lines
51 KiB
TypeScript
import { Badge } from '@/components/ui/badge';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Calendar } from '@/components/ui/calendar';
|
||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty';
|
||
import { Field, FieldError } from "@/components/ui/field";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import {
|
||
Sheet,
|
||
SheetClose,
|
||
SheetContent,
|
||
SheetTrigger,
|
||
} from "@/components/ui/sheet";
|
||
import { formatCurrency } from '@/lib/formatters';
|
||
import { cn } from '@/lib/utils';
|
||
import purchaseRoutes from '@/routes/purchase';
|
||
import type { Product, ProductPrice, Purchase } from '@/types';
|
||
import { Head, Link, useForm } from '@inertiajs/react';
|
||
import { format } from 'date-fns';
|
||
import { ArrowLeft, CalendarIcon, ImagePlus, Loader, Minus, Plus, Save, Search, ShoppingCart, Trash2, X } from 'lucide-react';
|
||
import React, { useMemo, useState } from 'react';
|
||
import { toast } from 'sonner';
|
||
|
||
type CartItem = { product_id: number; quantity: number; unit_price: number; product: Product };
|
||
|
||
function getPurchasePrice(prices: ProductPrice[] | undefined): number {
|
||
return prices?.find(p => p.price_type === 'purchase')?.price ?? 0;
|
||
}
|
||
|
||
function getPurchasePriceLabel(prices: ProductPrice[] | undefined): string {
|
||
return prices?.find(p => p.price_type === 'purchase')?.price_formatted ?? 'Rp 0';
|
||
}
|
||
|
||
export default function PurchaseEdit({ purchase, products }: { purchase: Purchase, products: Product[] }) {
|
||
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||
const [qtyDialogIndex, setQtyDialogIndex] = useState<number | null>(null);
|
||
const [qtyInputValue, setQtyInputValue] = useState('');
|
||
|
||
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 } = useForm({
|
||
purchase_date: purchase.purchase_date || format(new Date(), 'yyyy-MM-dd'),
|
||
note: purchase.note || '',
|
||
items: (purchase.items?.map(item => ({
|
||
product_id: item.product_id,
|
||
quantity: item.quantity,
|
||
unit_price: item.unit_price,
|
||
product: products.find(p => p.id === item.product_id) ?? item.product,
|
||
})) ?? []) as CartItem[],
|
||
});
|
||
|
||
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) =>
|
||
data.items.find(item => item.product_id === productId);
|
||
|
||
const addToCart = (product: Product) => {
|
||
const existingIndex = data.items.findIndex(i => i.product_id === product.id);
|
||
const unitPrice = getPurchasePrice(product.prices);
|
||
|
||
if (existingIndex > -1) {
|
||
const newItems = [...data.items];
|
||
newItems[existingIndex].quantity += 1;
|
||
setData('items', newItems);
|
||
} else {
|
||
setData('items', [
|
||
...data.items,
|
||
{ product_id: product.id, quantity: 1, unit_price: unitPrice, product }
|
||
]);
|
||
}
|
||
};
|
||
|
||
const decreaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
const existingIndex = data.items.findIndex(i => i.product_id === product.id);
|
||
|
||
if (existingIndex === -1) {
|
||
return;
|
||
}
|
||
|
||
const newItems = [...data.items];
|
||
|
||
if (newItems[existingIndex].quantity <= 1) {
|
||
newItems.splice(existingIndex, 1);
|
||
} else {
|
||
newItems[existingIndex].quantity -= 1;
|
||
}
|
||
|
||
setData('items', newItems);
|
||
};
|
||
|
||
const increaseQuantity = (product: Product, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
addToCart(product);
|
||
};
|
||
|
||
const removeFromCart = (productId: number) => {
|
||
const newItems = data.items.filter(item => item.product_id !== productId);
|
||
setData('items', newItems);
|
||
};
|
||
|
||
const updateCartQuantity = (productId: number, quantity: number) => {
|
||
if (quantity < 1) {
|
||
return;
|
||
}
|
||
|
||
const newItems = [...data.items];
|
||
const index = newItems.findIndex(i => i.product_id === productId);
|
||
|
||
if (index > -1) {
|
||
newItems[index].quantity = quantity;
|
||
setData('items', newItems);
|
||
}
|
||
};
|
||
|
||
|
||
const openQtyDialog = (item: CartItem, e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
setQtyInputValue(String(item.quantity));
|
||
setQtyDialogIndex(item.product_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 (data.items.length === 0) {
|
||
toast.error('Pilih minimal satu produk');
|
||
|
||
return;
|
||
}
|
||
|
||
patch(purchaseRoutes.update(purchase.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 total = useMemo(() => {
|
||
return data.items.reduce((acc, item) => acc + (item.quantity * item.unit_price), 0);
|
||
}, [data.items]);
|
||
|
||
const cartTotalItems = data.items.reduce((a, i) => a + i.quantity, 0);
|
||
|
||
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>
|
||
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{data.items.map((item) => (
|
||
<div key={item.product_id} className="p-4 space-y-2.5">
|
||
{/* Product info row */}
|
||
<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>
|
||
<p className="text-xs text-primary font-medium mt-0.5">
|
||
{formatCurrency(item.quantity * item.unit_price)}
|
||
</p>
|
||
</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)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
{/* Qty + price text */}
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<div className="px-2 py-0.5 rounded bg-muted/50 border font-medium text-foreground">
|
||
{item.quantity} pcs
|
||
</div>
|
||
<span>×</span>
|
||
<span className="font-mono">
|
||
{formatCurrency(item.unit_price)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</ScrollArea>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
|
||
<Field>
|
||
<Label htmlFor='purchase_date' className="text-xs text-muted-foreground" required>Tanggal</Label>
|
||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
id='purchase_date'
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn("w-full justify-start text-left font-normal h-8 text-xs")}
|
||
>
|
||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||
{data.purchase_date ? (
|
||
new Intl.DateTimeFormat("id-ID", {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
}).format(new Date(data.purchase_date))
|
||
) : (
|
||
<span className="text-muted-foreground">Pilih Tanggal</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={data.purchase_date ? new Date(data.purchase_date) : undefined}
|
||
defaultMonth={data.purchase_date ? new Date(data.purchase_date) : new Date()}
|
||
captionLayout="dropdown"
|
||
onSelect={(selectedDate: Date | undefined) => {
|
||
if (selectedDate) {
|
||
setData('purchase_date', format(selectedDate, 'yyyy-MM-dd'));
|
||
} else {
|
||
setData('purchase_date', '');
|
||
}
|
||
|
||
setIsCalendarOpen(false);
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<FieldError error={errors.purchase_date} label="Tanggal" className="text-xs" />
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label htmlFor='note' className="text-xs text-muted-foreground">Catatan</Label>
|
||
<Input id='note' className="h-8 text-xs" value={data.note} onChange={e => setData('note', e.target.value)} autoComplete='off' placeholder="...." />
|
||
<FieldError error={errors.note} label="Catatan" className="text-xs" />
|
||
</Field>
|
||
|
||
<div className="rounded-xl bg-primary/10 border border-primary/20 p-3 flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Total Belanja</p>
|
||
<p className="text-xl font-bold text-primary">{formatCurrency(total)}</p>
|
||
</div>
|
||
<div className="text-right text-xs text-muted-foreground">
|
||
<p>{data.items.length} produk</p>
|
||
<p>{cartTotalItems} pcs</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="submit"
|
||
className="w-full h-10 font-semibold shadow"
|
||
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 Belanja" />
|
||
|
||
<div className="flex items-center justify-between shrink-0">
|
||
<h1 className="text-3xl font-bold tracking-tight">Ubah Belanja</h1>
|
||
<Link href={purchaseRoutes.index().url}>
|
||
<Button variant='outline'>
|
||
<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">
|
||
{/* ── Product Grid (Scrollable) ── */}
|
||
<div className="lg:col-span-8 flex flex-col gap-5 h-full min-h-0">
|
||
{/* Search + Category Filter */}
|
||
<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 produk..."
|
||
className="pl-10"
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||
<SelectTrigger className="w-full sm:w-40 shrink-0">
|
||
<SelectValue placeholder="Semua Kategori" />
|
||
</SelectTrigger>
|
||
<SelectContent position='item-aligned'>
|
||
<SelectItem value="all">Semua</SelectItem>
|
||
{categories.map(cat => (
|
||
<SelectItem key={cat.id} value={String(cat.id)}>{cat.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
{/* Scrollable Area for Product Cards */}
|
||
<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);
|
||
const displayPrice = getPurchasePriceLabel(product.prices);
|
||
|
||
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)}
|
||
>
|
||
<CardContent className="p-4 flex flex-col gap-3">
|
||
{/* Full Image */}
|
||
<div className="-mx-4 -mt-4 aspect-[4/3] overflow-hidden bg-muted/50 rounded-t-xl">
|
||
{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-105"
|
||
/>
|
||
) : (
|
||
<div className="h-full w-full flex items-center justify-center">
|
||
<ImagePlus className="h-10 w-10 text-muted-foreground/30" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Categories + Name */}
|
||
<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">
|
||
{cat.name}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
<h3 className="font-semibold text-sm leading-snug line-clamp-2">
|
||
{product.name}
|
||
</h3>
|
||
</div>
|
||
|
||
{/* Price + Stepper */}
|
||
<div
|
||
className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mt-auto"
|
||
onClick={e => e.stopPropagation()}
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<span className="text-sm font-bold text-foreground tabular-nums truncate block">
|
||
{displayPrice}
|
||
</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) => decreaseQuantity(product, 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?.quantity ?? 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) => increaseQuantity(product, e)}
|
||
>
|
||
<Plus className="h-3.5 w-3.5 sm:h-3 sm:w-3" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
})}
|
||
|
||
{filteredProducts.length === 0 && (
|
||
<div className="col-span-full py-16 text-center text-muted-foreground">
|
||
<Empty>
|
||
<EmptyHeader>
|
||
<EmptyTitle>Ooops...</EmptyTitle>
|
||
<EmptyDescription>
|
||
Tidak ada data yang ditemukan.
|
||
</EmptyDescription>
|
||
</EmptyHeader>
|
||
</Empty>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</div>
|
||
|
||
{/* ── Cart Sidebar (Fixed on Desktop) ── */}
|
||
<div className="hidden lg:block lg:col-span-4 h-full min-h-0">
|
||
<form onSubmit={onSubmit} className="h-full">
|
||
<Card className="border shadow-xl bg-card overflow-hidden h-full flex flex-col">
|
||
<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>
|
||
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{data.items.map((item) => (
|
||
<div key={item.product_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>
|
||
<p className="text-xs text-primary font-medium mt-0.5">
|
||
{formatCurrency(item.quantity * item.unit_price)}
|
||
</p>
|
||
</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)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<div className="px-2 py-0.5 rounded bg-muted/50 border font-medium text-foreground">
|
||
{item.quantity} pcs
|
||
</div>
|
||
<span>×</span>
|
||
<span className="font-mono">
|
||
{formatCurrency(item.unit_price)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</CardContent>
|
||
</ScrollArea>
|
||
|
||
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
|
||
<Field>
|
||
<Label htmlFor="purchase_date" className="text-xs text-muted-foreground">Tanggal</Label>
|
||
<Popover open={isCalendarOpen} onOpenChange={setIsCalendarOpen}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
id="purchase_date"
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn("w-full justify-start text-left font-normal h-8 text-xs")}
|
||
>
|
||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||
{data.purchase_date ? (
|
||
new Intl.DateTimeFormat("id-ID", {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
}).format(new Date(data.purchase_date))
|
||
) : (
|
||
<span className="text-muted-foreground">Pilih Tanggal</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={data.purchase_date ? new Date(data.purchase_date) : undefined}
|
||
defaultMonth={data.purchase_date ? new Date(data.purchase_date) : new Date()}
|
||
captionLayout="dropdown"
|
||
onSelect={(selectedDate: Date | undefined) => {
|
||
if (selectedDate) {
|
||
setData('purchase_date', format(selectedDate, 'yyyy-MM-dd'));
|
||
} else {
|
||
setData('purchase_date', '');
|
||
}
|
||
|
||
setIsCalendarOpen(false);
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
<FieldError error={errors.purchase_date} label="Tanggal" className="text-xs" />
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label htmlFor="note" className="text-xs text-muted-foreground">Catatan</Label>
|
||
<Input id="note" className="h-8 text-xs" value={data.note} onChange={e => setData('note', e.target.value)} placeholder="...." />
|
||
<FieldError error={errors.note} label="Catatan" className="text-xs" />
|
||
</Field>
|
||
|
||
<div className="rounded-xl bg-primary/10 border border-primary/20 p-3 flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Total Belanja</p>
|
||
<p className="text-xl font-bold text-primary">{formatCurrency(total)}</p>
|
||
</div>
|
||
<div className="text-right text-xs text-muted-foreground">
|
||
<p>{data.items.length} produk</p>
|
||
<p>{cartTotalItems} pcs</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="submit"
|
||
className="w-full h-10 font-semibold shadow"
|
||
disabled={processing || data.items.length === 0}
|
||
>
|
||
{processing ? <Loader className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</form>
|
||
</div>
|
||
|
||
{/* ── Floating Mobile Cart Trigger ── */}
|
||
<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">{formatCurrency(total)}</p>
|
||
</div>
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent
|
||
side="bottom"
|
||
showCloseButton={false}
|
||
className="p-0 !h-[85vh] rounded-t-[2.5rem] flex flex-col overflow-hidden gap-0"
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-5 pt-5 pb-4 border-b shrink-0">
|
||
<div className="flex items-center gap-2 font-semibold text-base">
|
||
<ShoppingCart className="h-4 w-4" />
|
||
Keranjang
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Badge className="rounded-full font-bold">
|
||
{cartTotalItems} pcs
|
||
</Badge>
|
||
<SheetClose asChild>
|
||
<button
|
||
className="h-8 w-8 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
|
||
aria-label="Tutup"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</SheetClose>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scrollable cart items — min-h-0 wajib agar flex-1 bisa scroll */}
|
||
<div className="flex-1 min-h-0 overflow-y-auto overscroll-contain">
|
||
{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>
|
||
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
|
||
</div>
|
||
) : (
|
||
<div className="divide-y divide-border">
|
||
{data.items.map((item) => (
|
||
<div key={item.product_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>
|
||
<p className="text-xs text-primary font-medium mt-0.5">
|
||
{formatCurrency(item.quantity * item.unit_price)}
|
||
</p>
|
||
</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)}
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" />
|
||
</Button>
|
||
</div>
|
||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||
<div className="px-2 py-0.5 rounded bg-muted/50 border font-medium text-foreground">
|
||
{item.quantity} pcs
|
||
</div>
|
||
<span>×</span>
|
||
<span className="font-mono">
|
||
{formatCurrency(item.unit_price)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<div className="p-4 border-t bg-muted/10 space-y-3 shrink-0">
|
||
<Field>
|
||
<Label htmlFor="purchase_date_mobile" className="text-xs text-muted-foreground">Tanggal</Label>
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
id="purchase_date_mobile"
|
||
variant="outline"
|
||
size="sm"
|
||
className={cn("w-full justify-start text-left font-normal h-8 text-xs")}
|
||
>
|
||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||
{data.purchase_date ? (
|
||
new Intl.DateTimeFormat("id-ID", {
|
||
day: "numeric",
|
||
month: "long",
|
||
year: "numeric",
|
||
}).format(new Date(data.purchase_date))
|
||
) : (
|
||
<span className="text-muted-foreground">Pilih Tanggal</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto overflow-hidden p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={data.purchase_date ? new Date(data.purchase_date) : undefined}
|
||
defaultMonth={data.purchase_date ? new Date(data.purchase_date) : new Date()}
|
||
captionLayout="dropdown"
|
||
onSelect={(selectedDate: Date | undefined) => {
|
||
if (selectedDate) {
|
||
setData('purchase_date', format(selectedDate, 'yyyy-MM-dd'));
|
||
} else {
|
||
setData('purchase_date', '');
|
||
}
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</Field>
|
||
|
||
<Field>
|
||
<Label htmlFor="note_mobile" className="text-xs text-muted-foreground">Catatan</Label>
|
||
<Input id="note_mobile" className="h-8 text-xs" value={data.note} onChange={e => setData('note', e.target.value)} placeholder="...." />
|
||
<FieldError error={errors.note} label="Catatan" className="text-xs" />
|
||
</Field>
|
||
|
||
<div className="rounded-xl bg-primary/10 border border-primary/20 p-3 flex items-center justify-between">
|
||
<div>
|
||
<p className="text-xs text-muted-foreground">Total Belanja</p>
|
||
<p className="text-xl font-bold text-primary">{formatCurrency(total)}</p>
|
||
</div>
|
||
<div className="text-right text-xs text-muted-foreground">
|
||
<p>{data.items.length} produk</p>
|
||
<p>{cartTotalItems} pcs</p>
|
||
</div>
|
||
</div>
|
||
|
||
<Button
|
||
type="button"
|
||
className="w-full h-10 font-semibold shadow"
|
||
disabled={processing || data.items.length === 0}
|
||
onClick={() => onSubmit({ preventDefault: () => { } } as any)}
|
||
>
|
||
{processing ? <Loader className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
{processing ? 'Menyimpan...' : 'Simpan'}
|
||
</Button>
|
||
</div>
|
||
</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?.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" onClick={() => setQtyDialogIndex(null)}>Batal</Button>
|
||
<Button onClick={confirmQty}>Konfirmasi</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
PurchaseEdit.layout = {
|
||
breadcrumbs: [{ title: 'Kelola' }],
|
||
};
|