From 9e3325c16ab7926b282ef4a2827699d4e47671d2 Mon Sep 17 00:00:00 2001 From: Yoga Pangestu Date: Wed, 10 Jun 2026 14:00:29 +0700 Subject: [PATCH] Add raw materials management features including permissions in the Permission and Role enums, routes for CRUD operations in RawMaterialController, and UI updates in the sidebar for raw materials navigation. Enhance the application structure to support raw materials alongside existing product and category management. --- app/Enums/Permission.php | 14 + app/Enums/RawMaterialUnit.php | 40 ++ app/Enums/Role.php | 15 +- .../Admin/RawMaterialController.php | 278 +++++++++++++ .../Requests/Admin/RawMaterialRequest.php | 43 ++ app/Models/RawMaterial.php | 42 ++ app/Models/RawMaterialPrice.php | 27 ++ ...6_10_110001_create_raw_materials_table.php | 28 ++ ...10002_create_raw_material_prices_table.php | 30 ++ resources/js/components/AppSidebar.vue | 15 +- .../raw-materials/RawMaterialForm.vue | 374 ++++++++++++++++++ .../raw-materials/RawMaterialGroupedTable.vue | 179 +++++++++ .../raw-materials/data-table-actions.vue | 79 ++++ .../raw-material-status-toggle.vue | 61 +++ resources/js/lib/raw-materials.ts | 30 ++ .../js/pages/admin/raw-materials/Create.vue | 41 ++ .../js/pages/admin/raw-materials/Edit.vue | 50 +++ .../js/pages/admin/raw-materials/Index.vue | 111 ++++++ resources/js/types/raw-material.ts | 77 ++++ routes/web.php | 31 ++ 20 files changed, 1558 insertions(+), 7 deletions(-) create mode 100644 app/Enums/RawMaterialUnit.php create mode 100644 app/Http/Controllers/Admin/RawMaterialController.php create mode 100644 app/Http/Requests/Admin/RawMaterialRequest.php create mode 100644 app/Models/RawMaterial.php create mode 100644 app/Models/RawMaterialPrice.php create mode 100644 database/migrations/2026_06_10_110001_create_raw_materials_table.php create mode 100644 database/migrations/2026_06_10_110002_create_raw_material_prices_table.php create mode 100644 resources/js/components/raw-materials/RawMaterialForm.vue create mode 100644 resources/js/components/raw-materials/RawMaterialGroupedTable.vue create mode 100644 resources/js/components/raw-materials/data-table-actions.vue create mode 100644 resources/js/components/raw-materials/raw-material-status-toggle.vue create mode 100644 resources/js/lib/raw-materials.ts create mode 100644 resources/js/pages/admin/raw-materials/Create.vue create mode 100644 resources/js/pages/admin/raw-materials/Edit.vue create mode 100644 resources/js/pages/admin/raw-materials/Index.vue create mode 100644 resources/js/types/raw-material.ts diff --git a/app/Enums/Permission.php b/app/Enums/Permission.php index c97e998..798c144 100644 --- a/app/Enums/Permission.php +++ b/app/Enums/Permission.php @@ -28,6 +28,12 @@ enum Permission: string case PRODUCTS_DELETE = 'products.delete'; case PRODUCTS_TOGGLE_STATUS = 'products.toggle-status'; + case RAW_MATERIALS_VIEW = 'raw-materials.view'; + case RAW_MATERIALS_CREATE = 'raw-materials.create'; + case RAW_MATERIALS_UPDATE = 'raw-materials.update'; + case RAW_MATERIALS_DELETE = 'raw-materials.delete'; + case RAW_MATERIALS_TOGGLE_STATUS = 'raw-materials.toggle-status'; + public function label(): string { return match ($this) { @@ -50,6 +56,12 @@ public function label(): string self::PRODUCTS_UPDATE => 'Ubah Produk', self::PRODUCTS_DELETE => 'Hapus Produk', self::PRODUCTS_TOGGLE_STATUS => 'Ubah Status Produk', + + self::RAW_MATERIALS_VIEW => 'Lihat Bahan Baku', + self::RAW_MATERIALS_CREATE => 'Tambah Bahan Baku', + self::RAW_MATERIALS_UPDATE => 'Ubah Bahan Baku', + self::RAW_MATERIALS_DELETE => 'Hapus Bahan Baku', + self::RAW_MATERIALS_TOGGLE_STATUS => 'Ubah Status Bahan Baku', }; } @@ -63,6 +75,8 @@ public function group(): string self::CATEGORIES_DELETE => 'Kategori', self::PRODUCTS_VIEW, self::PRODUCTS_CREATE, self::PRODUCTS_UPDATE, 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', }; } diff --git a/app/Enums/RawMaterialUnit.php b/app/Enums/RawMaterialUnit.php new file mode 100644 index 0000000..2d3596a --- /dev/null +++ b/app/Enums/RawMaterialUnit.php @@ -0,0 +1,40 @@ + 'Yard', + self::METER => 'Meter', + self::KILOGRAM => 'Kilogram', + }; + } + + public function abbreviation(): string + { + return match ($this) { + self::YARD => 'yard', + self::METER => 'm', + self::KILOGRAM => 'kg', + }; + } + + /** + * @return list + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php index caa9a97..ef3b76e 100644 --- a/app/Enums/Role.php +++ b/app/Enums/Role.php @@ -53,6 +53,11 @@ public function permissions(): array Permission::PRODUCTS_UPDATE, Permission::PRODUCTS_DELETE, Permission::PRODUCTS_TOGGLE_STATUS, + Permission::RAW_MATERIALS_VIEW, + Permission::RAW_MATERIALS_CREATE, + Permission::RAW_MATERIALS_UPDATE, + Permission::RAW_MATERIALS_DELETE, + Permission::RAW_MATERIALS_TOGGLE_STATUS, ], self::ADMIN_TOKO => [ Permission::DASHBOARD_VIEW, @@ -78,11 +83,11 @@ public function permissions(): array Permission::CATEGORIES_CREATE, Permission::CATEGORIES_UPDATE, Permission::CATEGORIES_DELETE, - Permission::PRODUCTS_VIEW, - Permission::PRODUCTS_CREATE, - Permission::PRODUCTS_UPDATE, - Permission::PRODUCTS_DELETE, - Permission::PRODUCTS_TOGGLE_STATUS, + Permission::RAW_MATERIALS_VIEW, + Permission::RAW_MATERIALS_CREATE, + Permission::RAW_MATERIALS_UPDATE, + Permission::RAW_MATERIALS_DELETE, + Permission::RAW_MATERIALS_TOGGLE_STATUS, ], self::MARKETING => [ Permission::DASHBOARD_VIEW, diff --git a/app/Http/Controllers/Admin/RawMaterialController.php b/app/Http/Controllers/Admin/RawMaterialController.php new file mode 100644 index 0000000..80fc710 --- /dev/null +++ b/app/Http/Controllers/Admin/RawMaterialController.php @@ -0,0 +1,278 @@ +parseDataTableQuery($request); + $search = $tableQuery['search']; + $sort = $tableQuery['sort']; + $direction = $tableQuery['direction']; + $isActive = $request->string('is_active')->toString(); + + $query = RawMaterial::query() + ->with([ + 'prices' => fn ($query) => $query->orderBy('created_at'), + ]) + ->when($search !== '', function (Builder $query) use ($search): void { + $query->where(function (Builder $query) use ($search): void { + $query->where('name', 'like', "%{$search}%") + ->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%")); + }); + }) + ->when( + $isActive !== '', + fn (Builder $query) => $query->where('is_active', $isActive === '1') + ); + + $this->applySorting($query, $sort, $direction); + + $rawMaterials = $query + ->paginate(10) + ->withQueryString(); + + $rows = $rawMaterials->getCollection() + ->flatMap(fn (RawMaterial $rawMaterial, int $index) => $this->flattenRawMaterialForTable( + $rawMaterial, + $index, + $rawMaterials->firstItem() ?? 1, + )) + ->values() + ->all(); + + return Inertia::render('admin/raw-materials/Index', [ + 'rows' => $rows, + 'pagination' => [ + 'current_page' => $rawMaterials->currentPage(), + 'last_page' => $rawMaterials->lastPage(), + 'per_page' => $rawMaterials->perPage(), + 'total' => $rawMaterials->total(), + 'links' => $rawMaterials->linkCollection()->toArray(), + ], + 'filters' => $this->dataTableFilters($tableQuery, [ + 'is_active' => $isActive, + ]), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/raw-materials/Create', [ + 'units' => RawMaterialUnit::selectOptions(), + ]); + } + + public function store(RawMaterialRequest $request): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated): void { + $rawMaterial = RawMaterial::create([ + 'name' => $validated['name'], + 'unit' => $validated['unit'], + ]); + + foreach ($validated['prices'] as $priceData) { + $this->createPrice($rawMaterial, $priceData); + } + }); + + Inertia::flash('success', 'Bahan baku berhasil ditambahkan.'); + + return redirect()->route('admin.master.raw-materials.index'); + } + + public function edit(RawMaterial $rawMaterial): Response + { + $rawMaterial->load([ + 'prices' => fn ($query) => $query->orderBy('created_at'), + ]); + + return Inertia::render('admin/raw-materials/Edit', [ + 'units' => RawMaterialUnit::selectOptions(), + 'rawMaterial' => $this->transformRawMaterialForForm($rawMaterial), + ]); + } + + public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse + { + $validated = $request->validated(); + + DB::transaction(function () use ($validated, $rawMaterial): void { + $rawMaterial->name = $validated['name']; + $rawMaterial->unit = $validated['unit']; + $rawMaterial->save(); + + $submittedPriceIds = collect($validated['prices']) + ->pluck('id') + ->filter() + ->map(fn ($id) => (int) $id) + ->all(); + + $rawMaterial->prices() + ->whereNotIn('id', $submittedPriceIds) + ->get() + ->each(fn (RawMaterialPrice $price) => $price->delete()); + + foreach ($validated['prices'] as $priceData) { + if (! empty($priceData['id'])) { + $price = $rawMaterial->prices()->findOrFail($priceData['id']); + $price->variant = $priceData['variant']; + $price->price = $priceData['price']; + $price->stock = $priceData['stock']; + $price->save(); + + continue; + } + + $this->createPrice($rawMaterial, $priceData); + } + }); + + Inertia::flash('success', 'Bahan baku berhasil diperbarui.'); + + return redirect()->route('admin.master.raw-materials.index'); + } + + public function toggleStatus(Request $request, RawMaterial $rawMaterial): RedirectResponse + { + $validated = $request->validate([ + 'is_active' => ['required', 'boolean'], + ]); + + $rawMaterial->is_active = $validated['is_active']; + $rawMaterial->save(); + + Inertia::flash('success', 'Status bahan baku berhasil diperbarui.'); + + return back(); + } + + public function destroy(RawMaterial $rawMaterial): RedirectResponse + { + DB::transaction(function () use ($rawMaterial): void { + $rawMaterial->prices()->delete(); + $rawMaterial->delete(); + }); + + Inertia::flash('success', 'Bahan baku berhasil dihapus.'); + + return redirect()->route('admin.master.raw-materials.index'); + } + + private function applySorting(Builder $query, string $sort, string $direction): void + { + if (in_array($sort, ['name', 'unit', 'is_active'], true)) { + $query->orderBy($sort, $direction); + + return; + } + + $query->latest(); + } + + /** + * @param array $priceData + */ + private function createPrice(RawMaterial $rawMaterial, array $priceData): RawMaterialPrice + { + return $rawMaterial->prices()->create([ + 'variant' => $priceData['variant'], + 'price' => $priceData['price'], + 'stock' => $priceData['stock'], + ]); + } + + /** + * @return list> + */ + private function flattenRawMaterialForTable(RawMaterial $rawMaterial, int $materialIndex, int $firstItem): array + { + $prices = $rawMaterial->prices->isNotEmpty() + ? $rawMaterial->prices + : collect([null]); + + $variantCount = $prices->count(); + + return $prices + ->values() + ->map(function ($price, int $variantIndex) use ($rawMaterial, $materialIndex, $firstItem, $variantCount) { + return [ + 'row_id' => $price + ? "{$rawMaterial->id}-{$price->id}" + : "{$rawMaterial->id}-empty", + 'raw_material_id' => $rawMaterial->id, + 'raw_material_name' => $rawMaterial->name, + 'unit' => $rawMaterial->unit->value, + 'unit_label' => $rawMaterial->unit->label(), + 'unit_abbreviation' => $rawMaterial->unit->abbreviation(), + 'is_active' => $rawMaterial->is_active, + 'is_first_variant' => $variantIndex === 0, + 'variant_count' => $variantCount, + 'raw_material_row_number' => $firstItem + $materialIndex, + 'price_id' => $price?->id, + 'variant' => $price?->variant, + 'stock' => $price?->stock, + 'stock_formatted' => $price ? $this->formatStock($price->stock, $rawMaterial->unit) : null, + 'price' => $price?->price, + 'price_formatted' => $price ? $this->formatPrice($price->price) : null, + ]; + }) + ->all(); + } + + /** + * @return array + */ + private function transformRawMaterialForForm(RawMaterial $rawMaterial): array + { + return [ + 'id' => $rawMaterial->id, + 'name' => $rawMaterial->name, + 'unit' => $rawMaterial->unit->value, + 'prices' => $rawMaterial->prices + ->map(fn (RawMaterialPrice $price) => [ + 'id' => $price->id, + 'variant' => $price->variant, + 'price' => (string) (int) $price->price, + 'stock' => $this->formatStockInput($price->stock), + ]) + ->values() + ->all(), + ]; + } + + private function formatPrice(float|string $price): string + { + return 'Rp '.number_format((float) $price, 0, ',', '.'); + } + + private function formatStock(float|string $stock, RawMaterialUnit $unit): string + { + $formatted = rtrim(rtrim(number_format((float) $stock, 4, ',', '.'), '0'), ','); + + return "{$formatted} {$unit->abbreviation()}"; + } + + private function formatStockInput(float|string $stock): string + { + return rtrim(rtrim(number_format((float) $stock, 4, '.', ''), '0'), '.'); + } +} diff --git a/app/Http/Requests/Admin/RawMaterialRequest.php b/app/Http/Requests/Admin/RawMaterialRequest.php new file mode 100644 index 0000000..50de1d6 --- /dev/null +++ b/app/Http/Requests/Admin/RawMaterialRequest.php @@ -0,0 +1,43 @@ +isMethod('POST') + ? Permission::RAW_MATERIALS_CREATE + : Permission::RAW_MATERIALS_UPDATE; + + return $this->user()?->can($permission->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'unit' => ['required', Rule::enum(RawMaterialUnit::class)], + 'prices' => ['required', 'array', 'min:1'], + 'prices.*.id' => [ + 'nullable', + 'integer', + Rule::exists('raw_material_prices', 'id') + ->where('raw_material_id', $this->route('rawMaterial')?->id) + ->whereNull('deleted_at'), + ], + 'prices.*.variant' => ['required', 'string', 'max:200'], + 'prices.*.price' => ['required', 'numeric', 'gt:0'], + 'prices.*.stock' => ['required', 'numeric', 'min:0'], + ]; + } +} diff --git a/app/Models/RawMaterial.php b/app/Models/RawMaterial.php new file mode 100644 index 0000000..a1013dc --- /dev/null +++ b/app/Models/RawMaterial.php @@ -0,0 +1,42 @@ + 'boolean', + 'unit' => RawMaterialUnit::class, + ]; + } + + #[Scope] + public function active(Builder $query): void + { + $query->where('is_active', true); + } + + #[Scope] + public function inactive(Builder $query): void + { + $query->where('is_active', false); + } + + public function prices(): HasMany + { + return $this->hasMany(RawMaterialPrice::class); + } +} diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php new file mode 100644 index 0000000..0a8c822 --- /dev/null +++ b/app/Models/RawMaterialPrice.php @@ -0,0 +1,27 @@ + 'decimal:2', + 'stock' => 'decimal:4', + ]; + } + + public function rawMaterial(): BelongsTo + { + return $this->belongsTo(RawMaterial::class); + } +} diff --git a/database/migrations/2026_06_10_110001_create_raw_materials_table.php b/database/migrations/2026_06_10_110001_create_raw_materials_table.php new file mode 100644 index 0000000..86dd2f3 --- /dev/null +++ b/database/migrations/2026_06_10_110001_create_raw_materials_table.php @@ -0,0 +1,28 @@ +id(); + + $table->string('name', 200); + $table->string('unit', 20); + $table->boolean('is_active')->default(true); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('raw_materials'); + } +}; diff --git a/database/migrations/2026_06_10_110002_create_raw_material_prices_table.php b/database/migrations/2026_06_10_110002_create_raw_material_prices_table.php new file mode 100644 index 0000000..e942ef8 --- /dev/null +++ b/database/migrations/2026_06_10_110002_create_raw_material_prices_table.php @@ -0,0 +1,30 @@ +id(); + + $table->foreignId('raw_material_id')->constrained()->cascadeOnDelete(); + + $table->string('variant', 200); + $table->decimal('price', 18, 2); + $table->decimal('stock', 18, 4)->default(0); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('raw_material_prices'); + } +}; diff --git a/resources/js/components/AppSidebar.vue b/resources/js/components/AppSidebar.vue index 6b4e060..cfa7af2 100644 --- a/resources/js/components/AppSidebar.vue +++ b/resources/js/components/AppSidebar.vue @@ -1,6 +1,6 @@