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