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::PRODUCT => 'Produk',
|
||||||
self::EMPLOYEE => 'Karyawan',
|
self::EMPLOYEE => 'Karyawan',
|
||||||
self::ORDER => 'Pesanan',
|
self::ORDER => 'Pesanan',
|
||||||
self::PURCHASE => 'Pembelian',
|
self::PURCHASE => 'Belanja',
|
||||||
self::RAW_MATERIAL => 'Bahan Baku',
|
self::RAW_MATERIAL => 'Bahan Baku',
|
||||||
self::EXPENSE => 'Pengeluaran',
|
self::EXPENSE => 'Pengeluaran',
|
||||||
self::USER => 'Pengguna',
|
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\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Spatie\MediaLibrary\HasMedia;
|
||||||
|
use Spatie\MediaLibrary\InteractsWithMedia;
|
||||||
|
|
||||||
#[Guarded(['id'])]
|
#[Guarded(['id'])]
|
||||||
class Purchase extends Model
|
class Purchase extends Model implements HasMedia
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
use HasFactory, InteractsWithMedia, SoftDeletes;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
|
|||||||
@ -29,7 +29,7 @@ public function purchase(): BelongsTo
|
|||||||
|
|
||||||
public function rawMaterialPrice(): BelongsTo
|
public function rawMaterialPrice(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(RawMaterialPrice::class);
|
return $this->belongsTo(RawMaterialPrice::class)->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Services\S3PresignedService;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
use Illuminate\Database\Eloquent\Attributes\Guarded;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@ -36,6 +37,15 @@ public function purchaseItems(): HasMany
|
|||||||
|
|
||||||
public function rawMaterial(): BelongsTo
|
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'],
|
'leave-request' => ['view', 'create', 'update', 'delete', 'approve', 'reject'],
|
||||||
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
'attendance' => ['view', 'check-in', 'check-out', 'by-date'],
|
||||||
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
'settings' => ['view', 'update-system', 'update-homepage', 'update-social-media', 'update-marketplace', 'update-hr'],
|
||||||
|
'purchase' => ['view', 'create', 'update', 'delete'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($permissions as $module => $actions) {
|
foreach ($permissions as $module => $actions) {
|
||||||
@ -53,6 +54,7 @@ public function run(): void
|
|||||||
|
|
||||||
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
'Admin Bahan Baku' => array_filter($allPermissions, function ($p) {
|
||||||
return str_starts_with($p, 'supplier.')
|
return str_starts_with($p, 'supplier.')
|
||||||
|
|| str_starts_with($p, 'purchase.')
|
||||||
|| $p === 'category.view'
|
|| $p === 'category.view'
|
||||||
|| $p === 'customer.view'
|
|| $p === 'customer.view'
|
||||||
|| $p === 'cash-account.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 customersIndex } from '@/routes/admin/master/customers';
|
||||||
import { index as productsIndex } from '@/routes/admin/master/products';
|
import { index as productsIndex } from '@/routes/admin/master/products';
|
||||||
import { index as rawMaterialsIndex } from '@/routes/admin/master/raw-materials';
|
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 suppliersIndex } from '@/routes/admin/master/suppliers';
|
||||||
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
import { index as rolesIndex } from '@/routes/admin/settings/roles';
|
||||||
import { Link, router } from '@inertiajs/react';
|
import { Link, router } from '@inertiajs/react';
|
||||||
@ -75,7 +76,7 @@ const masterItems: NavMenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const kelolaItems: NavMenuItem[] = [
|
const kelolaItems: NavMenuItem[] = [
|
||||||
{ title: 'Belanja', href: '#', icon: ShoppingCart },
|
{ title: 'Belanja', href: purchasesIndex.url(), icon: ShoppingCart },
|
||||||
{ title: 'Cutting', href: '#', icon: Scissors },
|
{ title: 'Cutting', href: '#', icon: Scissors },
|
||||||
{ title: 'Restock', href: '#', icon: RefreshCw },
|
{ title: 'Restock', href: '#', icon: RefreshCw },
|
||||||
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
{ title: 'Stok Opname', href: '#', icon: ClipboardCheck },
|
||||||
@ -101,6 +102,8 @@ const sistemItems: NavMenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
||||||
|
const { isCurrentUrl } = useCurrentUrl();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{label}</SidebarGroupLabel>
|
||||||
@ -109,6 +112,7 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) {
|
|||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
|
isActive={isCurrentUrl(item.href)}
|
||||||
tooltip={{ children: item.title }}
|
tooltip={{ children: item.title }}
|
||||||
>
|
>
|
||||||
<Link href={item.href} prefetch>
|
<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> = {
|
const labels: Record<string, string> = {
|
||||||
'App\\Models\\Expense': 'Pengeluaran',
|
'App\\Models\\Expense': 'Pengeluaran',
|
||||||
'App\\Models\\Order': 'Penjualan Tunai',
|
'App\\Models\\Order': 'Penjualan Tunai',
|
||||||
'App\\Models\\Purchase': 'Pembelian',
|
'App\\Models\\Purchase': 'Belanja',
|
||||||
'App\\Models\\CashAccount': 'Transfer Kas',
|
'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();
|
return [...new Set(names)].sort();
|
||||||
}, [products.data]);
|
}, [products.data]);
|
||||||
|
|
||||||
|
const selectedCategory = useMemo(
|
||||||
|
() =>
|
||||||
|
categories.find((c) => String(c.id) === filters.category) ?? null,
|
||||||
|
[categories, filters.category],
|
||||||
|
);
|
||||||
|
|
||||||
function applyFilter(key: string, value: string) {
|
function applyFilter(key: string, value: string) {
|
||||||
const newFilters = { ...filters };
|
const newFilters = { ...filters };
|
||||||
|
|
||||||
@ -215,9 +221,10 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
Nama Produk
|
Nama Produk
|
||||||
</label>
|
</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
|
items={productNames}
|
||||||
value={filters.name ?? ''}
|
value={filters.name ?? ''}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
applyFilter('name', value as string)
|
applyFilter('name', (value as string) ?? '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
@ -229,11 +236,11 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
Tidak ada produk ditemukan.
|
Tidak ada produk ditemukan.
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{productNames.map((name) => (
|
{(name) => (
|
||||||
<ComboboxItem key={name} value={name}>
|
<ComboboxItem value={name}>
|
||||||
{name}
|
{name}
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
))}
|
)}
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
@ -296,9 +303,14 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
Kategori
|
Kategori
|
||||||
</label>
|
</label>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={filters.category ?? ''}
|
items={categories}
|
||||||
|
itemToStringLabel={(cat) => cat.name}
|
||||||
|
value={selectedCategory}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
applyFilter('category', value as string)
|
applyFilter(
|
||||||
|
'category',
|
||||||
|
value ? String(value.id) : '',
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ComboboxInput
|
<ComboboxInput
|
||||||
@ -310,14 +322,11 @@ export default function ProductIndex({ products, categories, filters }: Props) {
|
|||||||
Tidak ada kategori ditemukan.
|
Tidak ada kategori ditemukan.
|
||||||
</ComboboxEmpty>
|
</ComboboxEmpty>
|
||||||
<ComboboxList>
|
<ComboboxList>
|
||||||
{categories.map((cat) => (
|
{(cat) => (
|
||||||
<ComboboxItem
|
<ComboboxItem value={cat}>
|
||||||
key={cat.id}
|
|
||||||
value={String(cat.id)}
|
|
||||||
>
|
|
||||||
{cat.name}
|
{cat.name}
|
||||||
</ComboboxItem>
|
</ComboboxItem>
|
||||||
))}
|
)}
|
||||||
</ComboboxList>
|
</ComboboxList>
|
||||||
</ComboboxContent>
|
</ComboboxContent>
|
||||||
</Combobox>
|
</Combobox>
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
use App\Http\Controllers\Admin\HR\AttendanceController;
|
use App\Http\Controllers\Admin\HR\AttendanceController;
|
||||||
use App\Http\Controllers\Admin\HR\EmployeeController;
|
use App\Http\Controllers\Admin\HR\EmployeeController;
|
||||||
use App\Http\Controllers\Admin\HR\LeaveRequestController;
|
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\CategoryController;
|
||||||
use App\Http\Controllers\Admin\Master\CustomerController;
|
use App\Http\Controllers\Admin\Master\CustomerController;
|
||||||
use App\Http\Controllers\Admin\Master\Product\ProductController;
|
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::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::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';
|
require __DIR__.'/settings.php';
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
$response = $this->post(route('admin.finance.expenses.store'), [
|
$response = $this->post(route('admin.finance.expenses.store'), [
|
||||||
'amount' => 50000,
|
'amount' => 50000,
|
||||||
'description' => 'Pembelian ATK',
|
'description' => 'Belanja ATK',
|
||||||
'receipt_key' => 'expense/receipt-001.jpg',
|
'receipt_key' => 'expense/receipt-001.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -63,7 +63,7 @@
|
|||||||
|
|
||||||
$this->assertDatabaseHas('expenses', [
|
$this->assertDatabaseHas('expenses', [
|
||||||
'amount' => 50000,
|
'amount' => 50000,
|
||||||
'description' => 'Pembelian ATK',
|
'description' => 'Belanja ATK',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(CashAccount::first()->balance)->toBe(50000);
|
expect(CashAccount::first()->balance)->toBe(50000);
|
||||||
@ -164,7 +164,7 @@
|
|||||||
|
|
||||||
$response = $this->put(route('admin.finance.expenses.update', $expense), [
|
$response = $this->put(route('admin.finance.expenses.update', $expense), [
|
||||||
'amount' => 40000,
|
'amount' => 40000,
|
||||||
'description' => 'Pembelian ATK Updated',
|
'description' => 'Belanja ATK Updated',
|
||||||
'receipt_key' => 'expense/receipt-updated.jpg',
|
'receipt_key' => 'expense/receipt-updated.jpg',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -174,7 +174,7 @@
|
|||||||
|
|
||||||
$expense->refresh();
|
$expense->refresh();
|
||||||
expect($expense->amount)->toBe(40000);
|
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 () {
|
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