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,
|
CategorySeeder::class,
|
||||||
ProductSeeder::class,
|
ProductSeeder::class,
|
||||||
ExpenseSeeder::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 { 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 AppLogo from '@/components/app-logo';
|
||||||
import { NavMain } from '@/components/nav-main';
|
import { NavMain } from '@/components/nav-main';
|
||||||
import {
|
import {
|
||||||
@ -19,6 +19,7 @@ import expense from '@/routes/expense';
|
|||||||
import payroll from '@/routes/payroll';
|
import payroll from '@/routes/payroll';
|
||||||
import user from '@/routes/user';
|
import user from '@/routes/user';
|
||||||
import system from '@/routes/system';
|
import system from '@/routes/system';
|
||||||
|
import purchase from '@/routes/purchase';
|
||||||
|
|
||||||
const mainNavItems: NavItem[] = [
|
const mainNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
@ -46,6 +47,14 @@ const masterNavItems: NavItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const manageNavItems: NavItem[] = [
|
||||||
|
{
|
||||||
|
title: 'Belanja',
|
||||||
|
href: purchase.index().url,
|
||||||
|
icon: ShoppingCart,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const financeNavItems: NavItem[] = [
|
const financeNavItems: NavItem[] = [
|
||||||
{
|
{
|
||||||
title: 'Pengeluaran',
|
title: 'Pengeluaran',
|
||||||
@ -85,6 +94,7 @@ export function AppSidebar() {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={mainNavItems} />
|
<NavMain items={mainNavItems} />
|
||||||
<NavMain items={masterNavItems} label='Master' />
|
<NavMain items={masterNavItems} label='Master' />
|
||||||
|
<NavMain items={manageNavItems} label='Kelola' />
|
||||||
<NavMain items={financeNavItems} label='Keuangan' />
|
<NavMain items={financeNavItems} label='Keuangan' />
|
||||||
<NavMain items={systemNavItems} label='Sistem' />
|
<NavMain items={systemNavItems} label='Sistem' />
|
||||||
</SidebarContent>
|
</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 './product';
|
||||||
export type * from './expense';
|
export type * from './expense';
|
||||||
export type * from './payroll';
|
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__.'/master.php';
|
||||||
require __DIR__.'/finance.php';
|
require __DIR__.'/finance.php';
|
||||||
require __DIR__.'/system.php';
|
require __DIR__.'/system.php';
|
||||||
|
require __DIR__.'/manage.php';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user