From 04e57c0dd1f172c4b9220891b3d1ee31c150bc07 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Fri, 12 Jun 2026 00:09:20 +0700 Subject: [PATCH] Add purchase management features including Purchase and PurchaseItem models, PurchaseController for CRUD operations, and corresponding request validation. Implement UI components for creating, editing, and listing purchases, along with permissions in the Permission and Role enums. Enhance sidebar navigation for purchases and integrate media handling for purchase items. --- app/Enums/Permission.php | 12 + app/Enums/Role.php | 8 + .../Admin/Manage/PurchaseController.php | 76 ++++ .../Requests/Admin/Manage/PurchaseRequest.php | 42 ++ app/Models/Purchase.php | 88 ++++ app/Models/PurchaseItem.php | 81 ++++ app/Models/Supplier.php | 6 + app/Models/User.php | 5 + app/Services/Manage/PurchaseService.php | 295 ++++++++++++ ...26_06_11_120001_create_purchases_table.php | 32 ++ ..._11_120002_create_purchase_items_table.php | 31 ++ package-lock.json | 8 +- package.json | 2 +- resources/css/app.css | 2 + resources/js/components/AppSidebar.vue | 19 +- .../manage/purchases/PurchaseGroupedTable.vue | 168 +++++++ .../manage/purchases/PurchasePosForm.vue | 424 ++++++++++++++++++ .../manage/purchases/data-table-actions.vue | 80 ++++ .../components/media/MediaThumbnailCell.vue | 1 - .../pages/admin/manage/purchases/Create.vue | 42 ++ .../js/pages/admin/manage/purchases/Edit.vue | 61 +++ .../js/pages/admin/manage/purchases/Index.vue | 89 ++++ resources/js/types/purchase.ts | 80 ++++ routes/web.php | 29 ++ 24 files changed, 1674 insertions(+), 7 deletions(-) create mode 100644 app/Http/Controllers/Admin/Manage/PurchaseController.php create mode 100644 app/Http/Requests/Admin/Manage/PurchaseRequest.php create mode 100644 app/Models/Purchase.php create mode 100644 app/Models/PurchaseItem.php create mode 100644 app/Services/Manage/PurchaseService.php create mode 100644 database/migrations/2026_06_11_120001_create_purchases_table.php create mode 100644 database/migrations/2026_06_11_120002_create_purchase_items_table.php create mode 100644 resources/js/components/admin/manage/purchases/PurchaseGroupedTable.vue create mode 100644 resources/js/components/admin/manage/purchases/PurchasePosForm.vue create mode 100644 resources/js/components/admin/manage/purchases/data-table-actions.vue create mode 100644 resources/js/pages/admin/manage/purchases/Create.vue create mode 100644 resources/js/pages/admin/manage/purchases/Edit.vue create mode 100644 resources/js/pages/admin/manage/purchases/Index.vue create mode 100644 resources/js/types/purchase.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index 5945602..eb03a87 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -55,6 +55,11 @@ enum Permission: string case RAW_MATERIALS_DELETE = 'raw-materials.delete'; case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status'; + case PURCHASES_VIEW = 'purchases.view'; + case PURCHASES_CREATE = 'purchases.create'; + case PURCHASES_UPDATE = 'purchases.update'; + case PURCHASES_DELETE = 'purchases.delete'; + case CASH_VIEW = 'cash.view'; case CASH_DEPOSIT = 'cash.deposit'; case CASH_UPDATE = 'cash.update'; @@ -130,6 +135,11 @@ public function label(): string self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku', self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku', + self::PURCHASES_VIEW => 'Lihat Belanja', + self::PURCHASES_CREATE => 'Catat Belanja', + self::PURCHASES_UPDATE => 'Ubah Belanja', + self::PURCHASES_DELETE => 'Hapus Belanja', + self::CASH_VIEW => 'Lihat Kas', self::CASH_DEPOSIT => 'Setor Kas', self::CASH_UPDATE => 'Ubah Setor Kas', @@ -177,6 +187,8 @@ public function group(): string self::PRODUCTS_DELETE, self::PRODUCTS_TOGGLE_STATUS => 'Produk', self::RAW_MATERIALS_VIEW, self::RAW_MATERIALS_CREATE, self::RAW_MATERIALS_UPDATE, self::RAW_MATERIALS_DELETE, self::RAW_MATERIALS_TOGGLE_STATUS => 'Bahan Baku', + self::PURCHASES_VIEW, self::PURCHASES_CREATE, self::PURCHASES_UPDATE, + self::PURCHASES_DELETE => 'Belanja', self::CASH_VIEW, self::CASH_DEPOSIT, self::CASH_UPDATE, self::CASH_DELETE => 'Kas', self::EXPENSES_VIEW, self::EXPENSES_CREATE, self::EXPENSES_UPDATE, self::EXPENSES_DELETE => 'Pengeluaran', diff --git a/app/Enums/Role.php b/app/Enums/Role.php index 246bcbe..ee02d19 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -74,6 +74,10 @@ public function permissions(): array Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_DELETE, Permission::RAW_MATERIALS_TOGGLE_STATUS, + Permission::PURCHASES_VIEW, + Permission::PURCHASES_CREATE, + Permission::PURCHASES_UPDATE, + Permission::PURCHASES_DELETE, Permission::CASH_VIEW, Permission::CASH_DEPOSIT, Permission::CASH_UPDATE, @@ -166,6 +170,10 @@ public function permissions(): array Permission::RAW_MATERIALS_UPDATE, Permission::RAW_MATERIALS_DELETE, Permission::RAW_MATERIALS_TOGGLE_STATUS, + Permission::PURCHASES_VIEW, + Permission::PURCHASES_CREATE, + Permission::PURCHASES_UPDATE, + Permission::PURCHASES_DELETE, ], self::MARKETING => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/Manage/PurchaseController.php b/app/Http/Controllers/Admin/Manage/PurchaseController.php new file mode 100644 index 0000000..9e790d6 --- /dev/null +++ b/app/Http/Controllers/Admin/Manage/PurchaseController.php @@ -0,0 +1,76 @@ +parseDataTableQuery($request); + + return Inertia::render('admin/manage/purchases/Index', [ + 'purchases' => $this->purchaseService->paginateForIndex($tableQuery), + 'filters' => $this->dataTableFilters($tableQuery), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/manage/purchases/Create', [ + 'suppliers' => $this->purchaseService->supplierOptions(), + 'catalog' => $this->purchaseService->catalogItems(), + ]); + } + + public function store(PurchaseRequest $request): RedirectResponse + { + $this->purchaseService->create($request->validated(), $request->user()); + + Inertia::flash('success', 'Belanja berhasil ditambahkan.'); + + return redirect()->route('admin.manage.purchases.index'); + } + + public function edit(Purchase $purchase): Response + { + return Inertia::render('admin/manage/purchases/Edit', [ + 'purchase' => $this->purchaseService->findForEdit($purchase), + 'suppliers' => $this->purchaseService->supplierOptions(), + 'catalog' => $this->purchaseService->catalogItems($purchase), + ]); + } + + public function update(PurchaseRequest $request, Purchase $purchase): RedirectResponse + { + $this->purchaseService->update($purchase, $request->validated()); + + Inertia::flash('success', 'Belanja berhasil diperbarui.'); + + return redirect()->route('admin.manage.purchases.index'); + } + + public function destroy(Purchase $purchase): RedirectResponse + { + $this->purchaseService->delete($purchase); + + Inertia::flash('success', 'Belanja berhasil dihapus.'); + + return redirect()->route('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..429d720 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/PurchaseRequest.php @@ -0,0 +1,42 @@ +isMethod('POST') + ? Permission::PURCHASES_CREATE + : Permission::PURCHASES_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')], + 'discount' => ['nullable', 'integer', 'min:0'], + 'notes' => ['nullable', 'string', 'max:100'], + 'items' => ['required', 'array', 'min:1'], + 'items.*.raw_material_price_id' => [ + 'required', + 'integer', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ], + 'items.*.quantity' => ['required', 'numeric', 'gt:0'], + ...$this->photoRules(), + ]; + } +} diff --git a/app/Models/Purchase.php b/app/Models/Purchase.php new file mode 100644 index 0000000..c046202 --- /dev/null +++ b/app/Models/Purchase.php @@ -0,0 +1,88 @@ +addMediaCollection('photos'); + } + + protected function casts(): array + { + return [ + 'subtotal' => 'integer', + 'discount' => 'integer', + 'total' => 'integer', + ]; + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function discountFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'), + ); + } + + public function totalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'), + ); + } + + public function createdAtFormatted(): Attribute + { + return Attribute::make( + get: fn () => $this->created_at?->translatedFormat('l, d F Y H:i'), + ); + } + + public function supplier(): BelongsTo + { + return $this->belongsTo(Supplier::class); + } + + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function items(): HasMany + { + return $this->hasMany(PurchaseItem::class); + } +} diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php new file mode 100644 index 0000000..a4da751 --- /dev/null +++ b/app/Models/PurchaseItem.php @@ -0,0 +1,81 @@ + 'decimal:4', + 'unit_price' => 'integer', + 'subtotal' => 'integer', + ]; + } + + public function quantityFormatted(): Attribute + { + return Attribute::make( + get: function () { + $formatted = rtrim(rtrim(number_format((float) $this->quantity, 4, ',', '.'), '0'), ','); + + return "{$formatted} {$this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation()}"; + }, + ); + } + + public function quantityInput(): Attribute + { + return Attribute::make( + get: fn () => rtrim(rtrim(number_format((float) $this->quantity, 4, '.', ''), '0'), '.'), + ); + } + + public function unitPriceFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->unit_price, 0, ',', '.'), + ); + } + + public function subtotalFormatted(): Attribute + { + return Attribute::make( + get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'), + ); + } + + public function unitAbbreviation(): Attribute + { + return Attribute::make( + get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), + ); + } + + public function purchase(): BelongsTo + { + return $this->belongsTo(Purchase::class); + } + + public function rawMaterialPrice(): BelongsTo + { + return $this->belongsTo(RawMaterialPrice::class); + } +} diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php index b313adc..37d1f92 100644 --- a/app/Models/Supplier.php +++ b/app/Models/Supplier.php @@ -4,10 +4,16 @@ use Illuminate\Database\Eloquent\Attributes\Guarded; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; #[Guarded(['id'])] class Supplier extends Model { use SoftDeletes; + + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index b2ebd99..78c797b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -75,4 +75,9 @@ public function expenses(): HasMany { return $this->hasMany(Expense::class, 'created_by_id'); } + + public function purchases(): HasMany + { + return $this->hasMany(Purchase::class, 'created_by_id'); + } } diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php new file mode 100644 index 0000000..09c4e56 --- /dev/null +++ b/app/Services/Manage/PurchaseService.php @@ -0,0 +1,295 @@ +with([ + 'supplier:id,name', + 'createdBy.profile', + 'items.rawMaterialPrice.rawMaterial:id,name,unit', + 'media', + ]) + ->when($tableQuery['search'] !== '', function (Builder $query) use ($tableQuery): void { + $search = $tableQuery['search']; + $query->where(function (Builder $query) use ($search): void { + $query->where('notes', 'like', "%{$search}%") + ->orWhereHas('supplier', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")) + ->orWhereHas('items.rawMaterialPrice', function (Builder $query) use ($search): void { + $query->where('variant', 'like', "%{$search}%") + ->orWhereHas('rawMaterial', fn (Builder $query) => $query->where('name', 'like', "%{$search}%")); + }); + }); + }); + + $this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']); + + return $query + ->paginate(10) + ->withQueryString() + ->through(function (Purchase $purchase) { + $purchase->setAttribute( + 'photos', + MediaPresenter::first($purchase, 'photos'), + ); + + return $purchase; + }); + } + + /** + * @return list + */ + public function supplierOptions(): array + { + return Supplier::query() + ->orderBy('name') + ->get(['id', 'name']) + ->map(fn (Supplier $supplier) => [ + 'value' => $supplier->id, + 'label' => $supplier->name, + ]) + ->all(); + } + + /** + * @return Collection + */ + public function catalogItems(?Purchase $purchase = null): Collection + { + $purchasePriceIds = $purchase + ? $purchase->items()->pluck('raw_material_price_id')->all() + : []; + + return RawMaterial::query() + ->with([ + 'prices' => fn ($query) => $query + ->orderBy('created_at') + ->with(['rawMaterial:id,unit', 'media']), + ]) + ->where(function (Builder $query) use ($purchasePriceIds): void { + $query->active(); + + if ($purchasePriceIds !== []) { + $query->orWhereHas( + 'prices', + fn (Builder $query) => $query->whereIn('id', $purchasePriceIds), + ); + } + }) + ->orderBy('name') + ->get() + ->each(function (RawMaterial $rawMaterial): void { + $rawMaterial->prices->each(function (RawMaterialPrice $price): void { + $price->setAttribute( + 'images', + MediaPresenter::collection($price, 'images'), + ); + }); + }); + } + + public function findForEdit(Purchase $purchase): Purchase + { + $purchase->load([ + 'items.rawMaterialPrice.rawMaterial:id,name,unit', + 'media', + ]); + + $purchase->setAttribute( + 'photos', + MediaPresenter::first($purchase, 'photos'), + ); + + $purchase->items->each(function (PurchaseItem $item): void { + $price = $item->rawMaterialPrice; + + if ($price) { + $price->setAttribute( + 'images', + MediaPresenter::collection($price, 'images'), + ); + } + }); + + return $purchase; + } + + /** + * @param array $validated + */ + public function create(array $validated, User $user): Purchase + { + return DB::transaction(function () use ($validated, $user): Purchase { + $lineItems = $this->buildLineItems($validated['items']); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $total = max($subtotal - $discount, 0); + + $purchase = Purchase::create([ + 'supplier_id' => $validated['supplier_id'], + 'created_by_id' => $user->id, + 'subtotal' => $subtotal, + 'discount' => $discount, + 'total' => $total, + 'notes' => $validated['notes'] ?? null, + ]); + + foreach ($lineItems as $itemData) { + $purchaseItem = $purchase->items()->create($itemData); + $this->incrementStock($purchaseItem); + } + + $this->syncPhotos($purchase, $validated); + + return $purchase; + }); + } + + /** + * @param array $validated + */ + public function update(Purchase $purchase, array $validated): void + { + DB::transaction(function () use ($purchase, $validated): void { + $purchase->load('items'); + + foreach ($purchase->items as $item) { + $this->decrementStock($item); + } + + $purchase->items()->delete(); + + $lineItems = $this->buildLineItems($validated['items']); + $subtotal = array_sum(array_column($lineItems, 'subtotal')); + $discount = (int) ($validated['discount'] ?? 0); + $total = max($subtotal - $discount, 0); + + $purchase->supplier_id = $validated['supplier_id']; + $purchase->subtotal = $subtotal; + $purchase->discount = $discount; + $purchase->total = $total; + $purchase->notes = $validated['notes'] ?? null; + $purchase->save(); + + foreach ($lineItems as $itemData) { + $purchaseItem = $purchase->items()->create($itemData); + $this->incrementStock($purchaseItem); + } + + $this->syncPhotos($purchase, $validated); + }); + } + + public function delete(Purchase $purchase): void + { + DB::transaction(function () use ($purchase): void { + $purchase->load('items'); + + foreach ($purchase->items as $item) { + $this->decrementStock($item); + } + + $purchase->clearMediaCollection('photos'); + $purchase->items()->delete(); + $purchase->delete(); + }); + } + + /** + * @param list $items + * @return list + */ + private function buildLineItems(array $items): array + { + return collect($items) + ->map(function (array $itemData, int $index) { + $price = RawMaterialPrice::query()->find($itemData['raw_material_price_id']); + + if ($price === null) { + throw ValidationException::withMessages([ + "items.{$index}.raw_material_price_id" => 'Bahan baku tidak ditemukan.', + ]); + } + + $quantity = (float) $itemData['quantity']; + $unitPrice = (int) $price->price; + $subtotal = (int) round($quantity * $unitPrice); + + return [ + 'raw_material_price_id' => $price->id, + 'quantity' => $quantity, + 'unit_price' => $unitPrice, + 'subtotal' => $subtotal, + ]; + }) + ->all(); + } + + /** + * @param array $validated + */ + private function syncPhotos(Purchase $purchase, array $validated): void + { + $this->mediaService->syncCollection( + $purchase, + 'photos', + $validated['photos'] ?? null, + $validated['remove_media_ids'] ?? null, + self::MAX_PHOTOS_FILES, + required: false, + errorKey: 'photos', + ); + } + + private function incrementStock(PurchaseItem $item): void + { + RawMaterialPrice::query() + ->whereKey($item->raw_material_price_id) + ->increment('stock', $item->quantity); + } + + private function decrementStock(PurchaseItem $item): void + { + RawMaterialPrice::query() + ->whereKey($item->raw_material_price_id) + ->decrement('stock', $item->quantity); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } +} diff --git a/database/migrations/2026_06_11_120001_create_purchases_table.php b/database/migrations/2026_06_11_120001_create_purchases_table.php new file mode 100644 index 0000000..23067cf --- /dev/null +++ b/database/migrations/2026_06_11_120001_create_purchases_table.php @@ -0,0 +1,32 @@ +id(); + + $table->foreignId('supplier_id')->constrained()->cascadeOnDelete(); + $table->foreignId('created_by_id')->constrained('users')->cascadeOnDelete(); + + $table->unsignedBigInteger('subtotal'); + $table->unsignedBigInteger('discount')->default(0); + $table->unsignedBigInteger('total'); + $table->string('notes', 100)->nullable(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchases'); + } +}; diff --git a/database/migrations/2026_06_11_120002_create_purchase_items_table.php b/database/migrations/2026_06_11_120002_create_purchase_items_table.php new file mode 100644 index 0000000..2c9b09e --- /dev/null +++ b/database/migrations/2026_06_11_120002_create_purchase_items_table.php @@ -0,0 +1,31 @@ +id(); + + $table->foreignId('purchase_id')->constrained()->cascadeOnDelete(); + $table->foreignId('raw_material_price_id')->constrained()->cascadeOnDelete(); + + $table->decimal('quantity', 18, 4); + $table->unsignedBigInteger('unit_price'); + $table->unsignedBigInteger('subtotal'); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('purchase_items'); + } +}; diff --git a/package-lock.json b/package-lock.json index 0aab0e5..1a94fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.1", - "reka-ui": "^2.9.9", + "reka-ui": "^2.9.10", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", @@ -8922,9 +8922,9 @@ } }, "node_modules/reka-ui": { - "version": "2.9.9", - "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.9.tgz", - "integrity": "sha512-/e+hdF9vP8E2kPrKR4RdgMQQsfpCr8l436Zn8GRWM3jKT9EG1lOO/UFMGBVEnrMLOVoJSjjmIFrej4tMOb+6qQ==", + "version": "2.9.10", + "resolved": "https://registry.npmjs.org/reka-ui/-/reka-ui-2.9.10.tgz", + "integrity": "sha512-yuvZVTp4fWH2G3qk+ze/x6YYlyc2Xl1d+eMUlIYrKqzTowBKteoDoN17fitURmqSUck3mc7JbcYgp49DnGu2EQ==", "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13", diff --git a/package.json b/package.json index 6566f43..45aadc4 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "clsx": "^2.1.1", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.1", - "reka-ui": "^2.9.9", + "reka-ui": "^2.9.10", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.1.1", "typescript": "^5.2.2", diff --git a/resources/css/app.css b/resources/css/app.css index 5d945ac..2fb2a57 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -52,6 +52,8 @@ @theme inline { --radius-2xl: calc(var(--radius) * 1.8); --radius-3xl: calc(var(--radius) * 2.2); --radius-4xl: calc(var(--radius) * 2.6); + --font-heading: + var(--font-sans); } :root { diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 39c59b1..2da818f 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@ + + diff --git a/resources/js/components/admin/manage/purchases/PurchasePosForm.vue b/resources/js/components/admin/manage/purchases/PurchasePosForm.vue new file mode 100644 index 0000000..58a2db1 --- /dev/null +++ b/resources/js/components/admin/manage/purchases/PurchasePosForm.vue @@ -0,0 +1,424 @@ + + +