feat: implement purchase management system with database schema, CRUD controllers, and UI components

This commit is contained in:
Yoga Pangestu 2026-04-20 22:55:19 +07:00
parent ac537a6b8c
commit 70e0ba9760
22 changed files with 2500 additions and 1 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\Purchase\AddToCartRequest;
use App\Models\PurchaseItem;
use Illuminate\Http\RedirectResponse;
class PurchaseCartController extends Controller
{
public function addToCart(AddToCartRequest $request): RedirectResponse
{
$validated = $request->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();
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace App\Http\Controllers\Admin\Manage;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Manage\Purchase\PurchaseRequest;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class PurchaseController extends Controller
{
public function index(): Response
{
return Inertia::render('admin/manage/purchase/index', [
'purchases' => 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');
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\Admin\Manage\Purchase;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddToCartRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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',
],
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Admin\Manage\Purchase;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PurchaseRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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'],
];
}
}

38
app/Models/Purchase.php Normal file
View File

@ -0,0 +1,38 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['total_formatted'])]
class Purchase extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'purchase_date' => '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);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Appends;
use Illuminate\Database\Eloquent\Attributes\Guarded;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
#[Guarded(['id'])]
#[Appends(['unit_price_formatted', 'total_price_formatted'])]
class PurchaseItem extends Model
{
use HasFactory, SoftDeletes;
protected function casts(): array
{
return [
'quantity' => '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);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace Database\Factories;
use App\Models\Purchase;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Purchase>
*/
class PurchaseFactory extends Factory
{
protected $model = Purchase::class;
/**
* @return array<string, mixed>
*/
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(),
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace Database\Factories;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<PurchaseItem>
*/
class PurchaseItemFactory extends Factory
{
protected $model = PurchaseItem::class;
/**
* @return array<string, mixed>
*/
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,
];
}
}

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('purchases', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('purchase_items', function (Blueprint $table) {
$table->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');
}
};

View File

@ -18,6 +18,7 @@ public function run(): void
CategorySeeder::class,
ProductSeeder::class,
ExpenseSeeder::class,
PurchaseSeeder::class,
]);
}
}

View File

@ -0,0 +1,50 @@
<?php
namespace Database\Seeders;
use App\Models\Product;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\User;
use Illuminate\Database\Seeder;
class PurchaseSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$users = User::all();
$products = Product::all();
if ($users->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]);
});
}
}

View File

