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