feat: enhance product management and purchase functionality
- Updated ProductIndex component to improve category and product filtering with memoization. - Refactored Combobox components for better performance and usability. - Added PurchaseController routes for managing purchases with appropriate permissions. - Created comprehensive tests for purchase management, covering creation, updating, and deletion scenarios. - Ensured proper handling of raw materials and their variants during purchase operations. - Implemented validation for required fields in purchase creation and updates.
This commit is contained in:
parent
75f821957d
commit
9637671713
@ -20,7 +20,7 @@ public function label(): string
|
||||
self::PRODUCT => 'Produk',
|
||||
self::EMPLOYEE => 'Karyawan',
|
||||
self::ORDER => 'Pesanan',
|
||||
self::PURCHASE => 'Pembelian',
|
||||
self::PURCHASE => 'Belanja',
|
||||
self::RAW_MATERIAL => 'Bahan Baku',
|
||||
self::EXPENSE => 'Pengeluaran',
|
||||
self::USER => 'Pengguna',
|
||||
|
||||
73
app/Http/Controllers/Admin/Manage/PurchaseController.php
Normal file
73
app/Http/Controllers/Admin/Manage/PurchaseController.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Manage;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\Manage\PurchaseRequest;
|
||||
use App\Http\Requests\PaginatedRequest;
|
||||
use App\Models\Purchase;
|
||||
use App\Services\Admin\Manage\PurchaseService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class PurchaseController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private PurchaseService $service,
|
||||
) {}
|
||||
|
||||
public function index(PaginatedRequest $request): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/purchase/index', [
|
||||
'purchases' => $this->service->paginated(
|
||||
...$request->validatedWithDefaults(),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/purchase/create', [
|
||||
'data' => $this->service->getForCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(PurchaseRequest $request): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->create($request->validated()),
|
||||
'Belanja berhasil ditambahkan.',
|
||||
'admin.manage.purchases.index',
|
||||
'admin.manage.purchases.create'
|
||||
);
|
||||
}
|
||||
|
||||
public function edit(Purchase $purchase): Response
|
||||
{
|
||||
return Inertia::render('admin/manage/purchase/edit', [
|
||||
'purchase' => $this->service->getForEdit($purchase),
|
||||
'data' => $this->service->getForCreate(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->update($purchase, $request->validated()),
|
||||
'Belanja berhasil diperbarui.',
|
||||
'admin.manage.purchases.index',
|
||||
'admin.manage.purchases.edit',
|
||||
['purchase' => $purchase]
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Purchase $purchase): RedirectResponse
|
||||
{
|
||||
return $this->handleAction(
|
||||
fn () => $this->service->delete($purchase),
|
||||
'Belanja berhasil dihapus.',
|
||||
'admin.manage.purchases.index'
|
||||
);
|
||||
}
|
||||
}
|
||||
80
app/Http/Requests/Admin/Manage/PurchaseRequest.php
Normal file
80
app/Http/Requests/Admin/Manage/PurchaseRequest.php
Normal file
@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Admin\Manage;
|
||||
|
||||
use App\Enums\RawMaterialUnit;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class PurchaseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function prepareForValidation(): void
|
||||
{
|
||||
$variants = $this->variants;
|
||||
if (is_array($variants)) {
|
||||
foreach ($variants as $i => $variant) {
|
||||
if (isset($variant['price']) && is_string($variant['price'])) {
|
||||
$this->request->set("variants.$i.price", (int) str_replace('.', '', $variant['price']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (is_string($this->discount)) {
|
||||
$this->request->set('discount', (int) str_replace('.', '', $this->discount));
|
||||
}
|
||||
|
||||
if (is_string($this->shipping_cost)) {
|
||||
$this->request->set('shipping_cost', (int) str_replace('.', '', $this->shipping_cost));
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'mode' => ['sometimes', 'required', 'in:new,existing'],
|
||||
'name' => ['required_unless:mode,existing', 'string', 'max:200'],
|
||||
'unit' => [$this->isMethod('post') ? 'required_unless:mode,existing' : 'nullable', Rule::in(RawMaterialUnit::values())],
|
||||
'variants' => ['required_unless:mode,existing', 'array', 'min:1'],
|
||||
'variants.*.variant' => ['required_unless:mode,existing', 'string', 'max:200'],
|
||||
'variants.*.price' => ['required_unless:mode,existing', 'integer', 'min:0'],
|
||||
'variants.*.stock' => ['required_unless:mode,existing', 'numeric', 'min:0'],
|
||||
'variants.*.photo_key' => ['required_unless:mode,existing', 'string', 'max:500'],
|
||||
'existing_items' => ['required_if:mode,existing', 'array', 'min:1'],
|
||||
'existing_items.*.raw_material_price_id' => ['required', 'integer', 'exists:raw_material_prices,id'],
|
||||
'existing_items.*.quantity' => ['required', 'numeric', 'min:0.0001'],
|
||||
'existing_items.*.unit_price' => ['required', 'integer', 'min:0'],
|
||||
'supplier_id' => ['required', 'integer', 'exists:suppliers,id'],
|
||||
'discount' => ['nullable', 'integer', 'min:0'],
|
||||
'shipping_cost' => ['nullable', 'integer', 'min:0'],
|
||||
'notes' => ['nullable', 'string', 'max:100'],
|
||||
'photo_key' => ['nullable', 'string', 'max:500'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'name' => 'Nama Bahan Baku',
|
||||
'unit' => 'Satuan',
|
||||
'variants' => 'Varian',
|
||||
'variants.*.variant' => 'Nama Varian',
|
||||
'variants.*.price' => 'Harga',
|
||||
'variants.*.stock' => 'Stok',
|
||||
'variants.*.photo_key' => 'Foto Varian',
|
||||
'existing_items' => 'Item Bahan Baku',
|
||||
'existing_items.*.raw_material_price_id' => 'Varian Bahan Baku',
|
||||
'existing_items.*.quantity' => 'Jumlah',
|
||||
'existing_items.*.unit_price' => 'Harga Beli',
|
||||
'supplier_id' => 'Supplier',
|
||||
'discount' => 'Diskon',
|
||||
'shipping_cost' => 'Ongkir',
|
||||
'notes' => 'Keterangan',
|
||||
'photo_key' => 'Foto',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,13 @@
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Spatie\MediaLibrary\HasMedia;
|
||||
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||
|
||||
#[Guarded(['id'])]
|
||||
class Purchase extends Model
|
||||
class Purchase extends Model implements HasMedia
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
|
||||
@ -29,7 +29,7 @@ public function purchase(): BelongsTo
|
||||
|
||||
public function rawMaterialPrice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RawMaterialPrice::class);
|
||||
return $this->belongsTo(RawMaterialPrice::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@ -36,6 +37,15 @@ public function purchaseItems(): HasMany
|
||||
|
||||
public function rawMaterial(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(RawMaterial::class);
|
||||
return $this->belongsTo(RawMaterial::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function getPhotoUrlAttribute(): ?string
|
||||
{
|
||||
$media = $this->getFirstMedia('photos');
|
||||
|
||||
return $media
|
||||
? app(S3PresignedService::class)->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
507
app/Services/Admin/Manage/PurchaseService.php
Normal file
507
app/Services/Admin/Manage/PurchaseService.php
Normal file
@ -0,0 +1,507 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Admin\Manage;
|
||||
|
||||
use App\Models\Purchase;
|
||||
use App\Models\PurchaseItem;
|
||||
use App\Models\RawMaterial;
|
||||
use App\Models\RawMaterialPrice;
|
||||
use App\Models\Supplier;
|
||||
use App\Services\Concerns\RegistersMedia;
|
||||
use App\Services\NotificationService;
|
||||
use App\Services\S3PresignedService;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PurchaseService
|
||||
{
|
||||
use RegistersMedia;
|
||||
|
||||
public function __construct(
|
||||
private S3PresignedService $s3Service = new S3PresignedService,
|
||||
) {}
|
||||
|
||||
public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc'): LengthAwarePaginator
|
||||
{
|
||||
$paginator = Purchase::query()
|
||||
->select('id', 'supplier_id', 'created_by_id', 'subtotal', 'discount', 'shipping_cost', 'total', 'notes', 'created_at')
|
||||
->with([
|
||||
'supplier:id,name',
|
||||
'createdBy:id',
|
||||
'createdBy.userProfile:id,user_id,full_name',
|
||||
'purchaseItems:id,purchase_id,raw_material_price_id,quantity,unit_price,subtotal',
|
||||
'purchaseItems.rawMaterialPrice:id,raw_material_id,variant,price,stock',
|
||||
'purchaseItems.rawMaterialPrice.rawMaterial:id,name,unit',
|
||||
])
|
||||
->when($search, function ($q) use ($search) {
|
||||
$q->whereHas('supplier', fn ($sq) => $sq->where('name', 'like', "%{$search}%"))
|
||||
->orWhere('notes', 'like', "%{$search}%");
|
||||
})
|
||||
->orderBy($sort, $direction)
|
||||
->paginate($perPage);
|
||||
|
||||
$paginator->getCollection()->each(function (Purchase $purchase) {
|
||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
||||
$purchase->photo_url = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
||||
: null;
|
||||
|
||||
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
||||
if (! $item->rawMaterialPrice) {
|
||||
return;
|
||||
}
|
||||
|
||||
$media = $item->rawMaterialPrice->getMedia('photos');
|
||||
$item->rawMaterialPrice->photo_url = $media->first()
|
||||
? $this->s3Service->getTemporaryUrl($media->first()->file_name)
|
||||
: null;
|
||||
});
|
||||
});
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
public function getForCreate(): array
|
||||
{
|
||||
return [
|
||||
'suppliers' => Supplier::select('id', 'name')->latest()->get(),
|
||||
'rawMaterials' => RawMaterial::query()
|
||||
->select('id', 'name', 'unit', 'is_active')
|
||||
->with([
|
||||
'rawMaterialPrices:id,raw_material_id,variant,price,stock',
|
||||
])
|
||||
->orderBy('name')
|
||||
->get()
|
||||
->each(function (RawMaterial $rawMaterial) {
|
||||
$rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) {
|
||||
$media = $price->getFirstMedia('photos');
|
||||
$price->photo_url = $media
|
||||
? $this->s3Service->getTemporaryUrl($media->file_name)
|
||||
: null;
|
||||
});
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
public function getForEdit(Purchase $purchase): array
|
||||
{
|
||||
$purchase->load([
|
||||
'purchaseItems.rawMaterialPrice.rawMaterial',
|
||||
'supplier',
|
||||
]);
|
||||
|
||||
$rawMaterial = $purchase->purchaseItems->first()?->rawMaterialPrice?->rawMaterial;
|
||||
|
||||
$variants = $purchase->purchaseItems->map(function (PurchaseItem $item) {
|
||||
if (! $item->rawMaterialPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$media = $item->rawMaterialPrice->getFirstMedia('photos');
|
||||
|
||||
return [
|
||||
'id' => $item->rawMaterialPrice->id,
|
||||
'variant' => $item->rawMaterialPrice->variant,
|
||||
'price' => $item->unit_price,
|
||||
'stock' => $item->quantity,
|
||||
'photo_key' => $media?->file_name,
|
||||
'photo_url' => $media ? $this->s3Service->getTemporaryUrl($media->file_name) : null,
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
$items = $purchase->purchaseItems;
|
||||
$items = $items->filter(fn (PurchaseItem $item) => $item->rawMaterialPrice !== null);
|
||||
|
||||
$materials = $items
|
||||
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice->rawMaterial)
|
||||
->filter()
|
||||
->unique(fn (RawMaterial $material) => $material->id);
|
||||
|
||||
$singleMaterial = $materials->count() === 1;
|
||||
$sharedWithOther = PurchaseItem::where('purchase_id', '!=', $purchase->id)
|
||||
->whereIn('raw_material_price_id', $items->pluck('raw_material_price_id'))
|
||||
->exists();
|
||||
|
||||
$purchaseMedia = $purchase->getFirstMedia('photos');
|
||||
$purchasePhotoKey = $purchaseMedia?->file_name;
|
||||
$purchasePhotoUrl = $purchaseMedia
|
||||
? $this->s3Service->getTemporaryUrl($purchaseMedia->file_name)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'id' => $purchase->id,
|
||||
'name' => $rawMaterial?->name ?? '',
|
||||
'unit' => $rawMaterial?->unit->value ?? 'kg',
|
||||
'supplier_id' => $purchase->supplier_id,
|
||||
'discount' => $purchase->discount,
|
||||
'shipping_cost' => $purchase->shipping_cost,
|
||||
'notes' => $purchase->notes,
|
||||
'photo_key' => $purchasePhotoKey,
|
||||
'photo_url' => $purchasePhotoUrl,
|
||||
'variants' => $variants,
|
||||
'default_mode' => $singleMaterial && ! $sharedWithOther ? 'new' : 'existing',
|
||||
'existing_material_name' => $singleMaterial ? $materials->first()->name : null,
|
||||
'existing_quantities' => $singleMaterial
|
||||
? $items->mapWithKeys(fn (PurchaseItem $item) => [
|
||||
(int) $item->raw_material_price_id => (float) $item->quantity,
|
||||
])->all()
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
public function create(array $data): Purchase
|
||||
{
|
||||
if (($data['mode'] ?? 'new') === 'existing') {
|
||||
return $this->createFromExisting($data);
|
||||
}
|
||||
|
||||
return $this->createNew($data);
|
||||
}
|
||||
|
||||
private function createFromExisting(array $data): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$now = now();
|
||||
$subtotal = 0;
|
||||
|
||||
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
|
||||
$itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
|
||||
$subtotal += $itemSubtotal;
|
||||
|
||||
return [
|
||||
'purchase_id' => null,
|
||||
'raw_material_price_id' => $item['raw_material_price_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'subtotal' => $itemSubtotal,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$discount = $data['discount'] ?? 0;
|
||||
$shippingCost = $data['shipping_cost'] ?? 0;
|
||||
$total = $subtotal - $discount + $shippingCost;
|
||||
|
||||
$purchase = Purchase::create([
|
||||
'supplier_id' => $data['supplier_id'],
|
||||
'created_by_id' => auth()->id(),
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($itemRows as &$row) {
|
||||
$row['purchase_id'] = $purchase->id;
|
||||
}
|
||||
DB::table('purchase_items')->insert($itemRows);
|
||||
|
||||
foreach ($data['existing_items'] as $item) {
|
||||
RawMaterialPrice::whereKey($item['raw_material_price_id'])
|
||||
->increment('stock', (float) $item['quantity']);
|
||||
}
|
||||
|
||||
if (! empty($data['photo_key'])) {
|
||||
$this->registerMedia(
|
||||
model: $purchase,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
|
||||
title: 'Belanja Baru',
|
||||
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.manage.purchases.index'),
|
||||
);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
}
|
||||
|
||||
private function createNew(array $data): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($data) {
|
||||
$rawMaterial = RawMaterial::create([
|
||||
'name' => $data['name'],
|
||||
'unit' => $data['unit'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$subtotal = 0;
|
||||
$now = now();
|
||||
$priceRows = collect($data['variants'])->map(function ($v) use ($rawMaterial, $now, &$subtotal) {
|
||||
$itemSubtotal = (int) ($v['price'] * $v['stock']);
|
||||
$subtotal += $itemSubtotal;
|
||||
|
||||
return [
|
||||
'raw_material_id' => $rawMaterial->id,
|
||||
'variant' => $v['variant'],
|
||||
'price' => $v['price'],
|
||||
'stock' => $v['stock'],
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
DB::table('raw_material_prices')->insert($priceRows);
|
||||
|
||||
$insertedPrices = RawMaterialPrice::where('raw_material_id', $rawMaterial->id)->get();
|
||||
$variantMap = $insertedPrices->mapWithKeys(fn ($p) => [$p->variant => $p->id]);
|
||||
|
||||
foreach ($data['variants'] as $variantData) {
|
||||
if (! empty($variantData['photo_key'])) {
|
||||
$priceId = $variantMap[$variantData['variant']];
|
||||
$priceModel = RawMaterialPrice::find($priceId);
|
||||
$this->registerMedia(
|
||||
model: $priceModel,
|
||||
s3Key: $variantData['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$discount = $data['discount'] ?? 0;
|
||||
$shippingCost = $data['shipping_cost'] ?? 0;
|
||||
$total = $subtotal - $discount + $shippingCost;
|
||||
|
||||
$purchase = Purchase::create([
|
||||
'supplier_id' => $data['supplier_id'],
|
||||
'created_by_id' => auth()->id(),
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
$purchaseItems = $insertedPrices->map(fn ($price) => [
|
||||
'purchase_id' => $purchase->id,
|
||||
'raw_material_price_id' => $price->id,
|
||||
'user_id' => auth()->id(),
|
||||
'quantity' => $price->stock,
|
||||
'unit_price' => $price->price,
|
||||
'subtotal' => (int) ($price->price * $price->stock),
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])->toArray();
|
||||
|
||||
DB::table('purchase_items')->insert($purchaseItems);
|
||||
|
||||
if (! empty($data['photo_key'])) {
|
||||
$this->registerMedia(
|
||||
model: $purchase,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
NotificationService::notify(
|
||||
roles: ['Owner', 'Developer', 'Admin Bahan Baku'],
|
||||
title: 'Belanja Baru',
|
||||
body: 'Belanja bahan baku sebesar Rp '.number_format($total, 0, ',', '.').' berhasil dicatat oleh '.auth()->user()->full_name.'.',
|
||||
url: route('admin.manage.purchases.index'),
|
||||
);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
}
|
||||
|
||||
public function update(Purchase $purchase, array $data): Purchase
|
||||
{
|
||||
return DB::transaction(function () use ($purchase, $data) {
|
||||
$purchase->load('purchaseItems.rawMaterialPrice.rawMaterial');
|
||||
|
||||
$oldItems = $purchase->purchaseItems;
|
||||
|
||||
// 1. Reverse the stock increments of the old items, so prices
|
||||
// get adjusted by the difference instead of being reset.
|
||||
$oldItems->each(function (PurchaseItem $item) {
|
||||
if ($item->rawMaterialPrice) {
|
||||
$item->rawMaterialPrice->decrement('stock', (float) $item->quantity);
|
||||
}
|
||||
});
|
||||
|
||||
$oldMaterial = $oldItems
|
||||
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
|
||||
->filter()
|
||||
->unique(fn (RawMaterial $material) => $material->id)
|
||||
->first();
|
||||
|
||||
$oldMaterial = $oldItems
|
||||
->map(fn (PurchaseItem $item) => $item->rawMaterialPrice?->rawMaterial)
|
||||
->filter()
|
||||
->unique(fn (RawMaterial $material) => $material->id)
|
||||
->first();
|
||||
|
||||
$oldItems->each->delete();
|
||||
|
||||
$now = now();
|
||||
$subtotal = 0;
|
||||
|
||||
if (($data['mode'] ?? 'new') === 'existing') {
|
||||
// 2a. Reference existing prices and add their new stock.
|
||||
$itemRows = collect($data['existing_items'])->map(function ($item) use ($now, &$subtotal) {
|
||||
$itemSubtotal = (int) ($item['unit_price'] * $item['quantity']);
|
||||
$subtotal += $itemSubtotal;
|
||||
|
||||
return [
|
||||
'purchase_id' => null,
|
||||
'raw_material_price_id' => $item['raw_material_price_id'],
|
||||
'user_id' => auth()->id(),
|
||||
'quantity' => $item['quantity'],
|
||||
'unit_price' => $item['unit_price'],
|
||||
'subtotal' => $itemSubtotal,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
foreach ($data['existing_items'] as $item) {
|
||||
RawMaterialPrice::whereKey($item['raw_material_price_id'])
|
||||
->increment('stock', (float) $item['quantity']);
|
||||
}
|
||||
} else {
|
||||
// 2a. Always reuse the purchase's existing material in place;
|
||||
// a fresh material is only created when the purchase has
|
||||
// no items yet.
|
||||
if ($oldMaterial) {
|
||||
$rawMaterial = $oldMaterial;
|
||||
$rawMaterial->update([
|
||||
'name' => $data['name'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
} else {
|
||||
$rawMaterial = RawMaterial::create([
|
||||
'name' => $data['name'],
|
||||
'unit' => $data['unit'] ?? 'kg',
|
||||
'is_active' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
// 2b. Adjust the stock of existing variant prices, create
|
||||
// prices for new variants, but never delete variants.
|
||||
$itemRows = collect($data['variants'])->map(function ($v) use ($purchase, $rawMaterial, $now, &$subtotal) {
|
||||
$price = null;
|
||||
|
||||
if (! empty($v['id'])) {
|
||||
$price = RawMaterialPrice::withTrashed()->find($v['id']);
|
||||
}
|
||||
|
||||
if (! $price) {
|
||||
$price = $rawMaterial->rawMaterialPrices()
|
||||
->where('variant', $v['variant'])
|
||||
->first();
|
||||
}
|
||||
|
||||
if ($price) {
|
||||
$price->increment('stock', (float) $v['stock']);
|
||||
$price->update(['price' => $v['price']]);
|
||||
} else {
|
||||
$price = $rawMaterial->rawMaterialPrices()->create([
|
||||
'variant' => $v['variant'],
|
||||
'price' => $v['price'],
|
||||
'stock' => $v['stock'],
|
||||
]);
|
||||
}
|
||||
|
||||
if (! empty($v['photo_key']) && $price->getFirstMedia('photos')?->file_name !== $v['photo_key']) {
|
||||
$this->registerMedia(
|
||||
model: $price,
|
||||
s3Key: $v['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
|
||||
$itemSubtotal = (int) ($v['price'] * $v['stock']);
|
||||
$subtotal += $itemSubtotal;
|
||||
|
||||
return [
|
||||
'purchase_id' => $purchase->id,
|
||||
'raw_material_price_id' => $price->id,
|
||||
'user_id' => auth()->id(),
|
||||
'quantity' => $v['stock'],
|
||||
'unit_price' => $v['price'],
|
||||
'subtotal' => $itemSubtotal,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
$discount = $data['discount'] ?? 0;
|
||||
$shippingCost = $data['shipping_cost'] ?? 0;
|
||||
$total = $subtotal - $discount + $shippingCost;
|
||||
|
||||
$purchase->update([
|
||||
'supplier_id' => $data['supplier_id'],
|
||||
'subtotal' => $subtotal,
|
||||
'discount' => $discount,
|
||||
'shipping_cost' => $shippingCost,
|
||||
'total' => $total,
|
||||
'notes' => $data['notes'] ?? null,
|
||||
]);
|
||||
|
||||
foreach ($itemRows as &$row) {
|
||||
$row['purchase_id'] = $purchase->id;
|
||||
}
|
||||
DB::table('purchase_items')->insert($itemRows);
|
||||
|
||||
$this->syncPurchasePhoto($purchase, $data);
|
||||
|
||||
return $purchase;
|
||||
});
|
||||
}
|
||||
|
||||
private function syncPurchasePhoto(Purchase $purchase, array $data): void
|
||||
{
|
||||
if (! array_key_exists('photo_key', $data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentKey = $purchase->getFirstMedia('photos')?->file_name;
|
||||
|
||||
if ($data['photo_key'] === $currentKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
$purchase->clearMediaCollection('photos');
|
||||
|
||||
if (! empty($data['photo_key'])) {
|
||||
$this->registerMedia(
|
||||
model: $purchase,
|
||||
s3Key: $data['photo_key'],
|
||||
collectionName: 'photos',
|
||||
orderColumn: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(Purchase $purchase): bool
|
||||
{
|
||||
return DB::transaction(function () use ($purchase) {
|
||||
$purchase->load('purchaseItems.rawMaterialPrice');
|
||||
|
||||
// Remove the stock the purchase added, keep the variants.
|
||||
$purchase->purchaseItems->each(function (PurchaseItem $item) {
|
||||
if ($item->rawMaterialPrice) {
|
||||
$item->rawMaterialPrice->decrement('stock', (float) $item->quantity);
|
||||
}
|
||||
});
|
||||
|
||||
$purchase->clearMediaCollection('photos');
|
||||
$purchase->purchaseItems()->delete();
|
||||
$purchase->delete();
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -27,6 +27,7 @@ public function run(): void
|
||||
'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'],
|
||||
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
||||
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
||||
'purchase' => ['view', 'create', 'update', 'delete'],
|
||||
];
|
||||
|
||||
foreach ($permissions as $module => $actions) {
|
||||
@ -53,6 +54,7 @@ public function run(): void
|
||||
|
||||
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
||||
return str_starts_with($p, 'supplier.')
|
||||
|| str_starts_with($p, 'purchase.')
|
||||
|| $p === 'category.view'
|
||||
|| $p === 'customer.view'
|
||||
|| $p === 'cash-account.view';
|
||||
|
||||
@ -24,6 +24,7 @@ import { index as categoriesIndex } from '@/routes/admin/master/categories';
|
||||
import { index as customersIndex } from '@/routes/admin/master/customers';
|
||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
||||
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
|
||||
import { index as purchasesIndex } from '@/routes/admin/manage/purchases';
|
||||
import { index as suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
||||
import { Link, router } from '@inertiajs/react';
|
||||
@ -75,7 +76,7 @@ const masterItems: NavMenuItem[] = [
|
||||
];
|
||||
|
||||
const kelolaItems: NavMenuItem[] = [
|
||||
{ title: 'Belanja', href: '#', icon: ShoppingCart },
|
||||
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
|
||||
{ title: 'Cutting', href: '#', icon: Scissors },
|
||||
{ title: 'Restock', href: '#', icon: RefreshCw },
|
||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||
@ -101,6 +102,8 @@ const sistemItems: NavMenuItem[] = [
|
||||
];
|
||||
|
||||
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
||||
const { isCurrentUrl } = useCurrentUrl();
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||
@ -109,6 +112,7 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={isCurrentUrl(item.href)}
|
||||
tooltip={{ children: item.title }}
|
||||
>
|
||||
<Link href={item.href} prefetch>
|
||||
|
||||
68
resources/js/hooks/use-purchase-draft.ts
Normal file
68
resources/js/hooks/use-purchase-draft.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {
|
||||
clearPurchaseDraft,
|
||||
savePurchaseDraft,
|
||||
type PurchaseDraftData,
|
||||
} from '@/lib/purchase-draft';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
type DraftType = 'create' | 'edit';
|
||||
|
||||
export function usePurchaseDraftSave(
|
||||
type: DraftType,
|
||||
data: PurchaseDraftData,
|
||||
userId?: number,
|
||||
delay = 500,
|
||||
) {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dataRef = useRef(data);
|
||||
dataRef.current = data;
|
||||
const submittedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const offBefore = router.on('before', (event) => {
|
||||
if (event.detail.visit.method !== 'get') {
|
||||
submittedRef.current = true;
|
||||
}
|
||||
});
|
||||
const offError = router.on('error', () => {
|
||||
submittedRef.current = false;
|
||||
});
|
||||
|
||||
return () => {
|
||||
offBefore();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
if (!submittedRef.current) {
|
||||
savePurchaseDraft(type, dataRef.current, userId);
|
||||
}
|
||||
timeoutRef.current = null;
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [data, type, userId, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
if (submittedRef.current) {
|
||||
clearPurchaseDraft(type, userId);
|
||||
} else {
|
||||
savePurchaseDraft(type, dataRef.current, userId);
|
||||
}
|
||||
};
|
||||
}, [type, userId]);
|
||||
}
|
||||
78
resources/js/lib/purchase-draft.ts
Normal file
78
resources/js/lib/purchase-draft.ts
Normal file
@ -0,0 +1,78 @@
|
||||
const DRAFT_PREFIX = 'purchase-draft';
|
||||
|
||||
export type PurchaseDraftData = {
|
||||
name: string;
|
||||
unit: string;
|
||||
supplierId: string;
|
||||
discount: number;
|
||||
shippingCost: number;
|
||||
notes: string;
|
||||
variants: Array<{
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo?: string;
|
||||
}>;
|
||||
mode?: 'new' | 'existing';
|
||||
selectedMaterialName?: string;
|
||||
quantities?: Record<string, number>;
|
||||
photo?: string;
|
||||
};
|
||||
|
||||
function getKey(type: 'create' | 'edit', userId?: number): string {
|
||||
if (type === 'edit') {
|
||||
return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}`;
|
||||
}
|
||||
|
||||
return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`;
|
||||
}
|
||||
|
||||
export function savePurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
data: PurchaseDraftData,
|
||||
userId?: number,
|
||||
): boolean {
|
||||
if (type !== 'create') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.setItem(key, JSON.stringify(data));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadPurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): PurchaseDraftData | null {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
const raw = localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as PurchaseDraftData;
|
||||
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPurchaseDraft(
|
||||
type: 'create' | 'edit',
|
||||
userId?: number,
|
||||
): void {
|
||||
try {
|
||||
const key = getKey(type, userId);
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@ -62,7 +62,7 @@ function getReferenceLabel(type: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
'App\\Models\\Expense': 'Pengeluaran',
|
||||
'App\\Models\\Order': 'Penjualan Tunai',
|
||||
'App\\Models\\Purchase': 'Pembelian',
|
||||
'App\\Models\\Purchase': 'Belanja',
|
||||
'App\\Models\\CashAccount': 'Transfer Kas',
|
||||
};
|
||||
|
||||
|
||||
95
resources/js/pages/admin/manage/purchase/columns.tsx
Normal file
95
resources/js/pages/admin/manage/purchase/columns.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
export type PurchaseItem = {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
variant_name: string;
|
||||
};
|
||||
|
||||
export type Purchase = {
|
||||
id: number;
|
||||
supplier_id: number;
|
||||
created_by_id: number;
|
||||
subtotal: number;
|
||||
discount: number;
|
||||
shipping_cost: number;
|
||||
total: number;
|
||||
notes: string | null;
|
||||
photo_url: string | null;
|
||||
created_at: string;
|
||||
supplier: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
created_by: {
|
||||
id: number;
|
||||
user_profile: {
|
||||
full_name: string;
|
||||
};
|
||||
};
|
||||
purchase_items: {
|
||||
id: number;
|
||||
raw_material_price_id: number;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
subtotal: number;
|
||||
raw_material_price: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
raw_material: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
};
|
||||
};
|
||||
}[];
|
||||
};
|
||||
|
||||
export type PurchaseForEdit = {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
supplier_id: number;
|
||||
discount: number;
|
||||
shipping_cost: number;
|
||||
notes: string | null;
|
||||
photo_key: string | null;
|
||||
photo_url: string | null;
|
||||
variants: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_key: string | null;
|
||||
photo_url: string | null;
|
||||
}[];
|
||||
default_mode: 'new' | 'existing';
|
||||
existing_material_name: string | null;
|
||||
existing_quantities: Record<string, number>;
|
||||
};
|
||||
|
||||
export type Supplier = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type PurchaseCreateData = {
|
||||
suppliers: Supplier[];
|
||||
rawMaterials: {
|
||||
id: number;
|
||||
name: string;
|
||||
unit: string;
|
||||
is_active: boolean;
|
||||
raw_material_prices: {
|
||||
id: number;
|
||||
variant: string;
|
||||
price: number;
|
||||
stock: number;
|
||||
photo_url: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
1356
resources/js/pages/admin/manage/purchase/create.tsx
Normal file
1356
resources/js/pages/admin/manage/purchase/create.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1254
resources/js/pages/admin/manage/purchase/edit.tsx
Normal file
1254
resources/js/pages/admin/manage/purchase/edit.tsx
Normal file
File diff suppressed because it is too large
Load Diff
162
resources/js/pages/admin/manage/purchase/index.tsx
Normal file
162
resources/js/pages/admin/manage/purchase/index.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { CardTable } from '@/components/card-table';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { useCardTableExpand } from '@/components/hooks/use-card-table-expand';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
destroy,
|
||||
create as purchaseCreate,
|
||||
index as purchaseIndex,
|
||||
edit as purchaseEdit,
|
||||
} from '@/routes/admin/manage/purchases';
|
||||
import type { Purchase } from './columns';
|
||||
import { PurchaseCardRow } from './purchase-card';
|
||||
import { PurchaseItemSubRow } from './purchase-sub-row';
|
||||
|
||||
type Props = {
|
||||
purchases: {
|
||||
data: Purchase[];
|
||||
current_page: number;
|
||||
last_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export default function PurchaseIndex({ purchases }: Props) {
|
||||
const [deleting, setDeleting] = useState<Purchase | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const expand = useCardTableExpand(true);
|
||||
|
||||
const pagination = {
|
||||
current_page: purchases.current_page,
|
||||
last_page: purchases.last_page,
|
||||
per_page: purchases.per_page,
|
||||
total: purchases.total,
|
||||
};
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page,
|
||||
per_page: pagination.per_page,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
function handlePerPageChange(perPage: number) {
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: perPage,
|
||||
search,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
router.get(
|
||||
purchaseIndex.url(),
|
||||
{
|
||||
page: 1,
|
||||
per_page: pagination.per_page,
|
||||
search: value,
|
||||
},
|
||||
{ preserveState: true, replace: true },
|
||||
);
|
||||
},
|
||||
[pagination.per_page],
|
||||
);
|
||||
|
||||
function handleDelete() {
|
||||
if (!deleting) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.delete(destroy.url(deleting.id), {
|
||||
onSuccess: () => setDeleting(null),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Belanja" />
|
||||
|
||||
<div className="flex h-full flex-1 flex-col gap-6 overflow-x-auto p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
Belanja
|
||||
</h2>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<a href={purchaseCreate.url()}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Tambah
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<CardTable
|
||||
data={purchases.data}
|
||||
getItemKey={(p) => p.id}
|
||||
expandedKeys={expand.expandedKeys}
|
||||
onToggleExpand={expand.toggleExpand}
|
||||
searchValue={search}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="Cari berdasarkan supplier..."
|
||||
pagination={pagination}
|
||||
onPageChange={handlePageChange}
|
||||
onPerPageChange={handlePerPageChange}
|
||||
renderCard={({
|
||||
item,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
}) => (
|
||||
<PurchaseCardRow
|
||||
purchase={item}
|
||||
index={
|
||||
(pagination.current_page - 1) *
|
||||
pagination.per_page +
|
||||
index +
|
||||
1
|
||||
}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={onToggleExpand}
|
||||
onEdit={(p) => {
|
||||
window.location.href = purchaseEdit.url(p.id);
|
||||
}}
|
||||
onDelete={(p) => setDeleting(p)}
|
||||
/>
|
||||
)}
|
||||
renderSubContent={(purchase) => (
|
||||
<PurchaseItemSubRow purchase={purchase} />
|
||||
)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
title="Hapus Belanja"
|
||||
description={`Apakah Anda yakin ingin menghapus belanja dari "${deleting?.supplier?.name}"? Stok akan dikembalikan. Tindakan ini tidak dapat dibatalkan.`}
|
||||
confirmLabel="Hapus"
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
195
resources/js/pages/admin/manage/purchase/purchase-card.tsx
Normal file
195
resources/js/pages/admin/manage/purchase/purchase-card.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { ChevronDown, Pencil, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { Purchase } from './columns';
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export type PurchaseCardRowParams = {
|
||||
purchase: Purchase;
|
||||
index: number;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onEdit: (purchase: Purchase) => void;
|
||||
onDelete: (purchase: Purchase) => void;
|
||||
};
|
||||
|
||||
export function PurchaseCardRow({
|
||||
purchase,
|
||||
index,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: PurchaseCardRowParams) {
|
||||
const items = purchase.purchase_items ?? [];
|
||||
const variantCount = items.length;
|
||||
const rawMaterialName =
|
||||
items[0]?.raw_material_price?.raw_material?.name ?? '-';
|
||||
const unit =
|
||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
const totalQty = items.reduce((sum, item) => sum + Number(item.quantity), 0);
|
||||
const [photoPreviewOpen, setPhotoPreviewOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden">
|
||||
<CardContent className="p-0">
|
||||
<div className="flex items-start gap-3 p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mt-0.5 h-6 w-6 shrink-0"
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{index}.
|
||||
</span>
|
||||
<h3 className="truncate font-medium">
|
||||
{purchase.supplier?.name ?? '-'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{rawMaterialName}
|
||||
{variantCount > 0 && (
|
||||
<span className="ml-1">
|
||||
({variantCount} varian)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{formatDateTime(purchase.created_at)}
|
||||
</span>
|
||||
{purchase.notes && (
|
||||
<span className="max-w-[200px] truncate">
|
||||
{purchase.notes}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
|
||||
<span>
|
||||
<span className="text-muted-foreground">Qty: </span>
|
||||
{formatNumber(totalQty)} {unit}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Sub: </span>
|
||||
{formatCurrency(purchase.subtotal)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Disc: </span>
|
||||
{formatCurrency(purchase.discount)}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-muted-foreground">Ongkir: </span>
|
||||
{formatCurrency(purchase.shipping_cost)}
|
||||
</span>
|
||||
<span className="font-semibold">
|
||||
<span className="text-muted-foreground font-normal">Total: </span>
|
||||
{formatCurrency(purchase.total)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{purchase.photo_url && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhotoPreviewOpen(true)}
|
||||
className="block h-16 w-16 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={purchase.photo_url}
|
||||
alt="Foto belanja"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TooltipProvider>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onEdit(purchase)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onDelete(purchase)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Hapus
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{purchase.photo_url && (
|
||||
<ImagePreviewModal
|
||||
open={photoPreviewOpen}
|
||||
onOpenChange={setPhotoPreviewOpen}
|
||||
src={purchase.photo_url}
|
||||
title="Foto Belanja"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
135
resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx
Normal file
135
resources/js/pages/admin/manage/purchase/purchase-sub-row.tsx
Normal file
@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { ImagePreviewModal } from '@/components/image-preview-modal';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { Purchase } from './columns';
|
||||
|
||||
function formatCurrency(amount: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
maximumFractionDigits: 4,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function VariantPhotoPreview({ url, title }: { url: string; title: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="block h-10 w-10 overflow-hidden rounded-md border transition-opacity hover:opacity-80"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</button>
|
||||
<ImagePreviewModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
src={url}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PurchaseItemSubRow({
|
||||
purchase,
|
||||
}: {
|
||||
purchase: Purchase;
|
||||
}) {
|
||||
const items = purchase.purchase_items ?? [];
|
||||
const unit =
|
||||
items[0]?.raw_material_price?.raw_material?.unit ?? '';
|
||||
|
||||
return (
|
||||
<div className="space-y-4 overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">
|
||||
No
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]">Foto</TableHead>
|
||||
<TableHead>Varian</TableHead>
|
||||
<TableHead className="text-right">Harga</TableHead>
|
||||
<TableHead className="text-center">Qty</TableHead>
|
||||
<TableHead className="text-right">Subtotal</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
Tidak ada item.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.photo_url ? (
|
||||
<VariantPhotoPreview
|
||||
url={item.raw_material_price.photo_url}
|
||||
title={item.raw_material_price.variant}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-xs text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.raw_material_price?.variant ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatCurrency(item.unit_price)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatNumber(item.quantity)} {unit}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatCurrency(item.subtotal)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -84,6 +84,12 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
return [...new Set(names)].sort();
|
||||
}, [products.data]);
|
||||
|
||||
const selectedCategory = useMemo(
|
||||
() =>
|
||||
categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||
[categories, filters.category],
|
||||
);
|
||||
|
||||
function applyFilter(key: string, value: string) {
|
||||
const newFilters = { ...filters };
|
||||
|
||||
@ -215,9 +221,10 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
Nama Produk
|
||||
</label>
|
||||
<Combobox
|
||||
items={productNames}
|
||||
value={filters.name ?? ''}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('name', value as string)
|
||||
applyFilter('name', (value as string) ?? '')
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
@ -229,11 +236,11 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
Tidak ada produk ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{productNames.map((name) => (
|
||||
<ComboboxItem key={name} value={name}>
|
||||
{(name) => (
|
||||
<ComboboxItem value={name}>
|
||||
{name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
@ -296,9 +303,14 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
Kategori
|
||||
</label>
|
||||
<Combobox
|
||||
value={filters.category ?? ''}
|
||||
items={categories}
|
||||
itemToStringLabel={(cat) => cat.name}
|
||||
value={selectedCategory}
|
||||
onValueChange={(value) =>
|
||||
applyFilter('category', value as string)
|
||||
applyFilter(
|
||||
'category',
|
||||
value ? String(value.id) : '',
|
||||
)
|
||||
}
|
||||
>
|
||||
<ComboboxInput
|
||||
@ -310,14 +322,11 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
||||
Tidak ada kategori ditemukan.
|
||||
</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{categories.map((cat) => (
|
||||
<ComboboxItem
|
||||
key={cat.id}
|
||||
value={String(cat.id)}
|
||||
>
|
||||
{(cat) => (
|
||||
<ComboboxItem value={cat}>
|
||||
{cat.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
use App\Http\Controllers\Admin\HR\AttendanceController;
|
||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
||||
use App\Http\Controllers\Admin\Manage\PurchaseController;
|
||||
use App\Http\Controllers\Admin\Master\CategoryController;
|
||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
||||
@ -108,6 +109,10 @@
|
||||
Route::put('attendances/{attendance}', [AttendanceController::class, 'update'])->name('attendances.update')->middleware('permission:attendance.check-out');
|
||||
Route::get('attendances/by-date', [AttendanceController::class, 'byDate'])->name('attendances.by-date')->middleware('permission:attendance.by-date');
|
||||
});
|
||||
|
||||
Route::prefix('admin/manage')->name('admin.manage.')->group(function () {
|
||||
Route::resource('purchases', PurchaseController::class)->except(['show'])->middleware('permission:purchase.view|purchase.create|purchase.update|purchase.delete');
|
||||
});
|
||||
});
|
||||
|
||||
require __DIR__.'/settings.php';
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
$response = $this->post(route('admin.finance.expenses.store'), [
|
||||
'amount' => 50000,
|
||||
'description' => 'Pembelian ATK',
|
||||
'description' => 'Belanja ATK',
|
||||
'receipt_key' => 'expense/receipt-001.jpg',
|
||||
]);
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
|
||||
$this->assertDatabaseHas('expenses', [
|
||||
'amount' => 50000,
|
||||
'description' => 'Pembelian ATK',
|
||||
'description' => 'Belanja ATK',
|
||||
]);
|
||||
|
||||
expect(CashAccount::first()->balance)->toBe(50000);
|
||||
@ -164,7 +164,7 @@
|
||||
|
||||
$response = $this->put(route('admin.finance.expenses.update', $expense), [
|
||||
'amount' => 40000,
|
||||
'description' => 'Pembelian ATK Updated',
|
||||
'description' => 'Belanja ATK Updated',
|
||||
'receipt_key' => 'expense/receipt-updated.jpg',
|
||||
]);
|
||||
|
||||
@ -174,7 +174,7 @@
|
||||
|
||||
$expense->refresh();
|
||||
expect($expense->amount)->toBe(40000);
|
||||
expect($expense->description)->toBe('Pembelian ATK Updated');
|
||||
expect($expense->description)->toBe('Belanja ATK Updated');
|
||||
});
|
||||
|
||||
test('expense update requires receipt_key', function () {
|
||||
|
||||
1012
tests/Feature/Admin/Manage/PurchaseTest.php
Normal file
1012
tests/Feature/Admin/Manage/PurchaseTest.php
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user