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:
Yoga Pangestu 2026-08-02 14:07:16 +07:00
parent 75f821957d
commit 9637671713
22 changed files with 5070 additions and 23 deletions

View File

@ -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',

View 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'
);
}
}

View 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',
];
}
}

View File

@ -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
{

View File

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

View File

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

View 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;
});
}
}

View File

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

View File

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

View 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]);
}

View 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
}
}

View File

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

View 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;
}[];
}[];
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>
</>
);
}

View 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"
/>
)}
</>
);
}

View 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>
);
}

View File

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

View File

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

View File

@ -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 () {

File diff suppressed because it is too large Load Diff