diff --git a/app/Http/Controllers/Admin/Manage/PurchaseCartController.php b/app/Http/Controllers/Admin/Manage/PurchaseCartController.php new file mode 100644 index 0000000..7c3e3a1 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/PurchaseCartController.php @@ -0,0 +1,72 @@ +validated(); + + $item = PurchaseItem::where('user_id', auth()->id()) + ->where('purchase_id', null) + ->where('product_id', $validated['product_id']) + ->first(); + + if ($item) { + $newQuantity = $item->quantity + $validated['quantity']; + + if ($newQuantity <= 0) { + $item->delete(); + } else { + $item->update([ + 'quantity' => $newQuantity, + 'total_price' => $newQuantity * $item->unit_price, + ]); + } + } else { + if ($validated['quantity'] > 0) { + PurchaseItem::create([ + 'user_id' => auth()->id(), + 'purchase_id' => null, + 'product_id' => $validated['product_id'], + 'quantity' => $validated['quantity'], + 'unit_price' => $validated['unit_price'], + 'total_price' => $validated['quantity'] * $validated['unit_price'], + ]); + } + } + + return redirect()->back(); + } + + public function removeFromCart(PurchaseItem $purchaseItem): RedirectResponse + { + if ($purchaseItem->user_id === auth()->id() && $purchaseItem->purchase_id === null) { + $purchaseItem->delete(); + } + + return redirect()->back(); + } + + public function updateCartItem(AddToCartRequest $request, PurchaseItem $purchaseItem): RedirectResponse + { + if ($purchaseItem->user_id !== auth()->id() || $purchaseItem->purchase_id !== null) { + return redirect()->back(); + } + + $validated = $request->validated(); + + $purchaseItem->update([ + 'quantity' => $validated['quantity'], + 'total_price' => $validated['quantity'] * $purchaseItem->unit_price, + ]); + + return redirect()->back(); + } +} diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php new file mode 100644 index 0000000..2a1ae1e --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -0,0 +1,123 @@ + Purchase::latest()->get(), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/purchase/create', [ + 'products' => Product::with(['prices', 'categories'])->active()->get(), + 'cartItems' => PurchaseItem::with(['product.prices', 'product.categories']) + ->where('user_id', auth()->id()) + ->where('purchase_id', null) + ->latest() + ->get(), + ]); + } + + public function store(PurchaseRequest $request): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated) { + $total = collect($validated['items'])->sum(fn ($item) => $item['quantity'] * $item['unit_price']); + + $purchase = Purchase::create([ + 'purchase_date' => $validated['purchase_date'], + 'note' => $validated['note'], + 'total' => $total, + ]); + + foreach ($validated['items'] as $item) { + PurchaseItem::create([ + 'user_id' => auth()->id(), + 'purchase_id' => $purchase->id, + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_price' => $item['quantity'] * $item['unit_price'], + ]); + } + + PurchaseItem::where('user_id', auth()->id()) + ->where('purchase_id', null) + ->delete(); + }); + + return redirect()->route('purchase.index')->with('success', 'Data berhasil disimpan'); + } + + public function edit(Purchase $purchase): Response + { + $purchase->load(['items.product']); + + return Inertia::render('admin/manage/purchase/edit', [ + 'purchase' => $purchase, + 'products' => Product::with(['prices', 'categories'])->active()->get(), + ]); + } + + public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated, $purchase) { + $total = collect($validated['items'])->sum(fn ($item) => $item['quantity'] * $item['unit_price']); + + $purchase->update([ + 'purchase_date' => $validated['purchase_date'], + 'note' => $validated['note'], + 'total' => $total, + ]); + + $purchase->items()->delete(); + + foreach ($validated['items'] as $item) { + $purchase->items()->create([ + 'user_id' => auth()->id(), + 'product_id' => $item['product_id'], + 'quantity' => $item['quantity'], + 'unit_price' => $item['unit_price'], + 'total_price' => $item['quantity'] * $item['unit_price'], + ]); + } + }); + + return redirect()->route('purchase.index')->with('success', 'Data berhasil diperbarui'); + } + + public function destroy(Purchase $purchase): RedirectResponse + { + $purchase->delete(); + + return redirect()->back()->with('success', 'Data berhasil dihapus'); + } + + public function bulkDestroy(Request $request): RedirectResponse + { + $ids = $request->input('ids'); + + Purchase::whereIn('id', $ids)->delete(); + + return redirect()->back()->with('success', 'Data terpilih berhasil dihapus'); + } +} diff --git a/app/Http/Requests/Admin/Manage/Purchase/AddToCartRequest.php b/app/Http/Requests/Admin/Manage/Purchase/AddToCartRequest.php new file mode 100644 index 0000000..a504ce9 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/Purchase/AddToCartRequest.php @@ -0,0 +1,39 @@ +|string> + */ + public function rules(): array + { + return [ + 'product_id' => [ + Rule::requiredIf($this->isMethod('post')), + Rule::exists('products', 'id'), + ], + 'quantity' => ['required', 'integer', 'min:1'], + 'unit_price' => [ + Rule::requiredIf($this->isMethod('post')), + 'integer', + 'min:0', + ], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/Purchase/PurchaseRequest.php b/app/Http/Requests/Admin/Manage/Purchase/PurchaseRequest.php new file mode 100644 index 0000000..9e82083 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/Purchase/PurchaseRequest.php @@ -0,0 +1,35 @@ +|string> + */ + public function rules(): array + { + return [ + 'purchase_date' => ['required', 'date'], + 'note' => ['nullable', 'string', 'max:100'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.product_id' => ['required', Rule::exists('products', 'id')], + 'items.*.quantity' => ['required', 'integer', 'min:1'], + 'items.*.unit_price' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php new file mode 100644 index 0000000..054395a --- /dev/null +++ b/app/Models/Purchase.php @@ -0,0 +1,38 @@ + 'date', + 'total' => 'integer', + ]; + } + + protected function totalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'), + ); + } + + public function items(): HasMany + { + return $this->hasMany(PurchaseItem::class); + } +} diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php new file mode 100644 index 0000000..bbf4b4d --- /dev/null +++ b/app/Models/PurchaseItem.php @@ -0,0 +1,56 @@ + 'integer', + 'unit_price' => 'integer', + 'total_price' => 'integer', + ]; + } + + protected function unitPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'), + ); + } + + protected function totalPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total_price, 0, ',', '.'), + ); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/database/factories/PurchaseFactory.php b/database/factories/PurchaseFactory.php new file mode 100644 index 0000000..d61d275 --- /dev/null +++ b/database/factories/PurchaseFactory.php @@ -0,0 +1,26 @@ + + */ +class PurchaseFactory extends Factory +{ + protected $model = Purchase::class; + + /** + * @return array + */ + public function definition(): array + { + return [ + 'purchase_date' => $this->faker->dateTimeBetween('-1 month', 'now')->format('Y-m-d'), + 'total' => 0, // Calculated later in seeder + 'note' => $this->faker->optional()->sentence(), + ]; + } +} diff --git a/database/factories/PurchaseItemFactory.php b/database/factories/PurchaseItemFactory.php new file mode 100644 index 0000000..2581622 --- /dev/null +++ b/database/factories/PurchaseItemFactory.php @@ -0,0 +1,35 @@ + + */ +class PurchaseItemFactory extends Factory +{ + protected $model = PurchaseItem::class; + + /** + * @return array + */ + public function definition(): array + { + $quantity = $this->faker->numberBetween(1, 10); + $unitPrice = $this->faker->numberBetween(10000, 500000); + + return [ + 'purchase_id' => Purchase::factory(), + 'user_id' => User::factory(), + 'product_id' => Product::factory(), + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $quantity * $unitPrice, + ]; + } +} diff --git a/database/migrations/2026_04_18_164259_create_purchases_table.php b/database/migrations/2026_04_18_164259_create_purchases_table.php new file mode 100644 index 0000000..7d5abc7 --- /dev/null +++ b/database/migrations/2026_04_18_164259_create_purchases_table.php @@ -0,0 +1,32 @@ +id(); + $table->date('purchase_date'); + $table->unsignedInteger('total'); + $table->string('note', 100)->nullable(); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchases'); + } +}; diff --git a/database/migrations/2026_04_18_164307_create_purchase_items_table.php b/database/migrations/2026_04_18_164307_create_purchase_items_table.php new file mode 100644 index 0000000..b78be6d --- /dev/null +++ b/database/migrations/2026_04_18_164307_create_purchase_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('purchase_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('quantity'); + $table->unsignedInteger('unit_price'); + $table->unsignedInteger('total_price'); + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->nullable()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('purchase_items'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index eda85a8..c5afec5 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,6 +18,7 @@ public function run(): void CategorySeeder::class, ProductSeeder::class, ExpenseSeeder::class, + PurchaseSeeder::class, ]); } } diff --git a/database/seeders/PurchaseSeeder.php b/database/seeders/PurchaseSeeder.php new file mode 100644 index 0000000..f3ee40b --- /dev/null +++ b/database/seeders/PurchaseSeeder.php @@ -0,0 +1,50 @@ +isEmpty() || $products->isEmpty()) { + return; + } + + Purchase::factory(10)->create()->each(function ($purchase) use ($users, $products) { + $itemsCount = rand(1, 5); + $total = 0; + + for ($i = 0; $i < $itemsCount; $i++) { + $product = $products->random(); + $quantity = rand(1, 10); + $unitPrice = rand(10000, 500000); + $totalPrice = $quantity * $unitPrice; + + PurchaseItem::create([ + 'purchase_id' => $purchase->id, + 'user_id' => $users->random()->id, + 'product_id' => $product->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'total_price' => $totalPrice, + ]); + + $total += $totalPrice; + } + + $purchase->update(['total' => $total]); + }); + } +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index a452982..2c98ddc 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,5 +1,5 @@ import { Link } from '@inertiajs/react'; -import { Boxes, Currency, DollarSign, LayoutGrid, List, ScrollText, User, Wallet, WalletCardsIcon } from 'lucide-react'; +import { Boxes, Currency, DollarSign, LayoutGrid, List, ScrollText, ShoppingCart, User, Wallet, WalletCardsIcon } from 'lucide-react'; import AppLogo from '@/components/app-logo'; import { NavMain } from '@/components/nav-main'; import { @@ -19,6 +19,7 @@ import expense from '@/routes/expense'; import payroll from '@/routes/payroll'; import user from '@/routes/user'; import system from '@/routes/system'; +import purchase from '@/routes/purchase'; const mainNavItems: NavItem[] = [ { @@ -46,6 +47,14 @@ const masterNavItems: NavItem[] = [ }, ]; +const manageNavItems: NavItem[] = [ + { + title: 'Belanja', + href: purchase.index().url, + icon: ShoppingCart, + }, +]; + const financeNavItems: NavItem[] = [ { title: 'Pengeluaran', @@ -85,6 +94,7 @@ export function AppSidebar() { + diff --git a/resources/js/pages/admin/manage/purchase/create.tsx b/resources/js/pages/admin/manage/purchase/create.tsx new file mode 100644 index 0000000..e8791f9 --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/create.tsx @@ -0,0 +1,814 @@ +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 } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import purchaseRoutes from '@/routes/purchase'; +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Product, ProductPrice } from '@/types'; +import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +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, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; +import { + Sheet, + SheetContent, + SheetTrigger, + SheetClose, +} from "@/components/ui/sheet"; + +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 PurchaseCreate({ products, cartItems }: { products: Product[], cartItems: (CartItem & { id: number })[] }) { + const [isCalendarOpen, setIsCalendarOpen] = useState(false); + const [search, setSearch] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [qtyDialogIndex, setQtyDialogIndex] = useState(null); + const [qtyInputValue, setQtyInputValue] = useState(''); + + const categories = useMemo(() => { + const map = new Map(); + 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({ + purchase_date: format(new Date(), 'yyyy-MM-dd'), + note: '', + 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) => + cartItems.find(item => item.product_id === productId); + + const addToCart = (product: Product) => { + const unitPrice = getPurchasePrice(product.prices); + router.post(purchaseRoutes.addToCart().url, { + product_id: product.id, + quantity: 1, + unit_price: unitPrice + }, { + preserveScroll: true, + }); + }; + + const decreaseQuantity = (product: Product, e: React.MouseEvent) => { + e.stopPropagation(); + const item = getCartItem(product.id); + if (!item) return; + + router.post(purchaseRoutes.addToCart().url, { + product_id: product.id, + quantity: -1, + unit_price: item.unit_price + }, { + preserveScroll: true, + }); + }; + + const increaseQuantity = (product: Product, e: React.MouseEvent) => { + e.stopPropagation(); + addToCart(product); + }; + + const removeFromCart = (itemId: number) => { + router.delete(purchaseRoutes.removeFromCart(itemId).url, { + preserveScroll: true, + }); + }; + + const updateCartQuantity = (itemId: number, quantity: number) => { + if (quantity < 1) return; + router.patch(purchaseRoutes.updateCartItem(itemId).url, { + quantity + }, { + preserveScroll: true, + }); + }; + + const openQtyDialog = (item: CartItem & { id: number }, e: React.MouseEvent) => { + e.stopPropagation(); + setQtyInputValue(String(item.quantity)); + 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, + quantity: item.quantity, + unit_price: item.unit_price + })) + })); + + post(purchaseRoutes.store().url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + }, + }); + }; + + const total = useMemo(() => { + return cartItems.reduce((acc, item) => acc + (item.quantity * item.unit_price), 0); + }, [cartItems]); + + const cartTotalItems = cartItems.reduce((a, i) => a + i.quantity, 0); + + const CartFormContent = () => ( +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {cartItems.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {cartItems.map((item) => ( +
+ {/* Product info row */} +
+
+ {item.product.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+ {/* Qty + price text */} +
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + setIsCalendarOpen(false); + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{cartItems.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+ ); + + return ( +
+ + +
+

Tambah Belanja

+ + + +
+ +
+ {/* ── Product Grid (Scrollable) ── */} +
+ {/* Search + Category Filter */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {/* Scrollable Area for Product Cards */} + +
+ {filteredProducts.map((product) => { + const cartItem = getCartItem(product.id); + const displayPrice = getPurchasePriceLabel(product.prices); + + return ( + addToCart(product)} + > + + {/* Full Image */} +
+ {product.thumbnail_url ? ( + {product.name} + ) : ( +
+ +
+ )} +
+ + {/* Categories + Name */} +
+
+ {product.categories?.map(cat => ( + + {cat.name} + + ))} +
+

+ {product.name} +

+
+ + {/* Price + Stepper */} +
e.stopPropagation()} + > +
+ + {displayPrice} + +
+
+ + + +
+
+
+
+ ); + })} + + {filteredProducts.length === 0 && ( +
+ + + Ooops... + + Tidak ada data yang ditemukan. + + + +
+ )} +
+
+
+ + {/* ── Cart Sidebar (Fixed on Desktop) ── */} +
+
+ +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {cartItems.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {cartItems.map((item) => ( +
+
+
+ {item.product.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + setIsCalendarOpen(false); + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{cartItems.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+
+
+
+ + {/* ── Floating Mobile Cart Trigger ── */} +
+
+ + + + + + {/* Header */} +
+
+ + Keranjang +
+
+ + {cartTotalItems} pcs + + + + +
+
+ + {/* Scrollable cart items — min-h-0 wajib agar flex-1 bisa scroll */} +
+ {cartItems.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {cartItems.map((item) => ( +
+
+
+ {item.product.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{cartItems.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+
+
+
+
+ + {/* Quantity Input Dialog */} + { if (!open) setQtyDialogIndex(null); }}> + + + Ubah Jumlah + +
+ + setQtyInputValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} + className="text-lg font-bold" + autoFocus + /> +
+ + + + +
+
+
+ ); +} + +PurchaseCreate.layout = { + breadcrumbs: [{ title: 'Kelola' }], +}; diff --git a/resources/js/pages/admin/manage/purchase/edit.tsx b/resources/js/pages/admin/manage/purchase/edit.tsx new file mode 100644 index 0000000..ce80261 --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/edit.tsx @@ -0,0 +1,818 @@ +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 } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import purchaseRoutes from '@/routes/purchase'; +import React, { useMemo, useState } from 'react'; +import { toast } from 'sonner'; +import { Product, ProductPrice, Purchase } from '@/types'; +import { Plus, Trash2, CalendarIcon, Minus, ShoppingCart, Search, ImagePlus, X } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Calendar } from '@/components/ui/calendar'; +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, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '@/components/ui/empty'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, + SheetClose, +} from "@/components/ui/sheet"; + +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('all'); + const [qtyDialogIndex, setQtyDialogIndex] = useState(null); + const [qtyInputValue, setQtyInputValue] = useState(''); + + const categories = useMemo(() => { + const map = new Map(); + 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) => { + toast.success(response.props.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 = () => ( +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {data.items.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {data.items.map((item) => ( +
+ {/* Product info row */} +
+
+ {item.product?.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product?.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+ {/* Qty + price text */} +
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ + {/* Footer */} +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + setIsCalendarOpen(false); + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{data.items.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+ ); + + return ( +
+ + +
+

Ubah Belanja

+ + + +
+ +
+ {/* ── Product Grid (Scrollable) ── */} +
+ {/* Search + Category Filter */} +
+
+ + setSearch(e.target.value)} + /> +
+ +
+ + {/* Scrollable Area for Product Cards */} + +
+ {filteredProducts.map((product) => { + const cartItem = getCartItem(product.id); + const displayPrice = getPurchasePriceLabel(product.prices); + + return ( + addToCart(product)} + > + + {/* Full Image */} +
+ {product.thumbnail_url ? ( + {product.name} + ) : ( +
+ +
+ )} +
+ + {/* Categories + Name */} +
+
+ {product.categories?.map(cat => ( + + {cat.name} + + ))} +
+

+ {product.name} +

+
+ + {/* Price + Stepper */} +
e.stopPropagation()} + > +
+ + {displayPrice} + +
+
+ + + +
+
+
+
+ ); + })} + + {filteredProducts.length === 0 && ( +
+ + + Ooops... + + Tidak ada data yang ditemukan. + + + +
+ )} +
+
+
+ + {/* ── Cart Sidebar (Fixed on Desktop) ── */} +
+
+ +
+ + + + Keranjang + + + {cartTotalItems} pcs + + + + + + {data.items.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {data.items.map((item) => ( +
+
+
+ {item.product?.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product?.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+
+ +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + setIsCalendarOpen(false); + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{data.items.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+
+
+
+ + {/* ── Floating Mobile Cart Trigger ── */} +
+
+ + + + + + {/* Header */} +
+
+ + Keranjang +
+
+ + {cartTotalItems} pcs + + + + +
+
+ + {/* Scrollable cart items — min-h-0 wajib agar flex-1 bisa scroll */} +
+ {data.items.length === 0 ? ( +
+ +

Keranjang masih kosong

+

Klik produk untuk menambahkan

+
+ ) : ( +
+ {data.items.map((item) => ( +
+
+
+ {item.product?.thumbnail_url ? ( + {item.product.name} + ) : ( +
+ +
+ )} +
+
+

{item.product?.name}

+

+ Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')} +

+
+ +
+
+
+ {item.quantity} pcs +
+ × + + Rp {item.unit_price.toLocaleString('id-ID')} + +
+
+ ))} +
+ )} +
+ + {/* Footer */} +
+ + + + + + + + { + if (selectedDate) { + setData('purchase_date', format(selectedDate, 'yyyy-MM-dd')); + } else { + setData('purchase_date', ''); + } + }} + /> + + + + + + + setData('note', e.target.value)} placeholder="...." /> + + +
+
+

Total Belanja

+

Rp {total.toLocaleString('id-ID')}

+
+
+

{data.items.length} produk

+

{cartTotalItems} pcs

+
+
+ + +
+
+
+
+
+
+ + {/* Quantity Input Dialog */} + { if (!open) setQtyDialogIndex(null); }}> + + + Ubah Jumlah + +
+ + setQtyInputValue(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }} + className="text-center text-lg font-bold" + autoFocus + /> +
+ + + + +
+
+
+ ); +} + +PurchaseEdit.layout = { + breadcrumbs: [{ title: 'Kelola' }], +}; diff --git a/resources/js/pages/admin/manage/purchase/hooks/use-purchase-index.ts b/resources/js/pages/admin/manage/purchase/hooks/use-purchase-index.ts new file mode 100644 index 0000000..025f62a --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/hooks/use-purchase-index.ts @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { Purchase } from '@/types'; +import { router } from '@inertiajs/react'; +import purchaseRoutes from '@/routes/purchase'; +import { toast } from 'sonner'; + +export function usePurchaseIndex() { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false); + const [purchaseToDelete, setPurchaseToDelete] = useState(null); + const [rowsToDelete, setRowsToDelete] = useState([]); + const [rowSelection, setRowSelection] = useState({}); + + const onDelete = (purchase: Purchase) => { + setPurchaseToDelete(purchase); + setIsDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (purchaseToDelete) { + router.delete(purchaseRoutes.destroy(purchaseToDelete.id).url, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsDeleteDialogOpen(false); + setPurchaseToDelete(null); + setRowSelection({}); + }, + }); + } + }; + + const confirmBulkDelete = () => { + router.post(purchaseRoutes.bulkDestroy().url, { + ids: rowsToDelete.map((row: any) => row.id), + _method: 'DELETE' + }, { + onSuccess: (response: any) => { + toast.success(response.props.flash.success); + setIsBulkDeleteDialogOpen(false); + setRowsToDelete([]); + setRowSelection({}); + }, + }); + }; + + return { + isDeleteDialogOpen, + isBulkDeleteDialogOpen, + purchaseToDelete, + rowsToDelete, + rowSelection, + setRowSelection, + setRowsToDelete, + setIsDeleteDialogOpen, + setIsBulkDeleteDialogOpen, + onDelete, + confirmDelete, + confirmBulkDelete, + }; +} diff --git a/resources/js/pages/admin/manage/purchase/index.tsx b/resources/js/pages/admin/manage/purchase/index.tsx new file mode 100644 index 0000000..8aca007 --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/index.tsx @@ -0,0 +1,132 @@ +import { Head, Link } from '@inertiajs/react'; +import type { Purchase } from '@/types'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Trash2 } from 'lucide-react'; +import { format } from 'date-fns'; +import { id } from 'date-fns/locale'; +import { DataTable } from '@/components/data-table'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +import purchaseRoutes from '@/routes/purchase'; +import { usePurchaseIndex } from './hooks/use-purchase-index'; +import { getColumns } from './partials/columns'; + +export default function PurchaseIndex({ purchases }: { purchases: Purchase[] }) { + const { + isDeleteDialogOpen, + isBulkDeleteDialogOpen, + purchaseToDelete, + rowsToDelete, + rowSelection, + setRowSelection, + setRowsToDelete, + setIsDeleteDialogOpen, + setIsBulkDeleteDialogOpen, + onDelete, + confirmDelete, + confirmBulkDelete, + } = usePurchaseIndex(); + + const columns = getColumns({ onDelete }); + + return ( +
+ + +
+
+

Belanja

+
+ + + +
+ + + + { + setRowsToDelete(rows); + setIsBulkDeleteDialogOpen(true); + }, + icon: Trash2, + variant: 'destructive' + }, + ]} + /> + + + + {/* Single Delete Confirmation */} + + + + + + + Hapus data belanja? + + Tindakan ini tidak dapat dibatalkan. Data belanja tanggal {purchaseToDelete && format(new Date(purchaseToDelete.purchase_date), 'dd MMMM yyyy', { locale: id })} akan dihapus secara permanen. + + + + Batal + Hapus + + + + + {/* Bulk Delete Confirmation */} + + + + + + + Hapus {rowsToDelete.length} data belanja? + + Tindakan ini tidak dapat dibatalkan. {rowsToDelete.length} item yang terpilih akan dihapus secara permanen. + + + + Batal + + Hapus + + + + +
+ ); +} + +PurchaseIndex.layout = { + breadcrumbs: [ + { + title: 'Kelola', + }, + ], +}; diff --git a/resources/js/pages/admin/manage/purchase/partials/columns.tsx b/resources/js/pages/admin/manage/purchase/partials/columns.tsx new file mode 100644 index 0000000..b1804b1 --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/partials/columns.tsx @@ -0,0 +1,72 @@ +import { ColumnDef } from '@tanstack/react-table'; +import { Purchase } from '@/types'; +import { DataTableColumnHeader } from '@/components/data-table-column-header'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Pencil, Trash2 } from 'lucide-react'; +import { Link } from '@inertiajs/react'; +import purchaseRoutes from '@/routes/purchase'; +import { format } from 'date-fns'; +import { id } from 'date-fns/locale'; + +interface ColumnProps { + onDelete: (purchase: Purchase) => void; +} + +export const getColumns = ({ onDelete }: ColumnProps): ColumnDef[] => [ + { + accessorKey: "purchase_date", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.original.purchase_date; + return format(new Date(date), 'dd MMMM yyyy', { locale: id }); + }, + meta: { title: "Tanggal" }, + }, + { + accessorKey: "note", + header: "Catatan", + cell: ({ row }) => ( + + {row.original.note || '-'} + + ), + meta: { title: "Catatan" }, + }, + { + id: "actions", + header: "Aksi", + cell: ({ row }) => { + const purchase = row.original; + return ( +
+ + + + + + + +

Ubah

+
+
+ + + + + +

Hapus

+
+
+
+ ); + }, + meta: { title: "Aksi" }, + }, +]; diff --git a/resources/js/types/index.ts b/resources/js/types/index.ts index 8e8a9f1..471f2ca 100644 --- a/resources/js/types/index.ts +++ b/resources/js/types/index.ts @@ -5,3 +5,4 @@ export type * from './category'; export type * from './product'; export type * from './expense'; export type * from './payroll'; +export type * from './purchase'; diff --git a/resources/js/types/purchase.ts b/resources/js/types/purchase.ts new file mode 100644 index 0000000..437e678 --- /dev/null +++ b/resources/js/types/purchase.ts @@ -0,0 +1,27 @@ +import { Product } from "./product"; + +export interface Purchase { + id: number; + purchase_date: string; + total: number; + total_formatted: string; + note: string | null; + items?: PurchaseItem[]; + created_at: string; + updated_at: string; +} + +export interface PurchaseItem { + id: number; + purchase_id: number; + user_id: number; + product_id: number; + quantity: number; + unit_price: number; + unit_price_formatted: string; + total_price: number; + total_price_formatted: string; + product?: Product; + created_at: string; + updated_at: string; +} diff --git a/routes/manage.php b/routes/manage.php new file mode 100644 index 0000000..bac7b07 --- /dev/null +++ b/routes/manage.php @@ -0,0 +1,22 @@ +group(function () { + Route::prefix('admin/manage')->group(function () { + Route::get('purchases', [PurchaseController::class, 'index'])->name('purchase.index'); + Route::get('purchase/create', [PurchaseController::class, 'create'])->name('purchase.create'); + Route::post('purchase/store', [PurchaseController::class, 'store'])->name('purchase.store'); + Route::get('purchase/edit/{purchase}', [PurchaseController::class, 'edit'])->name('purchase.edit'); + Route::patch('purchase/update/{purchase}', [PurchaseController::class, 'update'])->name('purchase.update'); + Route::delete('purchase/destroy/{purchase}', [PurchaseController::class, 'destroy'])->name('purchase.destroy'); + Route::delete('purchase/bulk-destroy', [PurchaseController::class, 'bulkDestroy'])->name('purchase.bulkDestroy'); + + // Cart routes + Route::post('purchase/add-to-cart', [PurchaseCartController::class, 'addToCart'])->name('purchase.addToCart'); + Route::delete('purchase/remove-from-cart/{purchaseItem}', [PurchaseCartController::class, 'removeFromCart'])->name('purchase.removeFromCart'); + Route::patch('purchase/update-cart-item/{purchaseItem}', [PurchaseCartController::class, 'updateCartItem'])->name('purchase.updateCartItem'); + }); +}); diff --git a/routes/web.php b/routes/web.php index 3b105ea..9ef362a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,3 +15,4 @@ require __DIR__.'/master.php'; require __DIR__.'/finance.php'; require __DIR__.'/system.php'; +require __DIR__.'/manage.php';