diff --git a/app/Enums/RawMaterialUnit.php b/app/Enums/RawMaterialUnit.php index ce85935..effc0ba 100644 --- a/app/Enums/RawMaterialUnit.php +++ b/app/Enums/RawMaterialUnit.php @@ -10,8 +10,5 @@ enum RawMaterialUnit: string case KG = 'kg'; case METER = 'meter'; - case PCS = 'pcs'; - case ROLL = 'roll'; - case LITER = 'liter'; - case PACK = 'pack'; + case YARD = 'yard'; } diff --git a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php new file mode 100644 index 0000000..52135f8 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialController.php @@ -0,0 +1,82 @@ + $this->service->paginated( + ...$request->validatedWithDefaults(), + filters: $request->only(['is_active', 'stock']), + ), + 'filters' => $request->only(['is_active', 'stock']), + ]); + } + + public function create(): Response + { + return Inertia::render('admin/master/raw-material/create'); + } + + public function store(RawMaterialRequest $request): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->create($request->validated()), + 'Bahan baku berhasil ditambahkan.', + 'admin.master.raw-materials.index', + 'admin.master.raw-materials.create' + ); + } + + public function edit(RawMaterial $rawMaterial): Response + { + return Inertia::render('admin/master/raw-material/edit', [ + 'rawMaterial' => $this->service->getForEdit($rawMaterial), + ]); + } + + public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->update($rawMaterial, $request->validated()), + 'Bahan baku berhasil diperbarui.', + 'admin.master.raw-materials.index', + 'admin.master.raw-materials.edit', + ['rawMaterial' => $rawMaterial] + ); + } + + public function destroy(RawMaterial $rawMaterial): RedirectResponse + { + return $this->handleAction( + fn () => $this->service->delete($rawMaterial), + 'Bahan baku berhasil dihapus.', + 'admin.master.raw-materials.index' + ); + } + + public function toggleStatus(RawMaterial $rawMaterial): RedirectResponse + { + $this->service->toggleStatus($rawMaterial); + $status = $rawMaterial->fresh()->is_active ? 'Aktif' : 'Non Aktif'; + + Inertia::flash('toast', ['type' => 'success', 'message' => "Status bahan baku berhasil diubah menjadi {$status}."]); + + return to_route('admin.master.raw-materials.index'); + } +} diff --git a/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialVariantController.php b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialVariantController.php new file mode 100644 index 0000000..64920a2 --- /dev/null +++ b/app/Http/Controllers/Admin/Master/RawMaterial/RawMaterialVariantController.php @@ -0,0 +1,46 @@ + $this->variantService->getForEdit($variant), + ]); + } + + public function update(RawMaterialVariantRequest $request, RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse + { + return $this->handleAction( + fn () => $this->variantService->update($variant, $request->validated()), + 'Varian berhasil diperbarui.', + 'admin.master.raw-materials.index', + 'admin.master.raw-materials.variants.edit', + ['rawMaterial' => $rawMaterial, 'variant' => $variant] + ); + } + + public function destroy(RawMaterial $rawMaterial, RawMaterialPrice $variant): RedirectResponse + { + return $this->handleAction( + fn () => $this->variantService->delete($rawMaterial, $variant), + 'Varian berhasil dihapus.', + 'admin.master.raw-materials.index' + ); + } +} diff --git a/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialRequest.php b/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialRequest.php new file mode 100644 index 0000000..b9059b1 --- /dev/null +++ b/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialRequest.php @@ -0,0 +1,56 @@ +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'])); + } + } + } + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:200'], + 'unit' => ['required', Rule::in(RawMaterialUnit::values())], + 'is_active' => ['nullable', 'boolean'], + 'variants' => ['required', 'array', 'min:1'], + 'variants.*.id' => ['nullable', 'integer'], + 'variants.*.variant' => ['required', 'string', 'max:200'], + 'variants.*.price' => ['required', 'integer', 'min:0'], + 'variants.*.stock' => ['required', 'numeric', 'min:0'], + 'variants.*.photo_key' => ['required', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'Nama Bahan Baku', + 'unit' => 'Satuan', + 'is_active' => 'Status', + 'variants' => 'Varian', + 'variants.*.variant' => 'Nama Varian', + 'variants.*.price' => 'Harga', + 'variants.*.stock' => 'Stok', + 'variants.*.photo_key' => 'Foto Varian', + ]; + } +} diff --git a/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialVariantRequest.php b/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialVariantRequest.php new file mode 100644 index 0000000..16a872d --- /dev/null +++ b/app/Http/Requests/Admin/Master/RawMaterial/RawMaterialVariantRequest.php @@ -0,0 +1,41 @@ +price; + if (is_string($price)) { + $this->request->set('price', (int) str_replace('.', '', $price)); + } + } + + public function rules(): array + { + return [ + 'variant' => ['required', 'string', 'max:200'], + 'price' => ['required', 'integer', 'min:0'], + 'stock' => ['required', 'numeric', 'min:0'], + 'photo_key' => ['required', 'string', 'max:500'], + ]; + } + + public function attributes(): array + { + return [ + 'variant' => 'Nama Varian', + 'price' => 'Harga', + 'stock' => 'Stok', + 'photo_key' => 'Foto', + ]; + } +} diff --git a/app/Models/RawMaterialPrice.php b/app/Models/RawMaterialPrice.php index 4bef2e7..fbc36b3 100644 --- a/app/Models/RawMaterialPrice.php +++ b/app/Models/RawMaterialPrice.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 RawMaterialPrice extends Model +class RawMaterialPrice extends Model implements HasMedia { - use HasFactory, SoftDeletes; + use HasFactory, InteractsWithMedia, SoftDeletes; protected function casts(): array { diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php new file mode 100644 index 0000000..a875775 --- /dev/null +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialService.php @@ -0,0 +1,251 @@ +with([ + 'rawMaterialPrices:id,raw_material_id,variant,price,stock', + 'rawMaterialPrices.media', + ]) + ->when($filters['is_active'] ?? null, fn ($q, $isActive) => $q->where('is_active', $isActive === 'true')) + ->when($filters['name'] ?? null, fn ($q, $name) => $q->where('name', 'like', "%{$name}%")) + ->latest() + ->get() + ->each(function ($rawMaterial) { + $rawMaterial->rawMaterialPrices->each(function ($price) { + $media = $price->getMedia('photos'); + $price->photo_url = $media->first() + ? $this->s3Service->getTemporaryUrl($media->first()->file_name) + : null; + }); + }); + } + + public function paginated(int $perPage = 25, string $search = '', string $sort = 'created_at', string $direction = 'desc', array $filters = []): LengthAwarePaginator + { + $paginator = RawMaterial::query() + ->select('id', 'name', 'unit', 'is_active') + ->with([ + 'rawMaterialPrices:id,raw_material_id,variant,price,stock', + ]) + ->when($search, fn ($q) => $q->where('name', 'like', "%{$search}%")) + ->when(($filters['is_active'] ?? null) !== null && ($filters['is_active'] ?? null) !== '', function ($q) use ($filters) { + $q->where('is_active', $filters['is_active'] === 'true'); + }) + ->when(($filters['stock'] ?? null) === 'empty', function ($q) { + $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) = 0'); + }) + ->when(($filters['stock'] ?? null) === 'low', function ($q) { + $q->whereRaw('(SELECT IFNULL(SUM(stock), 0) FROM raw_material_prices WHERE raw_material_prices.raw_material_id = raw_materials.id AND raw_material_prices.deleted_at IS NULL) BETWEEN 0.0001 AND 9.9999'); + }) + ->orderBy($sort, $direction) + ->paginate($perPage); + + $paginator->getCollection()->each(function ($rawMaterial) { + $rawMaterial->rawMaterialPrices->each(function ($price) { + $media = $price->getMedia('photos'); + $price->photo_url = $media->first() + ? $this->s3Service->getTemporaryUrl($media->first()->file_name) + : null; + }); + }); + + return $paginator; + } + + public function create(array $data): RawMaterial + { + return DB::transaction(function () use ($data) { + $rawMaterial = RawMaterial::create([ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'is_active' => $data['is_active'] ?? true, + ]); + + $now = now(); + $priceRows = collect($data['variants'])->map(fn ($v) => [ + '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->registerPhoto($priceModel, $variantData['photo_key']); + } + } + + return $rawMaterial; + }); + } + + public function getForEdit(RawMaterial $rawMaterial): array + { + $rawMaterial->load([ + 'rawMaterialPrices.media', + ]); + + $variants = $rawMaterial->rawMaterialPrices->map(function (RawMaterialPrice $price) { + $media = $price->getMedia('photos'); + $photoKey = $media->first()?->file_name; + $photoUrl = $media->first() + ? $this->s3Service->getTemporaryUrl($media->first()->file_name) + : null; + + return [ + 'id' => $price->id, + 'variant' => $price->variant, + 'price' => $price->price, + 'stock' => $price->stock, + 'photo_key' => $photoKey, + 'photo_url' => $photoUrl, + ]; + }); + + return [ + 'id' => $rawMaterial->id, + 'name' => $rawMaterial->name, + 'unit' => $rawMaterial->unit->value, + 'is_active' => $rawMaterial->is_active, + 'raw_material_prices' => $variants, + ]; + } + + public function update(RawMaterial $rawMaterial, array $data): RawMaterial + { + return DB::transaction(function () use ($rawMaterial, $data) { + $rawMaterial->update([ + 'name' => $data['name'], + 'unit' => $data['unit'], + 'is_active' => $data['is_active'] ?? $rawMaterial->is_active, + ]); + + $existingVariantIds = collect($data['variants']) + ->pluck('id') + ->filter() + ->toArray(); + + $rawMaterial->rawMaterialPrices() + ->whereNotIn('id', $existingVariantIds) + ->each(function (RawMaterialPrice $price) { + $price->clearMediaCollection('photos'); + $price->delete(); + }); + + $existingPricesMap = RawMaterialPrice::whereIn('id', $existingVariantIds) + ->with('media') + ->get() + ->mapWithKeys(fn ($p) => [$p->id => $p]); + + $now = now(); + $newVariantsData = collect($data['variants'])->filter(fn ($v) => ! isset($v['id'])); + $newVariantIdMap = []; + + if ($newVariantsData->isNotEmpty()) { + $newRows = $newVariantsData->map(fn ($v) => [ + '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($newRows); + + $newlyCreated = RawMaterialPrice::where('raw_material_id', $rawMaterial->id) + ->whereIn('variant', $newVariantsData->pluck('variant')->toArray()) + ->get(); + + $newVariantIdMap = $newlyCreated->mapWithKeys(fn ($p) => [$p->variant => $p->id])->toArray(); + } + + foreach ($data['variants'] as $variantData) { + $priceId = $variantData['id'] ?? $newVariantIdMap[$variantData['variant']] ?? null; + if (! $priceId) { + continue; + } + + $priceModel = $existingPricesMap[$priceId] ?? RawMaterialPrice::find($priceId); + if (! $priceModel) { + continue; + } + + $priceModel->update([ + 'variant' => $variantData['variant'], + 'price' => $variantData['price'], + 'stock' => $variantData['stock'], + ]); + + if (isset($variantData['photo_key'])) { + $existingKey = $priceModel->getMedia('photos')->first()?->file_name; + if ($existingKey !== $variantData['photo_key']) { + $priceModel->clearMediaCollection('photos'); + if ($variantData['photo_key']) { + $this->registerPhoto($priceModel, $variantData['photo_key']); + } + } + } + } + + return $rawMaterial; + }); + } + + public function delete(RawMaterial $rawMaterial): bool + { + return DB::transaction(function () use ($rawMaterial) { + $rawMaterial->rawMaterialPrices->each(function (RawMaterialPrice $price) { + $price->clearMediaCollection('photos'); + $price->delete(); + }); + + return $rawMaterial->delete(); + }); + } + + public function toggleStatus(RawMaterial $rawMaterial): void + { + $rawMaterial->update([ + 'is_active' => ! $rawMaterial->is_active, + ]); + } + + private function registerPhoto(RawMaterialPrice $price, string $s3Key): void + { + $this->registerMedia( + model: $price, + s3Key: $s3Key, + collectionName: 'photos', + orderColumn: 1, + ); + } +} diff --git a/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php new file mode 100644 index 0000000..6598ab2 --- /dev/null +++ b/app/Services/Admin/Master/RawMaterial/RawMaterialVariantService.php @@ -0,0 +1,100 @@ +load('media'); + + $media = $variant->getMedia('photos'); + $photoKey = $media->first()?->file_name; + $photoUrl = $media->first() + ? $this->s3Service->getTemporaryUrl($media->first()->file_name) + : null; + + return [ + 'id' => $variant->id, + 'raw_material_id' => $variant->raw_material_id, + 'variant' => $variant->variant, + 'price' => $variant->price, + 'stock' => $variant->stock, + 'photo_key' => $photoKey, + 'photo_url' => $photoUrl, + ]; + } + + public function update(RawMaterialPrice $variant, array $data): RawMaterialPrice + { + DB::transaction(function () use ($variant, $data) { + $variant->update([ + 'variant' => $data['variant'], + 'price' => $data['price'], + 'stock' => $data['stock'], + ]); + + if (array_key_exists('photo_key', $data)) { + $existingKey = $variant->getMedia('photos')->first()?->file_name; + $newKey = $data['photo_key']; + + if ($existingKey !== $newKey) { + $variant->clearMediaCollection('photos'); + if ($newKey) { + $this->registerPhoto($variant, $newKey); + } + } + } + }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Varian Diperbarui', + body: "Varian \"{$variant->variant}\" berhasil diperbarui".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.raw-materials.index'), + ); + + return $variant->fresh(); + } + + public function delete(RawMaterial $rawMaterial, RawMaterialPrice $variant): bool + { + $result = DB::transaction(function () use ($variant) { + $variant->clearMediaCollection('photos'); + + return $variant->delete(); + }); + + NotificationService::notify( + roles: ['Owner', 'Developer', 'Direktur', 'Admin Toko'], + title: 'Varian Dihapus', + body: "Varian \"{$variant->variant}\" dari bahan baku \"{$rawMaterial->name}\" berhasil dihapus".' oleh '.auth()->user()->full_name.'.', + url: route('admin.master.raw-materials.index'), + ); + + return $result; + } + + private function registerPhoto(RawMaterialPrice $variant, string $s3Key): void + { + $this->registerMedia( + model: $variant, + s3Key: $s3Key, + collectionName: 'photos', + orderColumn: 1, + ); + } +} diff --git a/database/factories/RawMaterialFactory.php b/database/factories/RawMaterialFactory.php index 853dfd5..f1468be 100644 --- a/database/factories/RawMaterialFactory.php +++ b/database/factories/RawMaterialFactory.php @@ -10,7 +10,7 @@ public function definition(): array { return [ 'name' => fake()->unique()->words(2, true), - 'unit' => fake()->randomElement(['kg', 'meter', 'pcs', 'roll', 'liter', 'pack']), + 'unit' => fake()->randomElement(['kg', 'meter', 'yard']), 'is_active' => true, ]; } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 6b97053..248aa89 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -23,6 +23,7 @@ import { index as leaveRequestsIndex } from '@/routes/admin/hr/leave-requests'; 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 suppliersIndex } from '@/routes/admin/master/suppliers'; import { index as rolesIndex } from '@/routes/admin/settings/roles'; import { Link, router } from '@inertiajs/react'; @@ -68,7 +69,7 @@ const analisaItem: NavMenuItem = { const masterItems: NavMenuItem[] = [ { title: 'Kategori', href: categoriesIndex.url(), icon: Tags }, { title: 'Produk', href: productsIndex.url(), icon: Package }, - { title: 'Bahan Baku', href: '#', icon: Boxes }, + { title: 'Bahan Baku', href: rawMaterialsIndex.url(), icon: Boxes }, { title: 'Supplier', href: suppliersIndex.url(), icon: Truck }, { title: 'Customer', href: customersIndex.url(), icon: Users }, ]; diff --git a/resources/js/hooks/use-raw-material-draft.ts b/resources/js/hooks/use-raw-material-draft.ts new file mode 100644 index 0000000..5d74d87 --- /dev/null +++ b/resources/js/hooks/use-raw-material-draft.ts @@ -0,0 +1,59 @@ +import { useEffect, useRef } from 'react'; +import { router } from '@inertiajs/react'; +import { + saveRawMaterialDraft, + clearRawMaterialDraft, + type RawMaterialDraftData, +} from '@/lib/raw-material-draft'; + +type DraftType = 'create' | 'edit'; + +export function useRawMaterialDraftSave( + type: DraftType, + data: RawMaterialDraftData, + userId?: number, + rawMaterialId?: number, + delay = 500, +) { + const timeoutRef = useRef | null>(null); + const dataRef = useRef(data); + dataRef.current = data; + const submittedRef = useRef(false); + + useEffect(() => { + return router.on('before', () => { + submittedRef.current = true; + }); + }, []); + + useEffect(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + if (!submittedRef.current) { + saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId); + } + timeoutRef.current = null; + }, delay); + + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, [data, type, userId, rawMaterialId, delay]); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + if (submittedRef.current) { + clearRawMaterialDraft(type, userId, rawMaterialId); + } else { + saveRawMaterialDraft(type, dataRef.current, userId, rawMaterialId); + } + }; + }, [type, userId, rawMaterialId]); +} diff --git a/resources/js/lib/product-draft.ts b/resources/js/lib/product-draft.ts index 25cd62d..e32f590 100644 --- a/resources/js/lib/product-draft.ts +++ b/resources/js/lib/product-draft.ts @@ -37,6 +37,10 @@ export function saveProductDraft( userId?: number, productId?: number, ): boolean { + if (type !== 'create') { + return false; + } + try { const key = getKey(type, userId, productId); localStorage.setItem(key, JSON.stringify(data)); diff --git a/resources/js/lib/raw-material-draft.ts b/resources/js/lib/raw-material-draft.ts new file mode 100644 index 0000000..adbdf7e --- /dev/null +++ b/resources/js/lib/raw-material-draft.ts @@ -0,0 +1,78 @@ +const DRAFT_PREFIX = 'raw-material-draft'; + +export type RawMaterialDraftData = { + name: string; + unit: string; + isActive: boolean; + variants: Array<{ + id?: number | null; + variant: string; + price: number; + stock: number; + photo?: string; + }>; +}; + +function getKey( + type: 'create' | 'edit', + userId?: number, + rawMaterialId?: number, +): string { + if (type === 'edit' && rawMaterialId) { + return `${DRAFT_PREFIX}-edit-${userId ?? 'anon'}-${rawMaterialId}`; + } + + return `${DRAFT_PREFIX}-create-${userId ?? 'anon'}`; +} + +export function saveRawMaterialDraft( + type: 'create' | 'edit', + data: RawMaterialDraftData, + userId?: number, + rawMaterialId?: number, +): boolean { + if (type !== 'create') { + return false; + } + + try { + const key = getKey(type, userId, rawMaterialId); + localStorage.setItem(key, JSON.stringify(data)); + + return true; + } catch { + return false; + } +} + +export function loadRawMaterialDraft( + type: 'create' | 'edit', + userId?: number, + rawMaterialId?: number, +): RawMaterialDraftData | null { + try { + const key = getKey(type, userId, rawMaterialId); + const raw = localStorage.getItem(key); + + if (!raw) { + return null; + } + + return JSON.parse(raw) as RawMaterialDraftData; + } catch { + return null; + } +} + +export function clearRawMaterialDraft( + type: 'create' | 'edit', + userId?: number, + rawMaterialId?: number, +): void { + try { + const key = getKey(type, userId, rawMaterialId); + localStorage.removeItem(key); + } catch { + // ignore + } +} diff --git a/resources/js/pages/admin/master/product/edit.tsx b/resources/js/pages/admin/master/product/edit.tsx index 709a8e3..3b4087e 100644 --- a/resources/js/pages/admin/master/product/edit.tsx +++ b/resources/js/pages/admin/master/product/edit.tsx @@ -19,8 +19,7 @@ import { Label } from '@/components/ui/label'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Textarea } from '@/components/ui/textarea'; import { useProductDraftSave } from '@/hooks/use-product-draft'; -import { loadProductDraft } from '@/lib/product-draft'; -import { getTemporaryUrl } from '@/lib/upload'; +import { clearProductDraft } from '@/lib/product-draft'; import { index as productIndex, update } from '@/routes/admin/master/products'; type Category = { @@ -95,14 +94,12 @@ export default function ProductEdit({ product, categories }: Props) { const { auth } = usePage().props as { auth: { user?: { id?: number } } }; const userId = auth.user?.id; - const draft = loadProductDraft('edit', userId, product.id); + clearProductDraft('edit', userId, product.id); - const [productName, setProductName] = useState( - draft?.productName ?? product.name, - ); - const [status, setStatus] = useState(draft?.status ?? product.status); + const [productName, setProductName] = useState(product.name); + const [status, setStatus] = useState(product.status); const [description, setDescription] = useState( - draft?.description ?? product.description ?? '', + product.description ?? '', ); const serverVariants: VariantState[] = product.product_variants.map( @@ -122,56 +119,23 @@ export default function ProductEdit({ product, categories }: Props) { ); const [categoryIds, setCategoryIds] = useState( - draft?.categoryIds ?? product.category_ids, + product.category_ids, ); const [useSamePrice, setUseSamePrice] = useState( - draft?.useSamePrice ?? - (serverVariants.length > 1 - ? serverVariants.every((v) => - arePricesEqual(v.prices, serverVariants[0].prices), - ) - : true), + serverVariants.length > 1 + ? serverVariants.every((v) => + arePricesEqual(v.prices, serverVariants[0].prices), + ) + : true, ); const [sharedPrices, setSharedPrices] = useState< Array<{ type: string; price: number }> >( - draft?.sharedPrices ?? - (serverVariants.length > 0 - ? serverVariants[0].prices - : createEmptyPrices()), + serverVariants.length > 0 + ? serverVariants[0].prices + : createEmptyPrices(), ); const [variants, setVariants] = useState(() => { - if (draft?.variants && draft.variants.length > 0) { - const serverVariantMap = new Map( - serverVariants.map((sv) => [sv.id, sv]), - ); - - return draft.variants.map((v) => { - const serverMatch = - v.id != null ? serverVariantMap.get(v.id) : undefined; - - const photos = Array.isArray(v.photos) - ? v.photos.map((p, i) => ({ - key: p.key, - url: serverMatch?.photos[i]?.url ?? getTemporaryUrl(p.key), - })) - : typeof v.photo === 'string' && v.photo - ? [{ key: v.photo, url: serverMatch?.photos[0]?.url ?? getTemporaryUrl(v.photo) }] - : []; - - return { - id: v.id ?? null, - name: v.name, - stock: v.stock, - reject_stock: v.reject_stock, - retail_stock: v.retail_stock, - photos, - uploading: false, - prices: v.prices, - }; - }); - } - return serverVariants.length > 0 ? serverVariants : [ diff --git a/resources/js/pages/admin/master/raw-material/columns.tsx b/resources/js/pages/admin/master/raw-material/columns.tsx new file mode 100644 index 0000000..9f775ca --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/columns.tsx @@ -0,0 +1,38 @@ +export type RawMaterialVariant = { + id: number; + variant: string; + price: number; + stock: number; + photo_url: string | null; +}; + +export type RawMaterial = { + id: number; + name: string; + unit: string; + is_active: boolean; + raw_material_prices: RawMaterialVariant[]; +}; + +export type RawMaterialVariantForEdit = { + id: number; + variant: string; + price: number; + stock: number; + photo_key: string | null; + photo_url: string | null; +}; + +export type RawMaterialForEdit = { + id: number; + name: string; + unit: string; + is_active: boolean; + raw_material_prices: RawMaterialVariantForEdit[]; +}; + +export const UNIT_LABELS: Record = { + kg: 'Kilogram', + meter: 'Meter', + yard: 'Yard', +}; diff --git a/resources/js/pages/admin/master/raw-material/create.tsx b/resources/js/pages/admin/master/raw-material/create.tsx new file mode 100644 index 0000000..936756d --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/create.tsx @@ -0,0 +1,432 @@ +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; +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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft'; +import { loadRawMaterialDraft } from '@/lib/raw-material-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { index as rawMaterialIndex, store } from '@/routes/admin/master/raw-materials'; +import { Form, Head, usePage } from '@inertiajs/react'; +import { + ArrowLeft, + Check, + ClipboardPaste, + Copy, + Plus, + Trash2, +} from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; + +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; +}; + +export default function RawMaterialCreate() { + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + const draft = loadRawMaterialDraft('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 variantsRef = useRef(variants); + variantsRef.current = variants; + + const draftData = { + name, + unit, + isActive: true, + variants: variants.map((v) => ({ + variant: v.variant, + price: v.price, + stock: v.stock, + photo: v.photo ?? undefined, + })), + }; + + useRawMaterialDraftSave('create', draftData, userId); + + 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 }, + ); + }); + }, []); + + function getPayload() { + return { + name, + unit, + is_active: true, + variants: variantsRef.current.map((v) => ({ + variant: v.variant, + price: Number(v.price), + stock: Number(v.stock), + photo_key: v.photo, + })), + }; + } + + return ( + <> + + +
+
+

+ Tambah Bahan Baku +

+ +
+ +
({ + ...data, + ...getPayload(), + })} + > + {({ errors, processing }) => ( + <> +
+ + + 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) + } + /> + +
+
+ ))} + + +
+
+
+ +
+ +
+ + )} +
+ + { + if (!open) { + setDeleteConfirmOpen(false); + setDeleteVariantIndex(null); + } + }} + title="Hapus Varian" + description="Apakah Anda yakin ingin menghapus varian ini?" + confirmLabel="Hapus" + onConfirm={() => { + if (deleteVariantIndex !== null) { + removeVariant(deleteVariantIndex); + } + setDeleteConfirmOpen(false); + setDeleteVariantIndex(null); + }} + /> +
+ + ); +} diff --git a/resources/js/pages/admin/master/raw-material/edit.tsx b/resources/js/pages/admin/master/raw-material/edit.tsx new file mode 100644 index 0000000..9eeedcc --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/edit.tsx @@ -0,0 +1,450 @@ +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { FileUpload } from '@/components/file-upload'; +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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { useRawMaterialDraftSave } from '@/hooks/use-raw-material-draft'; +import { clearRawMaterialDraft } from '@/lib/raw-material-draft'; +import { getTemporaryUrl } from '@/lib/upload'; +import { index as rawMaterialIndex, update } from '@/routes/admin/master/raw-materials'; +import { Form, Head, usePage } from '@inertiajs/react'; +import { + ArrowLeft, + Check, + ClipboardPaste, + Copy, + Plus, + Trash2, +} from 'lucide-react'; +import { useCallback, useRef, useState } from 'react'; +import type { RawMaterialForEdit, RawMaterialVariantForEdit } from './columns'; + +const UNITS = [ + { value: 'kg', label: 'Kilogram' }, + { value: 'meter', label: 'Meter' }, + { value: 'yard', label: 'Yard' }, +]; + +type Props = { + rawMaterial: RawMaterialForEdit; +}; + +type VariantState = { + id: number | null; + variant: string; + price: number; + stock: number; + photo: string | null; + photoUrl: string | null; + uploading: boolean; +}; + +export default function RawMaterialEdit({ rawMaterial }: Props) { + const { auth } = usePage().props as { auth: { user?: { id?: number } } }; + const userId = auth.user?.id; + + clearRawMaterialDraft('edit', userId, rawMaterial.id); + + const [name, setName] = useState(rawMaterial.name); + const [unit, setUnit] = useState(rawMaterial.unit); + const [isActive, setIsActive] = useState(rawMaterial.is_active); + + const serverVariants: VariantState[] = rawMaterial.raw_material_prices.map( + (v: RawMaterialVariantForEdit) => ({ + id: v.id, + variant: v.variant, + price: v.price, + stock: v.stock, + photo: v.photo_key, + photoUrl: v.photo_url, + uploading: false, + }), + ); + + const [variants, setVariants] = useState(() => { + return serverVariants.length > 0 + ? serverVariants + : [ + { + id: null, + variant: '', + price: 0, + stock: 0, + photo: null, + photoUrl: null, + uploading: false, + }, + ]; + }); + + const variantsRef = useRef(variants); + variantsRef.current = variants; + + const draftData = { + name, + unit, + isActive, + variants: variants.map((v) => ({ + id: v.id, + variant: v.variant, + price: v.price, + stock: v.stock, + photo: v.photo ?? undefined, + })), + }; + + useRawMaterialDraftSave('edit', draftData, userId, rawMaterial.id); + + const addVariant = useCallback(() => { + setVariants((prev) => [ + ...prev, + { + id: null, + 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 }, + ); + }); + }, []); + + function getPayload() { + return { + name, + unit, + is_active: isActive, + variants: variantsRef.current.map((v) => ({ + id: v.id, + variant: v.variant, + price: Number(v.price), + stock: Number(v.stock), + photo_key: v.photo, + })), + }; + } + + return ( + <> + + +
+
+

+ Edit Bahan Baku +

+ +
+ +
({ + ...data, + ...getPayload(), + })} + > + {({ errors, processing }) => ( + <> +
+ + + 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) + } + /> + +
+
+ ))} + + +
+
+
+ +
+ +
+ + )} +
+ + { + if (!open) { + setDeleteConfirmOpen(false); + setDeleteVariantIndex(null); + } + }} + title="Hapus Varian" + description="Apakah Anda yakin ingin menghapus varian ini?" + confirmLabel="Hapus" + onConfirm={() => { + if (deleteVariantIndex !== null) { + removeVariant(deleteVariantIndex); + } + setDeleteConfirmOpen(false); + setDeleteVariantIndex(null); + }} + /> +
+ + ); +} diff --git a/resources/js/pages/admin/master/raw-material/index.tsx b/resources/js/pages/admin/master/raw-material/index.tsx new file mode 100644 index 0000000..f3d3e29 --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/index.tsx @@ -0,0 +1,318 @@ +import { Head, router } from '@inertiajs/react'; +import { Filter, Plus, X } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { CardTable } from '@/components/card-table'; +import { useCardTableExpand } from '@/components/hooks/use-card-table-expand'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { Button } from '@/components/ui/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + destroy, + create as rawMaterialCreate, + index as rawMaterialIndex, + edit as rawMaterialEdit, + toggleStatus, +} from '@/routes/admin/master/raw-materials'; +import { + destroy as variantDestroy, + edit as variantEdit, +} from '@/routes/admin/master/raw-materials/variants'; +import type { RawMaterial, RawMaterialVariant } from './columns'; +import { RawMaterialCardRow } from './raw-material-card'; +import { RawMaterialVariantSubRow } from './variant/sub-row'; + +type Props = { + rawMaterials: { + data: RawMaterial[]; + current_page: number; + last_page: number; + per_page: number; + total: number; + }; + filters: { + is_active?: string; + stock?: string; + }; +}; + +export default function RawMaterialIndex({ rawMaterials, filters }: Props) { + const [deleting, setDeleting] = useState(null); + const [deletingVariant, setDeletingVariant] = useState<{ + rawMaterial: RawMaterial; + variant: RawMaterialVariant; + } | null>(null); + const [filterOpen, setFilterOpen] = useState(false); + const [search, setSearch] = useState(''); + const expand = useCardTableExpand(true); + const hasActiveFilters = filters.is_active || filters.stock; + + const pagination = { + current_page: rawMaterials.current_page, + last_page: rawMaterials.last_page, + per_page: rawMaterials.per_page, + total: rawMaterials.total, + }; + + function applyFilter(key: string, value: string) { + const newFilters = { ...filters }; + + if (value === '' || value === 'all') { + delete newFilters[key as keyof typeof newFilters]; + } else { + newFilters[key as keyof typeof newFilters] = value; + } + + router.get(rawMaterialIndex(), newFilters, { + preserveState: true, + replace: true, + }); + } + + function clearFilters() { + router.get( + rawMaterialIndex(), + {}, + { + preserveState: true, + replace: true, + }, + ); + setFilterOpen(false); + } + + function handlePageChange(page: number) { + router.get( + rawMaterialIndex.url(), + { + page, + per_page: pagination.per_page, + search, + ...filters, + }, + { preserveState: true, replace: true }, + ); + } + + function handlePerPageChange(perPage: number) { + router.get( + rawMaterialIndex.url(), + { + page: 1, + per_page: perPage, + search, + ...filters, + }, + { preserveState: true, replace: true }, + ); + } + + const handleSearchChange = useCallback( + (value: string) => { + setSearch(value); + router.get( + rawMaterialIndex.url(), + { + page: 1, + per_page: pagination.per_page, + search: value, + ...filters, + }, + { preserveState: true, replace: true }, + ); + }, + [pagination.per_page, filters], + ); + + function handleDelete() { + if (!deleting) return; + + router.delete(destroy.url(deleting.id), { + onSuccess: () => setDeleting(null), + }); + } + + function handleDeleteVariant() { + if (!deletingVariant) return; + + router.delete( + variantDestroy.url({ + rawMaterial: deletingVariant.rawMaterial.id, + variant: deletingVariant.variant.id, + }), + { + onSuccess: () => setDeletingVariant(null), + }, + ); + } + + const filterToolbar = ( + + + + + +
+
+ Filter + {hasActiveFilters && ( + + )} +
+ +
+ + +
+ +
+ + +
+
+
+
+ ); + + return ( + <> + + +
+
+
+

+ Bahan Baku +

+
+ +
+ + rm.id} + expandedKeys={expand.expandedKeys} + onToggleExpand={expand.toggleExpand} + searchValue={search} + onSearchChange={handleSearchChange} + searchPlaceholder="Cari bahan baku..." + pagination={pagination} + onPageChange={handlePageChange} + onPerPageChange={handlePerPageChange} + toolbar={filterToolbar} + renderCard={({ + item, + index, + isExpanded, + onToggleExpand, + }) => ( + { + window.location.href = rawMaterialEdit.url(rm.id); + }} + onDelete={(rm) => setDeleting(rm)} + toggleStatusUrl={(id) => toggleStatus.url(id)} + /> + )} + renderSubContent={(rawMaterial) => ( + + )} + /> + + { + if (!open) { + setDeleting(null); + } + }} + title="Hapus Bahan Baku" + description={`Apakah Anda yakin ingin menghapus bahan baku "${deleting?.name}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDelete} + /> + + { + if (!open) { + setDeletingVariant(null); + } + }} + title="Hapus Varian" + description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant?.variant.variant}" dari bahan baku "${deletingVariant?.rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDeleteVariant} + /> +
+ + ); +} diff --git a/resources/js/pages/admin/master/raw-material/raw-material-card.tsx b/resources/js/pages/admin/master/raw-material/raw-material-card.tsx new file mode 100644 index 0000000..0e24f94 --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/raw-material-card.tsx @@ -0,0 +1,146 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { router } from '@inertiajs/react'; +import { ChevronDown, Pencil, Trash2 } from 'lucide-react'; +import type { RawMaterial } from './columns'; + +function formatNumber(num: number): string { + return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num); +} + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(amount); +} + +export type RawMaterialCardRowParams = { + rawMaterial: RawMaterial; + index: number; + isExpanded: boolean; + onToggleExpand: () => void; + onEdit: (rawMaterial: RawMaterial) => void; + onDelete: (rawMaterial: RawMaterial) => void; + toggleStatusUrl: (id: number) => string; +}; + +export function RawMaterialCardRow({ + rawMaterial, + index, + isExpanded, + onToggleExpand, + onEdit, + onDelete, + toggleStatusUrl, +}: RawMaterialCardRowParams) { + const variants = rawMaterial.raw_material_prices ?? []; + const totalStock = variants.reduce((sum, v) => sum + (Number(v.stock) || 0), 0); + const totalValue = variants.reduce((sum, v) => sum + (Number(v.price) || 0) * (Number(v.stock) || 0), 0); + + function handleToggle() { + router.post(toggleStatusUrl(rawMaterial.id), {}, { preserveScroll: true }); + } + + return ( + + +
+ + +
+
+ + {index}. + +

+ {rawMaterial.name} +

+
+ +
+ + {variants.length} varian + + + Total Stok:{' '} + + {formatNumber(totalStock)} + + + + Total Nilai:{' '} + + {formatCurrency(totalValue)} + + +
+ +
+
+ + + {rawMaterial.is_active ? 'Aktif' : 'Non Aktif'} + +
+
+
+ + +
+ + + + + Edit + + + + + + + Hapus + + +
+
+
+
+
+ ); +} diff --git a/resources/js/pages/admin/master/raw-material/variant/edit.tsx b/resources/js/pages/admin/master/raw-material/variant/edit.tsx new file mode 100644 index 0000000..2683eaa --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/variant/edit.tsx @@ -0,0 +1,149 @@ +import { Form, Head } from '@inertiajs/react'; +import { ArrowLeft } from 'lucide-react'; +import { useState } from 'react'; +import { FileUpload } from '@/components/file-upload'; +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 { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { getTemporaryUrl } from '@/lib/upload'; +import { index as rawMaterialIndex } from '@/routes/admin/master/raw-materials'; + +type Props = { + variant: { + id: number; + raw_material_id: number; + variant: string; + price: number; + stock: number; + photo_key: string | null; + photo_url: string | null; + }; +}; + +export default function RawMaterialVariantEdit({ variant }: Props) { + const [variantName, setVariantName] = useState(variant.variant); + const [price, setPrice] = useState(variant.price); + const [stock, setStock] = useState(Number(variant.stock) || 0); + const [photo, setPhoto] = useState(variant.photo_key); + const [photoUrl, setPhotoUrl] = useState(variant.photo_url); + const [uploading, setUploading] = useState(false); + + function getPayload() { + return { + variant: variantName, + price: Number(price), + stock: Number(stock), + photo_key: photo, + }; + } + + return ( + <> + + +
+
+

+ Edit Varian +

+ +
+ +
getPayload()} + > + {({ errors, processing }) => ( + <> +
+ + + Informasi Varian + + +
+ + setVariantName(e.target.value)} + placeholder="Contoh: Ukuran L, Warna Merah" + /> + +
+
+ + + +
+
+ + setStock(Number(e.target.value))} + /> + +
+
+
+ + + + Foto Varian * + + + { + setPhoto(key); + setPhotoUrl(key ? getTemporaryUrl(key) : null); + }} + folder="raw-material-variant" + existingUrl={photoUrl} + onUploadingChange={setUploading} + /> + + + +
+ +
+ +
+ + )} +
+
+ + ); +} diff --git a/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx new file mode 100644 index 0000000..59b1697 --- /dev/null +++ b/resources/js/pages/admin/master/raw-material/variant/sub-row.tsx @@ -0,0 +1,201 @@ +import { router } from '@inertiajs/react'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useState } from 'react'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { ImagePreviewModal } from '@/components/image-preview-modal'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import type { RawMaterial, RawMaterialVariant } from '../columns'; +import { edit as variantEdit, destroy as variantDestroy } from '@/routes/admin/master/raw-materials/variants'; + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + minimumFractionDigits: 0, + }).format(amount); +} + +function formatNumber(num: number): string { + return new Intl.NumberFormat('id-ID', { maximumFractionDigits: 4 }).format(num); +} + +function VariantPhotoPreview({ url, title }: { url: string; title: string }) { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} + +export function RawMaterialVariantSubRow({ + rawMaterial, +}: { + rawMaterial: RawMaterial; +}) { + const variants = rawMaterial.raw_material_prices ?? []; + const [deletingVariant, setDeletingVariant] = useState(null); + + function handleDeleteVariant() { + if (!deletingVariant) return; + + router.delete( + variantDestroy.url({ + rawMaterial: rawMaterial.id, + variant: deletingVariant.id, + }), + { + onSuccess: () => setDeletingVariant(null), + }, + ); + } + + return ( +
+ + + + + No + + Foto + Nama Varian + Harga + Stok + + Aksi + + + + + {variants.length === 0 ? ( + + + Tidak ada varian. + + + ) : ( + variants.map((variant, index) => ( + + + {index + 1} + + + {variant.photo_url ? ( + + ) : ( +
+ N/A +
+ )} +
+ + {variant.variant} + + + {formatCurrency(variant.price)} + + + {formatNumber(variant.stock)} + + + +
+ + + + + + Edit + + + + + + + + Hapus + + +
+
+
+
+ )) + )} +
+
+ + {deletingVariant && ( + { + if (!open) { + setDeletingVariant(null); + } + }} + title="Hapus Varian" + description={`Apakah Anda yakin ingin menghapus varian "${deletingVariant.variant}" dari bahan baku "${rawMaterial.name}"? Tindakan ini tidak dapat dibatalkan.`} + confirmLabel="Hapus" + onConfirm={handleDeleteVariant} + /> + )} +
+ ); +} diff --git a/routes/web.php b/routes/web.php index 7e7a42f..36b1154 100644 --- a/routes/web.php +++ b/routes/web.php @@ -15,6 +15,8 @@ use App\Http\Controllers\Admin\Master\Product\ProductController; use App\Http\Controllers\Admin\Master\Product\ProductVariantController; use App\Http\Controllers\Admin\Master\Product\StockMutationController; +use App\Http\Controllers\Admin\Master\RawMaterial\RawMaterialController; +use App\Http\Controllers\Admin\Master\RawMaterial\RawMaterialVariantController; use App\Http\Controllers\Admin\Master\SupplierController; use App\Http\Controllers\Admin\RoleController; use Illuminate\Support\Facades\Route; @@ -40,6 +42,11 @@ Route::put('products/{product}/variants/{variant}', [ProductVariantController::class, 'update'])->name('products.variants.update'); Route::post('products/{product}/variants/{variant}/transfer-stock', [ProductVariantController::class, 'transferStock'])->name('products.variants.transfer-stock'); Route::get('products/{product}/variants/{variant}/stock-mutations', [StockMutationController::class, 'index'])->name('products.variants.stock-mutations'); + Route::resource('raw-materials', RawMaterialController::class)->except(['show']); + Route::post('raw-materials/{rawMaterial}/toggle-status', [RawMaterialController::class, 'toggleStatus'])->name('raw-materials.toggle-status'); + Route::get('raw-materials/{rawMaterial}/variants/{variant}/edit', [RawMaterialVariantController::class, 'edit'])->name('raw-materials.variants.edit'); + Route::put('raw-materials/{rawMaterial}/variants/{variant}', [RawMaterialVariantController::class, 'update'])->name('raw-materials.variants.update'); + Route::delete('raw-materials/{rawMaterial}/variants/{variant}', [RawMaterialVariantController::class, 'destroy'])->name('raw-materials.variants.destroy'); Route::resource('suppliers', SupplierController::class)->except(['show', 'create', 'edit']); Route::resource('customers', CustomerController::class)->except(['show', 'create', 'edit']); }); diff --git a/tests/Feature/Admin/Master/RawMaterialTest.php b/tests/Feature/Admin/Master/RawMaterialTest.php new file mode 100644 index 0000000..478aca2 --- /dev/null +++ b/tests/Feature/Admin/Master/RawMaterialTest.php @@ -0,0 +1,1069 @@ + 'Bahan Baku Test', + 'unit' => 'kg', + 'is_active' => true, + 'variants' => [ + [ + 'variant' => 'Varian Default', + 'price' => 50000, + 'stock' => 100, + 'photo_key' => 'raw-material-variant/test-photo.jpg', + ], + ], + ], $overrides); +} + +/* +|-------------------------------------------------------------------------- +| AUTHENTICATION +|-------------------------------------------------------------------------- +*/ + +test('guests are redirected to the login page', function () { + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected when visiting create page', function () { + $response = $this->get(route('admin.master.raw-materials.create')); + $response->assertRedirect(route('login')); +}); + +test('guests are redirected when visiting edit page', function () { + $rawMaterial = RawMaterial::factory()->create(); + $response = $this->get(route('admin.master.raw-materials.edit', $rawMaterial)); + $response->assertRedirect(route('login')); +}); + +test('guest cannot create raw material', function () { + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload()); + $response->assertRedirect(route('login')); + $this->assertDatabaseCount('raw_materials', 0); +}); + +test('guest cannot update raw material', function () { + $rawMaterial = RawMaterial::factory()->create(); + $response = $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload()); + $response->assertRedirect(route('login')); +}); + +test('guest cannot delete raw material', function () { + $rawMaterial = RawMaterial::factory()->create(); + $response = $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + $response->assertRedirect(route('login')); + $this->assertDatabaseHas('raw_materials', ['id' => $rawMaterial->id, 'deleted_at' => null]); +}); + +test('guest cannot toggle raw material status', function () { + $rawMaterial = RawMaterial::factory()->create(); + $response = $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + $response->assertRedirect(route('login')); +}); + +test('authenticated users can visit the raw material index page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); +}); + +test('authenticated users can visit the raw material create page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.master.raw-materials.create')); + $response->assertOk(); +}); + +test('authenticated users can visit the raw material edit page', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + + $response = $this->get(route('admin.master.raw-materials.edit', $rawMaterial)); + $response->assertOk(); +}); + +/* +|-------------------------------------------------------------------------- +| INDEX PAGE +|-------------------------------------------------------------------------- +*/ + +test('raw material index page displays raw materials', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + RawMaterial::factory()->count(3)->create(); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/index') + ->has('rawMaterials.data', 3) + ->where('rawMaterials.total', 3) + ->where('rawMaterials.current_page', 1) + ->where('rawMaterials.per_page', 25) + ); +}); + +test('index page works with zero raw materials', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/index') + ->has('rawMaterials.data', 0) + ->where('rawMaterials.total', 0) + ->where('rawMaterials.current_page', 1) + ->where('rawMaterials.per_page', 25) + ); +}); + +test('index page does not display soft-deleted raw materials', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + RawMaterial::factory()->create(['name' => 'Active Material']); + RawMaterial::factory()->create(['name' => 'Deleted Material'])->delete(); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/index') + ->has('rawMaterials.data', 1) + ->where('rawMaterials.total', 1) + ->where('rawMaterials.data.0.name', 'Active Material') + ); +}); + +test('index page displays correct count after delete', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + RawMaterial::factory()->count(5)->create(); + $rawMaterial = RawMaterial::first(); + + $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/index') + ->has('rawMaterials.data', 4) + ->where('rawMaterials.total', 4) + ); +}); + +test('index page includes raw material prices (variants)', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->get(route('admin.master.raw-materials.index')); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/index') + ->has('rawMaterials.data.0.raw_material_prices', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| CREATE / STORE - BASIC +|-------------------------------------------------------------------------- +*/ + +test('raw material can be created', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload()); + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.raw-materials.index')); + + $this->assertDatabaseHas('raw_materials', [ + 'name' => 'Bahan Baku Test', + 'unit' => 'kg', + 'is_active' => true, + ]); + + $this->assertDatabaseCount('raw_material_prices', 1); +}); + +test('raw material can be created with multiple variants', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [ + ['variant' => 'Varian 1', 'price' => 50000, 'stock' => 10, 'photo_key' => 'raw-material-variant/v1.jpg'], + ['variant' => 'Varian 2', 'price' => 75000, 'stock' => 20, 'photo_key' => 'raw-material-variant/v2.jpg'], + ], + ])); + + $response->assertSessionHasNoErrors(); + + $this->assertDatabaseCount('raw_material_prices', 2); +}); + +test('raw material can be created with different units', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + foreach (['kg', 'meter', 'yard'] as $unit) { + $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => "Bahan {$unit}", + 'unit' => $unit, + ])); + } + + $this->assertDatabaseCount('raw_materials', 3); +}); + +test('raw material can be created with zero price', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => 'Gratis', 'price' => 0, 'stock' => 10, 'photo_key' => 'raw-material-variant/free.jpg']], + ])); + + $response->assertSessionHasNoErrors(); + + $price = RawMaterialPrice::where('variant', 'Gratis')->first(); + expect($price->price)->toBe(0); +}); + +test('raw material can be created with zero stock', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => 'Kosong', 'price' => 50000, 'stock' => 0, 'photo_key' => 'raw-material-variant/empty.jpg']], + ])); + + $response->assertSessionHasNoErrors(); +}); + +test('raw material can be created with decimal stock', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => 'Desimal', 'price' => 50000, 'stock' => 1.5, 'photo_key' => 'raw-material-variant/decimal.jpg']], + ])); + + $response->assertSessionHasNoErrors(); + + $price = RawMaterialPrice::where('variant', 'Desimal')->first(); + expect((float) $price->stock)->toBe(1.5); +}); + +test('raw material can be created as inactive', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['is_active' => false])); + $response->assertSessionHasNoErrors(); + + $rawMaterial = RawMaterial::where('name', 'Bahan Baku Test')->first(); + expect($rawMaterial->is_active)->toBeFalse(); +}); + +test('raw material default is_active is true', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + unset($payload['is_active']); + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasNoErrors(); + + $rawMaterial = RawMaterial::where('name', 'Bahan Baku Test')->first(); + expect($rawMaterial->is_active)->toBeTrue(); +}); + +test('store flashes success toast via Inertia', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload()); + $response->assertRedirect(); +}); + +test('store creates new raw material in database', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload()); + + $this->assertDatabaseCount('raw_materials', 1); + $this->assertDatabaseHas('raw_materials', ['name' => 'Bahan Baku Test']); +}); + +test('multiple raw materials can be created sequentially', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => 'Bahan 1'])); + $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => 'Bahan 2'])); + $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => 'Bahan 3'])); + + $this->assertDatabaseCount('raw_materials', 3); +}); + +/* +|-------------------------------------------------------------------------- +| STORE VALIDATION - REQUIRED FIELDS +|-------------------------------------------------------------------------- +*/ + +test('raw material name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => ''])); + $response->assertSessionHasErrors('name'); +}); + +test('raw material name must be string', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => 12345])); + $response->assertSessionHasErrors('name'); +}); + +test('raw material name must not exceed 200 characters', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => str_repeat('a', 201)])); + $response->assertSessionHasErrors('name'); +}); + +test('raw material name exactly 200 characters passes validation', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['name' => str_repeat('a', 200)])); + $response->assertSessionHasNoErrors(); +}); + +test('raw material unit is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['unit' => ''])); + $response->assertSessionHasErrors('unit'); +}); + +test('raw material unit must be valid enum value', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['unit' => 'invalid_unit'])); + $response->assertSessionHasErrors('unit'); +}); + +test('raw material variants is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload(['variants' => []])); + $response->assertSessionHasErrors('variants'); +}); + +test('raw material variant name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + $payload['variants'][0]['variant'] = ''; + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.variant'); +}); + +test('raw material variant price is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + unset($payload['variants'][0]['price']); + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.price'); +}); + +test('raw material variant price must be integer', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + $payload['variants'][0]['price'] = 'not-a-number'; + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.price'); +}); + +test('raw material variant price must be >= 0', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + $payload['variants'][0]['price'] = -100; + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.price'); +}); + +test('raw material variant stock is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + unset($payload['variants'][0]['stock']); + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.stock'); +}); + +test('raw material variant stock must be numeric', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + $payload['variants'][0]['stock'] = 'abc'; + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.stock'); +}); + +test('raw material variant stock must be >= 0', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + $payload['variants'][0]['stock'] = -1; + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.stock'); +}); + +test('raw material variant photo_key is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $payload = makeValidRawMaterialPayload(); + unset($payload['variants'][0]['photo_key']); + + $response = $this->post(route('admin.master.raw-materials.store'), $payload); + $response->assertSessionHasErrors('variants.0.photo_key'); +}); + +/* +|-------------------------------------------------------------------------- +| EDIT PAGE +|-------------------------------------------------------------------------- +*/ + +test('edit page shows raw material data', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['name' => 'Bahan Edit']); + RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Varian Edit', 'price' => 50000]); + + $response = $this->get(route('admin.master.raw-materials.edit', $rawMaterial)); + $response->assertOk(); + $response->assertInertia(fn (Assert $page) => $page + ->component('admin/master/raw-material/edit') + ->where('rawMaterial.name', 'Bahan Edit') + ->has('rawMaterial.raw_material_prices', 1) + ); +}); + +/* +|-------------------------------------------------------------------------- +| UPDATE / PUT +|-------------------------------------------------------------------------- +*/ + +test('raw material can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Existing']); + + $response = $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'name' => 'Bahan Updated', + 'variants' => [[ + 'id' => $variant->id, + 'variant' => 'Existing Updated', + 'price' => 75000, + 'stock' => 50, + 'photo_key' => 'raw-material-variant/existing.jpg', + ]], + ])); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(); + + $rawMaterial->refresh(); + expect($rawMaterial->name)->toBe('Bahan Updated'); +}); + +test('raw material update name is required', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload(['name' => ''])); + $response->assertSessionHasErrors('name'); +}); + +test('raw material update name must not exceed 200 characters', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload(['name' => str_repeat('a', 201)])); + $response->assertSessionHasErrors('name'); +}); + +test('updating non-existent raw material returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->put(route('admin.master.raw-materials.update', 999999), makeValidRawMaterialPayload(['name' => 'Ghost'])); + $response->assertStatus(404); +}); + +test('update can change raw material status', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['is_active' => true]); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'is_active' => false, + 'variants' => [[ + 'id' => $variant->id, + 'variant' => $variant->variant, + 'price' => $variant->price, + 'stock' => $variant->stock, + 'photo_key' => 'raw-material-variant/v1.jpg', + ]], + ])); + + $rawMaterial->refresh(); + expect($rawMaterial->is_active)->toBeFalse(); +}); + +test('update can change unit', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['unit' => 'kg']); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'unit' => 'meter', + 'variants' => [[ + 'id' => $variant->id, + 'variant' => $variant->variant, + 'price' => $variant->price, + 'stock' => $variant->stock, + 'photo_key' => 'raw-material-variant/v1.jpg', + ]], + ])); + + $rawMaterial->refresh(); + expect($rawMaterial->unit->value)->toBe('meter'); +}); + +test('update can add new variants', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $existingVariant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Existing']); + + $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'variants' => [ + ['id' => $existingVariant->id, 'variant' => 'Existing', 'price' => 50000, 'stock' => 10, 'photo_key' => 'raw-material-variant/existing.jpg'], + ['variant' => 'New Variant', 'price' => 75000, 'stock' => 20, 'photo_key' => 'raw-material-variant/new.jpg'], + ], + ])); + + $this->assertDatabaseCount('raw_material_prices', 2); +}); + +test('update can delete variants', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant1 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Keep']); + $variant2 = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Delete']); + + $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'variants' => [ + ['id' => $variant1->id, 'variant' => 'Keep', 'price' => 50000, 'stock' => 10, 'photo_key' => 'raw-material-variant/keep.jpg'], + ], + ])); + + $this->assertDatabaseHas('raw_material_prices', ['id' => $variant1->id, 'deleted_at' => null]); + $this->assertSoftDeleted('raw_material_prices', ['id' => $variant2->id]); +}); + +test('update can modify existing variant data', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create([ + 'raw_material_id' => $rawMaterial->id, + 'variant' => 'Original', + 'price' => 50000, + 'stock' => 10, + ]); + + $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload([ + 'variants' => [[ + 'id' => $variant->id, + 'variant' => 'Updated Name', + 'price' => 99999, + 'stock' => 88, + 'photo_key' => 'raw-material-variant/updated.jpg', + ]], + ])); + + $variant->refresh(); + expect($variant->variant)->toBe('Updated Name'); + expect($variant->price)->toBe(99999); + expect((float) $variant->stock)->toBe(88.0); +}); + +/* +|-------------------------------------------------------------------------- +| DELETE / DESTROY +|-------------------------------------------------------------------------- +*/ + +test('raw material can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + + $response = $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.raw-materials.index')); + + $this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]); +}); + +test('raw material is soft-deleted not permanently removed', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + + $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + + $this->assertDatabaseHas('raw_materials', ['id' => $rawMaterial->id]); + $this->assertSoftDeleted('raw_materials', ['id' => $rawMaterial->id]); +}); + +test('soft-deleted raw material has deleted_at timestamp', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + + $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + + $rawMaterial->refresh(); + expect($rawMaterial->deleted_at)->not->toBeNull(); +}); + +test('delete cascades to raw material prices', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + + $this->assertSoftDeleted('raw_material_prices', ['id' => $variant->id]); +}); + +test('deleting non-existent raw material returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->delete(route('admin.master.raw-materials.destroy', 999999)); + $response->assertStatus(404); +}); + +test('updating soft-deleted raw material via route returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $rawMaterial->delete(); + + $response = $this->put(route('admin.master.raw-materials.update', $rawMaterial), makeValidRawMaterialPayload(['name' => 'Nope'])); + $response->assertStatus(404); +}); + +test('deleting soft-deleted raw material via route returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $rawMaterial->delete(); + + $response = $this->delete(route('admin.master.raw-materials.destroy', $rawMaterial)); + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| TOGGLE STATUS +|-------------------------------------------------------------------------- +*/ + +test('toggle status changes active to inactive', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['is_active' => true]); + + $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + + $rawMaterial->refresh(); + expect($rawMaterial->is_active)->toBeFalse(); +}); + +test('toggle status changes inactive to active', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['is_active' => false]); + + $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + + $rawMaterial->refresh(); + expect($rawMaterial->is_active)->toBeTrue(); +}); + +test('toggle status changes active to inactive then back to active', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['is_active' => true]); + + $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + $rawMaterial->refresh(); + expect($rawMaterial->is_active)->toBeFalse(); + + $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + $rawMaterial->refresh(); + expect($rawMaterial->is_active)->toBeTrue(); +}); + +test('toggle status redirects to index', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(['is_active' => true]); + + $response = $this->post(route('admin.master.raw-materials.toggle-status', $rawMaterial)); + $response->assertRedirect(route('admin.master.raw-materials.index')); +}); + +test('toggle status on non-existent raw material returns 404', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.toggle-status', 999999)); + $response->assertStatus(404); +}); + +/* +|-------------------------------------------------------------------------- +| WEIRD / EDGE CASE USER INPUTS +|-------------------------------------------------------------------------- +*/ + +test('raw material name with special characters is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => 'Bahan & Co. (PT) - Best!', + ])); + + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('raw_materials', ['name' => 'Bahan & Co. (PT) - Best!']); +}); + +test('raw material name with HTML tags is accepted as plain text', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => '', + ])); + + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('raw_materials', ['name' => '']); +}); + +test('raw material name with emoji is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => '🧵 Bahan Kain 🏷️', + ])); + + $response->assertSessionHasNoErrors(); +}); + +test('raw material name with only numbers is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => '12345', + ])); + + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('raw_materials', ['name' => '12345']); +}); + +test('raw material name with SQL injection attempt is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'name' => "'; DROP TABLE raw_materials; --", + ])); + + $response->assertSessionHasNoErrors(); + $this->assertDatabaseHas('raw_materials', ['name' => "'; DROP TABLE raw_materials; --"]); +}); + +test('raw material variant name at max length 200 is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => str_repeat('a', 200), 'price' => 50000, 'stock' => 10, 'photo_key' => 'raw-material-variant/maxlen.jpg']], + ])); + + $response->assertSessionHasNoErrors(); +}); + +test('raw material variant name over 200 characters fails validation', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => str_repeat('a', 201), 'price' => 50000, 'stock' => 10, 'photo_key' => 'raw-material-variant/overmax.jpg']], + ])); + + $response->assertSessionHasErrors('variants.0.variant'); +}); + +test('raw material stock at very large value is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => 'Varian Besar', 'price' => 50000, 'stock' => 99999999, 'photo_key' => 'raw-material-variant/huge.jpg']], + ])); + + $response->assertSessionHasNoErrors(); +}); + +test('raw material price at very large value is accepted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $response = $this->post(route('admin.master.raw-materials.store'), makeValidRawMaterialPayload([ + 'variants' => [['variant' => 'Varian Mahal', 'price' => 999999999, 'stock' => 1, 'photo_key' => 'raw-material-variant/expensive.jpg']], + ])); + + $response->assertSessionHasNoErrors(); +}); + +/* +|-------------------------------------------------------------------------- +| VARIANT EDIT / UPDATE +|-------------------------------------------------------------------------- +*/ + +test('variant can be updated', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'variant' => 'Old Name', 'price' => 50000, 'stock' => 10]); + + $response = $this->put(route('admin.master.raw-materials.variants.update', [$rawMaterial, $variant]), [ + 'variant' => 'New Name', + 'price' => 75000, + 'stock' => 20, + 'photo_key' => 'raw-material-variant/updated.jpg', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.raw-materials.index')); + + $variant->refresh(); + expect($variant->variant)->toBe('New Name'); + expect($variant->price)->toBe(75000); + expect((float) $variant->stock)->toBe(20.0); +}); + +test('variant update validation requires variant name', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.variants.update', [$rawMaterial, $variant]), [ + 'variant' => '', + 'price' => 50000, + 'stock' => 10, + 'photo_key' => 'raw-material-variant/v1.jpg', + ]); + + $response->assertSessionHasErrors('variant'); +}); + +test('variant update validation requires price', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.variants.update', [$rawMaterial, $variant]), [ + 'variant' => 'Test', + 'stock' => 10, + 'photo_key' => 'raw-material-variant/v1.jpg', + ]); + + $response->assertSessionHasErrors('price'); +}); + +test('variant update validation requires stock', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.variants.update', [$rawMaterial, $variant]), [ + 'variant' => 'Test', + 'price' => 50000, + 'photo_key' => 'raw-material-variant/v1.jpg', + ]); + + $response->assertSessionHasErrors('stock'); +}); + +test('variant update validation requires photo_key', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->put(route('admin.master.raw-materials.variants.update', [$rawMaterial, $variant]), [ + 'variant' => 'Test', + 'price' => 50000, + 'stock' => 10, + ]); + + $response->assertSessionHasErrors('photo_key'); +}); + +/* +|-------------------------------------------------------------------------- +| VARIANT DELETE +|-------------------------------------------------------------------------- +*/ + +test('variant can be deleted', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $response = $this->delete(route('admin.master.raw-materials.variants.destroy', [$rawMaterial, $variant])); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('admin.master.raw-materials.index')); + + $this->assertSoftDeleted('raw_material_prices', ['id' => $variant->id]); +}); + +test('deleting variant does not affect raw material', function () { + $user = User::factory()->create(); + $this->actingAs($user); + + $rawMaterial = RawMaterial::factory()->create(); + $variant = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id]); + + $this->delete(route('admin.master.raw-materials.variants.destroy', [$rawMaterial, $variant])); + + $this->assertDatabaseHas('raw_materials', ['id' => $rawMaterial->id, 'deleted_at' => null]); +});