@ -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() {
<SidebarContent>
<NavMain items={mainNavItems} />
<NavMain items={masterNavItems} label='Master' />
<NavMain items={manageNavItems} label='Kelola' />
<NavMain items={financeNavItems} label='Keuangan' />
<NavMain items={systemNavItems} label='Sistem' />
</SidebarContent>

View File

@ -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<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, 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 = () => (
<div className="flex flex-col h-full">
<CardHeader className="border-b py-4 flex-row items-center justify-between shrink-0">
<CardTitle className="flex items-center gap-2 text-base">
<ShoppingCart className="h-4 w-4" />
Keranjang
</CardTitle>
<Badge className="rounded-full font-bold">
{cartTotalItems} pcs
</Badge>
</CardHeader>
<ScrollArea className="flex-1 min-h-0">
<CardContent className="p-0">
{cartItems.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Keranjang masih kosong</p>
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
</div>
) : (
<div className="divide-y divide-border">
{cartItems.map((item) => (
<div key={item.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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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.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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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">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>
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{cartItems.length} produk</p>
<p>{cartTotalItems} pcs</p>
</div>
</div>
<Button
type="submit"
className="w-full h-10 font-semibold shadow"
disabled={processing || cartItems.length === 0}
onClick={() => {
if (cartItems.length > 0) {
onSubmit({ preventDefault: () => { } } as any)
}
}}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Checkout Belanja'}
</Button>
</div>
</div>
);
return (
<div className="flex flex-col gap-6 p-6 lg:h-[calc(100vh-64px)] lg:overflow-hidden">
<Head title="Tambah Belanja" />
<div className="flex items-center justify-between shrink-0">
<h1 className="text-3xl font-bold tracking-tight">Tambah Belanja</h1>
<Link href={purchaseRoutes.index().url}>
<Button variant='outline'>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">
{cartItems.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Keranjang masih kosong</p>
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
</div>
) : (
<div className="divide-y divide-border">
{cartItems.map((item) => (
<div key={item.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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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.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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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" 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>
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{cartItems.length} produk</p>
<p>{cartTotalItems} pcs</p>
</div>
</div>
<Button
type="submit"
className="w-full h-10 font-semibold shadow"
disabled={processing || cartItems.length === 0}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Checkout Belanja'}
</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">Rp {total.toLocaleString('id-ID')}</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">
{cartItems.length === 0 ? (
<div className="py-16 text-center text-muted-foreground">
<ShoppingCart className="h-10 w-10 mx-auto mb-3 opacity-20" />
<p className="text-sm">Keranjang masih kosong</p>
<p className="text-xs mt-1 opacity-60">Klik produk untuk menambahkan</p>
</div>
) : (
<div className="divide-y divide-border">
{cartItems.map((item) => (
<div key={item.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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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.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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</p>
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{cartItems.length} produk</p>
<p>{cartTotalItems} pcs</p>
</div>
</div>
<Button
type="button"
className="w-full h-10 font-semibold shadow"
disabled={processing || cartItems.length === 0}
onClick={() => onSubmit({ preventDefault: () => { } } as any)}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Checkout Belanja'}
</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 ? cartItems.find(i => i.id === qtyDialogIndex)?.product?.name : ''}
</Label>
<Input
type="number"
min="1"
value={qtyInputValue}
onChange={e => setQtyInputValue(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmQty(); }}
className="text-lg font-bold"
autoFocus
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setQtyDialogIndex(null)}>Batal</Button>
<Button onClick={confirmQty}>Konfirmasi</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
PurchaseCreate.layout = {
breadcrumbs: [{ title: 'Kelola' }],
};

View File

@ -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<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) => {
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 = () => (
<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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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>
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</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)
}
}}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Perbarui Belanja'}
</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'>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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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>
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</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}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Perbarui Belanja'}
</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">Rp {total.toLocaleString('id-ID')}</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">
Rp {(item.quantity * item.unit_price).toLocaleString('id-ID')}
</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">
Rp {item.unit_price.toLocaleString('id-ID')}
</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="...." />
</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">Rp {total.toLocaleString('id-ID')}</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)}
>
<ShoppingCart className="h-4 w-4 mr-2" />
{processing ? 'Menyimpan...' : 'Perbarui Belanja'}
</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-center 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' }],
};

View File

@ -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<Purchase | null>(null);
const [rowsToDelete, setRowsToDelete] = useState<any[]>([]);
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,
};
}

View File

@ -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 (
<div className="flex flex-col gap-6 p-6">
<Head title="Belanja" />
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Belanja</h1>
</div>
<Link href={purchaseRoutes.create().url}>
<Button>
Tambah
</Button>
</Link>
</div>
<Card className="overflow-hidden border-none shadow-lg bg-card/50 backdrop-blur-sm p-5">
<CardContent className="p-0">
<DataTable
columns={columns}
data={purchases}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
bulkActions={[
{
label: 'Hapus Terpilih',
onClick: (rows) => {
setRowsToDelete(rows);
setIsBulkDeleteDialogOpen(true);
},
icon: Trash2,
variant: 'destructive'
},
]}
/>
</CardContent>
</Card>
{/* Single Delete Confirmation */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus data belanja?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. Data belanja tanggal <strong>{purchaseToDelete && format(new Date(purchaseToDelete.purchase_date), 'dd MMMM yyyy', { locale: id })}</strong> akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} variant="destructive">Hapus</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Bulk Delete Confirmation */}
<AlertDialog open={isBulkDeleteDialogOpen} onOpenChange={setIsBulkDeleteDialogOpen}>
<AlertDialogContent size="default">
<AlertDialogHeader>
<AlertDialogMedia className="bg-destructive/10 text-destructive dark:bg-destructive/20 dark:text-destructive">
<Trash2 className="size-5" />
</AlertDialogMedia>
<AlertDialogTitle>Hapus {rowsToDelete.length} data belanja?</AlertDialogTitle>
<AlertDialogDescription>
Tindakan ini tidak dapat dibatalkan. <strong>{rowsToDelete.length}</strong> item yang terpilih akan dihapus secara permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="outline">Batal</AlertDialogCancel>
<AlertDialogAction
onClick={confirmBulkDelete}
variant="destructive"
>
Hapus
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
PurchaseIndex.layout = {
breadcrumbs: [
{
title: 'Kelola',
},
],
};

View File

@ -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<Purchase>[] => [
{
accessorKey: "purchase_date",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Tanggal" />
),
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 }) => (
<span className="text-muted-foreground text-sm truncate max-w-[200px] block">
{row.original.note || '-'}
</span>
),
meta: { title: "Catatan" },
},
{
id: "actions",
header: "Aksi",
cell: ({ row }) => {
const purchase = row.original;
return (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Link href={purchaseRoutes.edit(purchase.id).url}>
<Button variant="ghost" size="icon" className='text-yellow-600 hover:text-yellow-700 hover:bg-yellow-50 dark:hover:bg-yellow-950/20'>
<Pencil className="size-4" />
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<p>Ubah</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className='text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950/20' onClick={() => onDelete(purchase)}>
<Trash2 className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Hapus</p>
</TooltipContent>
</Tooltip>
</div>
);
},
meta: { title: "Aksi" },
},
];

View File

@ -5,3 +5,4 @@ export type * from './category';
export type * from './product';
export type * from './expense';
export type * from './payroll';
export type * from './purchase';

View File

@ -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;
}

22
routes/manage.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use App\Http\Controllers\Admin\Manage\PurchaseCartController;
use App\Http\Controllers\Admin\Manage\PurchaseController;
use Illuminate\Support\Facades\Route;
Route::middleware(['auth'])->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');
});
});

View File

@ -15,3 +15,4 @@
require __DIR__.'/master.php';
require __DIR__.'/finance.php';
require __DIR__.'/system.php';
require __DIR__.'/manage.php';