diff --git a/app/Enums/Modules.php b/app/Enums/Modules.php index b1971d7..0ff3d83 100644 --- a/app/Enums/Modules.php +++ b/app/Enums/Modules.php @@ -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', diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php new file mode 100644 index 0000000..66de86e --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -0,0 +1,73 @@ + $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' + ); + } +} diff --git a/app/Http/Requests/Admin/Manage/PurchaseRequest.php b/app/Http/Requests/Admin/Manage/PurchaseRequest.php new file mode 100644 index 0000000..7417590 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/PurchaseRequest.php @@ -0,0 +1,80 @@ +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', + ]; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php index 7d786d5..f69d25a 100644 --- a/app/Models/Purchase.php +++ b/app/Models/Purchase.php @@ -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 { diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php index 9d23220..f7e6f04 100644 --- a/app/Models/PurchaseItem.php +++ b/app/Models/PurchaseItem.php @@ -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 diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php index fbc36b3..894ca11 100644 --- a/app/Models/RawMaterialPrice.php +++ b/app/Models/RawMaterialPrice.php @@ -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; } } diff --git a/app/Services/Admin/Manage/PurchaseService.php b/app/Services/Admin/Manage/PurchaseService.php new file mode 100644 index 0000000..f2df4ef --- /dev/null +++ b/app/Services/Admin/Manage/PurchaseService.php @@ -0,0 +1,507 @@ +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; + }); + } +} diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php index 8f4cc3e..a5acbee 100644 --- a/database/seeders/RolePermissionSeeder.php +++ b/database/seeders/RolePermissionSeeder.php @@ -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'; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 248aa89..a747883 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -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 ( {label} @@ -109,6 +112,7 @@ function MenuGroup({ label, items }: { label: string; items: NavMenuItem[] }) { diff --git a/resources/js/hooks/use-purchase-draft.ts b/resources/js/hooks/use-purchase-draft.ts new file mode 100644 index 0000000..46263e5 --- /dev/null +++ b/resources/js/hooks/use-purchase-draft.ts @@ -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 | 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]); +} diff --git a/resources/js/lib/purchase-draft.ts b/resources/js/lib/purchase-draft.ts new file mode 100644 index 0000000..e739172 --- /dev/null +++ b/resources/js/lib/purchase-draft.ts @@ -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; + 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 + } +} diff --git a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx index 9cba56d..af5db19 100644 --- a/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx +++ b/resources/js/pages/admin/finance/cash-account/transaction-columns.tsx @@ -62,7 +62,7 @@ function getReferenceLabel(type: string): string { const labels: Record = { 'App\\Models\\Expense': 'Pengeluaran', 'App\\Models\\Order': 'Penjualan Tunai', - 'App\\Models\\Purchase': 'Pembelian', + 'App\\Models\\Purchase': 'Belanja', 'App\\Models\\CashAccount': 'Transfer Kas', }; diff --git a/resources/js/pages/admin/manage/purchase/columns.tsx b/resources/js/pages/admin/manage/purchase/columns.tsx new file mode 100644 index 0000000..95a4102 --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/columns.tsx @@ -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; +}; + +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; + }[]; + }[]; +}; diff --git a/resources/js/pages/admin/manage/purchase/create.tsx b/resources/js/pages/admin/manage/purchase/create.tsx new file mode 100644 index 0000000..1847aab --- /dev/null +++ b/resources/js/pages/admin/manage/purchase/create.tsx @@ -0,0 +1,1356 @@ +'use no memo'; + +import { Form, Head, usePage } from '@inertiajs/react'; +import { + ArrowLeft, + Check, + ClipboardPaste, + Copy, + Minus, + Plus, + ShoppingCart, + Trash2, +} from 'lucide-react'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; +import InputError from '@/components/input-error'; +import { RupiahInput } from '@/components/rupiah-input'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from '@/components/ui/combobox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { usePurchaseDraftSave } from '@/hooks/use-purchase-draft'; +import { loadPurchaseDraft } from '@/lib/purchase-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { formatCurrency } from '@/lib/utils'; +import { index as purchaseIndex, store } from '@/routes/admin/manage/purchases'; +import type { PurchaseCreateData } from './columns'; + +const UNITS = [ + { value: 'kg', label: 'Kilogram' }, + { value: 'meter', label: 'Meter' }, + { value: 'yard', label: 'Yard' }, +]; + +type VariantState = { + variant: string; + price: number; + stock: number; + photo: string | null; + photoUrl: string | null; + uploading: boolean; +}; + +type CartLine = { + key: string; + photoUrl: string | null; + title: string; + subtitle: string; + price: number; + quantity: number; + onAdjust: (delta: number) => void; + onSet: (value: number) => void; + onRemove: () => void; +}; + +type Props = { + data: PurchaseCreateData; +}; + +export default function PurchaseCreate({ data }: Props) { + const { suppliers, rawMaterials } = data; + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + const draft = loadPurchaseDraft('create', userId); + + const [name, setName] = useState(draft?.name ?? ''); + const [unit, setUnit] = useState(draft?.unit ?? 'kg'); + const [variants, setVariants] = useState(() => { + if (draft?.variants && draft.variants.length > 0) { + return draft.variants.map((v) => ({ + variant: v.variant, + price: v.price, + stock: v.stock, + photo: v.photo ?? null, + photoUrl: v.photo ? getTemporaryUrl(v.photo) : null, + uploading: false, + })); + } + + return [ + { + variant: '', + price: 0, + stock: 0, + photo: null, + photoUrl: null, + uploading: false, + }, + ]; + }); + + const [supplierId, setSupplierId] = useState(draft?.supplierId ?? ''); + const selectedSupplier = + suppliers.find((s) => String(s.id) === supplierId) ?? null; + const [discount, setDiscount] = useState(draft?.discount ?? 0); + const [shippingCost, setShippingCost] = useState(draft?.shippingCost ?? 0); + const [notes, setNotes] = useState(draft?.notes ?? ''); + const [photo, setPhoto] = useState(draft?.photo ?? null); + const [photoUrl, setPhotoUrl] = useState( + draft?.photo ? getTemporaryUrl(draft.photo) : null, + ); + const [uploading, setUploading] = useState(false); + + const [mode, setMode] = useState<'new' | 'existing'>(draft?.mode ?? 'new'); + const [selectedMaterialName, setSelectedMaterialName] = useState( + draft?.selectedMaterialName ?? '', + ); + const [quantities, setQuantities] = useState>(() => + Object.fromEntries( + Object.entries(draft?.quantities ?? {}).map(([id, qty]) => [ + Number(id), + qty, + ]), + ), + ); + const [cartOpen, setCartOpen] = useState(false); + const [previewKey, setPreviewKey] = useState(null); + const [cartRemoveKey, setCartRemoveKey] = useState(null); + + const draftData = useMemo( + () => ({ + name, + unit, + supplierId, + discount, + shippingCost, + notes, + variants: variants.map((v) => ({ + variant: v.variant, + price: v.price, + stock: v.stock, + photo: v.photo ?? undefined, + })), + mode, + selectedMaterialName, + quantities: Object.fromEntries( + Object.entries(quantities).map(([id, qty]) => [ + String(id), + qty, + ]), + ), + photo: photo ?? undefined, + }), + [ + name, + unit, + supplierId, + discount, + shippingCost, + notes, + variants, + mode, + selectedMaterialName, + quantities, + photo, + ], + ); + + usePurchaseDraftSave('create', draftData, userId); + + const variantsRef = useRef(variants); + variantsRef.current = variants; + + const priceMap = useMemo( + () => + new Map( + rawMaterials.flatMap((m) => + m.raw_material_prices.map((p) => [p.id, p]), + ), + ), + [rawMaterials], + ); + + const materialByPriceId = useMemo( + () => + new Map( + rawMaterials.flatMap((m) => + m.raw_material_prices.map((p) => [ + p.id, + { name: m.name, unit: m.unit }, + ]), + ), + ), + [rawMaterials], + ); + + const newSubtotal = variants.reduce( + (sum, v) => sum + Number(v.price) * Number(v.stock), + 0, + ); + const existingSubtotal = Object.entries(quantities).reduce( + (sum, [priceId, quantity]) => { + const price = priceMap.get(Number(priceId)); + + return sum + (price ? price.price * quantity : 0); + }, + 0, + ); + const subtotal = mode === 'existing' ? existingSubtotal : newSubtotal; + const total = subtotal - discount + shippingCost; + + const addVariant = useCallback(() => { + setVariants((prev) => [ + ...prev, + { + variant: '', + price: 0, + stock: 0, + photo: null, + photoUrl: null, + uploading: false, + }, + ]); + }, []); + + const removeVariant = useCallback((index: number) => { + setVariants((prev) => prev.filter((_, i) => i !== index)); + }, []); + + const updateVariant = useCallback( + (index: number, field: keyof VariantState, value: unknown) => { + setVariants((prev) => { + const updated = [...prev]; + (updated[index] as Record)[field] = value; + + return updated; + }); + }, + [], + ); + + const [copiedIndex, setCopiedIndex] = useState(null); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteVariantIndex, setDeleteVariantIndex] = useState( + null, + ); + + const confirmRemoveVariant = useCallback((index: number) => { + setDeleteVariantIndex(index); + setDeleteConfirmOpen(true); + }, []); + + const copyPrice = useCallback((variantIndex: number) => { + setVariants((prev) => { + const price = prev[variantIndex].price; + navigator.clipboard.writeText(String(price)); + setCopiedIndex(variantIndex); + setTimeout(() => setCopiedIndex(null), 1500); + + return prev; + }); + }, []); + + const pastePrice = useCallback((variantIndex: number) => { + navigator.clipboard.readText().then((text) => { + try { + const price = Number(text); + + if (!isNaN(price)) { + setVariants((prev) => { + const updated = [...prev]; + updated[variantIndex] = { + ...updated[variantIndex], + price, + }; + + return updated; + }); + } + } catch { + // invalid clipboard data + } + }); + }, []); + + const applyToAll = useCallback((variantIndex: number) => { + setVariants((prev) => { + const sourcePrice = prev[variantIndex].price; + + return prev.map((v, i) => + i === variantIndex ? v : { ...v, price: sourcePrice }, + ); + }); + }, []); + + const selectedMaterial = useMemo( + () => rawMaterials.find((m) => m.name === selectedMaterialName) ?? null, + [rawMaterials, selectedMaterialName], + ); + + const updateQuantity = useCallback((priceId: number, value: number) => { + setQuantities((prev) => ({ + ...prev, + [priceId]: Math.max(0, value), + })); + }, []); + + const incrementQuantity = useCallback((priceId: number, amount: number) => { + setQuantities((prev) => ({ + ...prev, + [priceId]: Math.max(0, (prev[priceId] ?? 0) + amount), + })); + }, []); + + const adjustVariantStock = useCallback((index: number, amount: number) => { + setVariants((prev) => { + const updated = [...prev]; + updated[index] = { + ...updated[index], + stock: Math.max(0, Number(updated[index].stock) + amount), + }; + + return updated; + }); + }, []); + + const cartItems: CartLine[] = (() => { + if (mode === 'existing') { + const lines: CartLine[] = []; + + for (const [priceId, quantity] of Object.entries(quantities)) { + if (quantity <= 0) { + continue; + } + + const id = Number(priceId); + const price = priceMap.get(id); + const material = materialByPriceId.get(id); + + if (price && material) { + lines.push({ + key: `existing-${id}`, + photoUrl: price.photo_url, + title: `${material.name} — ${price.variant}`, + subtitle: `${formatCurrency(price.price)} / ${material.unit}`, + price: price.price, + quantity, + onAdjust: (delta) => incrementQuantity(id, delta), + onSet: (value) => updateQuantity(id, value), + onRemove: () => updateQuantity(id, 0), + }); + } + } + + return lines; + } + + return variants + .map((v, index): CartLine => ({ + key: `new-${index}`, + photoUrl: v.photoUrl, + title: `${name || 'Bahan Baku Baru'} — ${v.variant || `Varian ${index + 1}`}`, + subtitle: `${formatCurrency(v.price)} / ${unit}`, + price: v.price, + quantity: v.stock, + onAdjust: (delta) => adjustVariantStock(index, delta), + onSet: (value) => updateVariant(index, 'stock', value), + onRemove: () => removeVariant(index), + })) + .filter((line) => line.quantity > 0); + })(); + + function formatQuantity(value: number): string { + return new Intl.NumberFormat('id-ID', { + maximumFractionDigits: 4, + }).format(value); + } + + function getPayload() { + const base = { + mode, + supplier_id: supplierId ? Number(supplierId) : null, + discount, + shipping_cost: shippingCost, + notes: notes || null, + photo_key: photo, + }; + + if (mode === 'existing') { + return { + ...base, + existing_items: Object.entries(quantities) + .map(([priceId, quantity]) => ({ + raw_material_price_id: Number(priceId), + quantity: Number(quantity), + unit_price: priceMap.get(Number(priceId))?.price ?? 0, + })) + .filter((item) => item.quantity > 0), + }; + } + + return { + ...base, + name, + unit, + variants: variantsRef.current.map((v) => ({ + variant: v.variant, + price: Number(v.price), + stock: Number(v.stock), + photo_key: v.photo, + })), + }; + } + + return ( + <> + + +
+
+

+ Tambah Belanja +

+ +
+ +
({ + ...data, + ...getPayload(), + })} + > + {({ errors, processing }) => ( +
+
+ + setMode(value as 'new' | 'existing') + } + > + + + Baru + + + Lama + + + + + + + + Informasi Bahan Baku + + + +
+ + + setName( + e.target.value, + ) + } + placeholder="Masukkan nama bahan baku" + /> + +
+
+ + + {UNITS.map((u) => ( +
+ + +
+ ))} +
+ +
+
+
+ + + + + Varian Bahan Baku + + + + {variants.map( + (variant, variantIndex) => ( +
+
+

+ Varian{' '} + {variantIndex + + 1} +

+
+ + + + {variantIndex > + 0 && ( + + )} +
+
+
+
+ + + updateVariant( + variantIndex, + 'variant', + e + .target + .value, + ) + } + placeholder="Contoh: Ukuran L, Warna Merah" + /> + +
+
+ + + updateVariant( + variantIndex, + 'price', + val, + ) + } + /> + +
+
+ + + updateVariant( + variantIndex, + 'stock', + Number( + e + .target + .value, + ), + ) + } + /> + +
+
+
+ + { + updateVariant( + variantIndex, + 'photo', + key, + ); + updateVariant( + variantIndex, + 'photoUrl', + key + ? getTemporaryUrl( + key, + ) + : null, + ); + }} + folder="raw-material-variant" + existingUrl={ + variant.photoUrl + } + onUploadingChange={( + uploading, + ) => + updateVariant( + variantIndex, + 'uploading', + uploading, + ) + } + /> + +
+
+ ), + )} + + +
+
+
+ + + + + + Pilih Bahan Baku + + + +
+ + + m.name + } + value={selectedMaterial} + onValueChange={( + value, + ) => + setSelectedMaterialName( + value?.name ?? + '', + ) + } + > + + + + Tidak ada bahan + baku ditemukan. + + + {(m) => ( + + {m.name}{' '} + ({m.unit}) + + )} + + + + +
+ + {selectedMaterial && ( +
+ {selectedMaterial.raw_material_prices.map( + (price) => ( +
+ 0 + ? 'flex items-center justify-between gap-3 rounded-lg border border-primary p-3' + : 'flex items-center justify-between gap-3 rounded-lg border p-3' + } + > +
+ {price.photo_url ? ( + { + ) : ( +
+ N/A +
+ )} +
+

+ { + price.variant + } +

+

+ Stok:{' '} + {formatQuantity( + Number( + price.stock, + ), + )}{' '} + { + selectedMaterial.unit + }{' '} + ·{' '} + {formatCurrency( + price.price, + )} +

+
+
+
+ + + updateQuantity( + price.id, + Number( + e + .target + .value, + ), + ) + } + /> + +
+
+ ), + )} +
+ )} +
+
+
+
+
+ +
+ + + Ringkasan + + +
+ + + s.name + } + value={selectedSupplier} + onValueChange={(value) => + setSupplierId( + value + ? String( + value.id, + ) + : '', + ) + } + > + + + + Tidak ada supplier + ditemukan. + + + {(s) => ( + + {s.name} + + )} + + + + +
+ +
+
+ + Subtotal + + + {formatCurrency(subtotal)} + +
+
+ + Diskon + +
+ +
+
+ +
+ + Ongkir + +
+ +
+
+ +
+
+ Total + + {formatCurrency(total)} + +
+
+
+ +
+ +