feat: implement purchase management system with database schema, CRUD controllers, and UI components
This commit is contained in:
parent
ac537a6b8c
commit
70e0ba9760
72
app/Http/Controllers/Admin/Manage/PurchaseCartController.php
Normal file
72
app/Http/Controllers/Admin/Manage/PurchaseCartController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Admin/Manage/PurchaseController.php
Normal file
123
app/Http/Controllers/Admin/Manage/PurchaseController.php
Normal 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');
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/Admin/Manage/Purchase/AddToCartRequest.php
Normal file
39
app/Http/Requests/Admin/Manage/Purchase/AddToCartRequest.php
Normal 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',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/Admin/Manage/Purchase/PurchaseRequest.php
Normal file
35
app/Http/Requests/Admin/Manage/Purchase/PurchaseRequest.php
Normal 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
38
app/Models/Purchase.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
app/Models/PurchaseItem.php
Normal file
56
app/Models/PurchaseItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
26
database/factories/PurchaseFactory.php
Normal file
26
database/factories/PurchaseFactory.php
Normal 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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
35
database/factories/PurchaseItemFactory.php
Normal file
35
database/factories/PurchaseItemFactory.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -18,6 +18,7 @@ public function run(): void
|
||||
CategorySeeder::class,
|
||||
ProductSeeder::class,
|
||||
ExpenseSeeder::class,
|
||||
PurchaseSeeder::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
50
database/seeders/PurchaseSeeder.php
Normal file
50
database/seeders/PurchaseSeeder.php
Normal 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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
814
resources/js/pages/admin/manage/purchase/create.tsx
Normal file
814
resources/js/pages/admin/manage/purchase/create.tsx
Normal 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' }],
|
||||
};
|
||||
818
resources/js/pages/admin/manage/purchase/edit.tsx
Normal file
818
resources/js/pages/admin/manage/purchase/edit.tsx
Normal 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' }],
|
||||
};
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
132
resources/js/pages/admin/manage/purchase/index.tsx
Normal file
132
resources/js/pages/admin/manage/purchase/index.tsx
Normal 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',
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -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" },
|
||||
},
|
||||
];
|
||||
@ -5,3 +5,4 @@ export type * from './category';
|
||||
export type * from './product';
|
||||
export type * from './expense';
|
||||
export type * from './payroll';
|
||||
export type * from './purchase';
|
||||
|
||||
27
resources/js/types/purchase.ts
Normal file
27
resources/js/types/purchase.ts
Normal 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
22
routes/manage.php
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -15,3 +15,4 @@
|
||||
require __DIR__.'/master.php';
|
||||
require __DIR__.'/finance.php';
|
||||
require __DIR__.'/system.php';
|
||||
require __DIR__.'/manage.php';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user