refactor: remove quick-create raw material functionality and associated request, streamlining the CuttingDraftItemController and related components

This commit is contained in:
Yoga Pangestu 2026-07-09 15:22:12 +07:00
parent b8e9aaa128
commit b93a200284
9 changed files with 26 additions and 383 deletions

View File

@ -7,7 +7,6 @@
use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest;
use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest;
use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest;
use App\Http\Requests\Admin\Manage\CuttingQuickCreateRawMaterialRequest;
use App\Models\ProductVariant;
use App\Models\RawMaterialPrice;
use App\Services\Manage\CuttingService;
@ -62,13 +61,6 @@ public function destroyCombination(Request $request, int $combinationId): JsonRe
return response()->json(['ok' => true]);
}
public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse
{
$rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated());
return response()->json(['raw_material' => $rawMaterial]);
}
public function quickCreateProduct(CuttingQuickCreateProductRequest $request): JsonResponse
{
$product = $this->cuttingService->quickCreateProduct($request->validated());

View File

@ -1,44 +0,0 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use App\Http\Requests\Concerns\HasRawMaterialPriceRules;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class CuttingQuickCreateRawMaterialRequest extends FormRequest
{
use HasRawMaterialPriceRules;
public function authorize(): bool
{
return $this->user()?->can(Permission::CUTTINGS_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:200'],
'unit' => ['required', Rule::enum(RawMaterialUnit::class)],
...$this->rawMaterialPriceRules(),
];
}
/**
* @return array<string, string>
*/
public function attributes(): array
{
return [
'name' => 'Nama Bahan Baku',
'unit' => 'Satuan',
...$this->rawMaterialPriceAttributes(),
];
}
}

View File

@ -1182,65 +1182,6 @@ private function storeResultPrices(Cutting $cutting, array $resultPrices): void
}
}
/**
* Quick-create a raw material with variants, bypassing owner verification.
*/
public function quickCreateRawMaterial(array $validated): array
{
$rawMaterial = RawMaterial::create([
'name' => $validated['name'],
'unit' => $validated['unit'],
'is_active' => true,
]);
$createdPrices = [];
$maxVariantImages = 5;
foreach ($validated['prices'] as $index => $priceData) {
$price = $rawMaterial->prices()->create([
'variant' => $priceData['variant'],
'price' => $priceData['price'],
'stock' => $priceData['stock'],
]);
$this->mediaService->syncCollection(
$price,
'images',
$priceData['images'] ?? null,
null,
$maxVariantImages,
required: true,
errorKey: "prices.{$index}.images",
s3Keys: $priceData['s3_keys'] ?? null,
);
$price->refresh();
$createdPrices[] = [
'id' => $price->id,
'variant' => $price->variant,
'price' => $price->price,
'price_formatted' => 'Rp '.number_format($price->price, 0, ',', '.'),
'stock' => $price->stock,
'stock_formatted' => $this->formatStockForUnit((float) $price->stock, $rawMaterial->unit),
'stock_input' => $this->formatQuantityInput((float) $price->stock),
'images' => MediaPresenter::collection($price, 'images'),
];
}
$this->cacheForgetByPattern('manage:cuttings:*');
return [
'id' => $rawMaterial->id,
'name' => $rawMaterial->name,
'unit' => $rawMaterial->unit->value,
'unit_label' => $rawMaterial->unit->label(),
'unit_abbreviation' => $rawMaterial->unit->abbreviation(),
'is_active' => true,
'prices' => $createdPrices,
];
}
/**
* Quick-create a product with variants and prices, bypassing owner verification.
*/

View File

@ -263,7 +263,7 @@ function () use ($validated, $user): array {
if ($rawMaterial->trashed()) {
$rawMaterial->restore();
}
if ($isOwner && !$rawMaterial->is_active) {
if ($isOwner && ! $rawMaterial->is_active) {
$rawMaterial->update(['is_active' => true]);
}
} else {
@ -847,7 +847,7 @@ private function processRequestItems(array $items, bool $isOwner): array
if ($rawMaterial->trashed()) {
$rawMaterial->restore();
}
if ($isOwner && !$rawMaterial->is_active) {
if ($isOwner && ! $rawMaterial->is_active) {
$rawMaterial->update(['is_active' => true]);
}
} else {
@ -881,7 +881,7 @@ private function processRequestItems(array $items, bool $isOwner): array
}
// Sync variant photo if provided
if (!empty($itemData['photos']) || !empty($itemData['s3_keys']) || !empty($itemData['remove_media_ids'])) {
if (! empty($itemData['photos']) || ! empty($itemData['s3_keys']) || ! empty($itemData['remove_media_ids'])) {
$this->mediaService->syncCollection(
$price,
'images',

View File

@ -292,17 +292,7 @@ function submit() {
});
}
function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) {
// Add to catalog
rawMaterialCatalogState.value.push(rawMaterial);
// Auto-add the first price variant to cart
const firstPrice = rawMaterial.prices[0];
if (firstPrice) {
addMaterial(rawMaterial, firstPrice);
}
}
function onCombinationCreated(items: CuttingMaterialCartItem[]) {
for (const item of items) {
@ -333,7 +323,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
:material-cart="materialCart" :get-material-cart-item="getMaterialCartItem" :units="units"
@add-material="addMaterial" @decrease-material-qty="decreaseMaterialQty"
@raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" />
@combination-created="onCombinationCreated" />
<CuttingPosResultCatalogPanel v-model:product-search="productSearch" :filtered-products="filteredProducts"
:get-result-cart-item="getResultCartItem" @add-result="addResult" :is-create-mode="isCreateMode"

View File

@ -25,7 +25,6 @@ import { getFirstCoverImage } from '@/lib/catalog-cover';
import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting';
import type { EnumOption } from '@/types/raw-material';
import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue';
import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue';
import type { CuttingCatalogPrice } from './useCuttingPosCart';
const props = defineProps<{
@ -83,11 +82,9 @@ const selectedRawMaterialId = defineModel<string>('selectedRawMaterialId', { req
const emit = defineEmits<{
'add-material': [rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice];
'decrease-material-qty': [priceId: number];
'raw-material-created': [rawMaterial: CuttingRawMaterialCatalogItem];
'combination-created': [items: CuttingMaterialCartItem[]];
}>();
const quickCreateOpen = ref(false);
const combinationOpen = ref(false);
const combinationInitialVariant = ref<{
rawMaterial: CuttingRawMaterialCatalogItem;
@ -104,10 +101,6 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<Card class="min-w-0">
<CardHeader class="flex flex-row items-center justify-between pb-3 space-y-0">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
<Button type="button" variant="outline" size="sm" @click="quickCreateOpen = true">
<Plus class="size-3.5 mr-1" />
Baru
</Button>
</CardHeader>
<CardContent>
<div class="space-y-4">
@ -119,10 +112,10 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
<div class="w-full sm:w-60">
<Select v-model="selectedRawMaterialId">
<SelectTrigger class="w-full">
<SelectValue placeholder="Semua Bahan Baku" />
<SelectValue placeholder="FIlter Bahan Baku" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Bahan Baku</SelectItem>
<SelectItem value="all">Filter Bahan Baku</SelectItem>
<SelectItem v-for="item in rawMaterialCatalog" :key="item.id" :value="String(item.id)">
{{ item.name }}
</SelectItem>
@ -207,8 +200,7 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</CardContent>
</Card>
<QuickCreateRawMaterialModal v-model:open="quickCreateOpen" :units="units"
@created="emit('raw-material-created', $event)" />
<CuttingPosCombinationDialog v-model:open="combinationOpen" :raw-material-catalog="rawMaterialCatalog"
:existing-cart-items="materialCart" :initial-variant="combinationInitialVariant"

View File

@ -1,228 +0,0 @@
<script setup lang="ts">
import { Plus, Save } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { FieldError } from '@/components/ui/field';
import { useVariantList } from '@/composables/useVariantList';
import { apiFetch } from '@/lib/api';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import RawMaterialInfoSection from '@/pages/admin/master/raw-materials/form/RawMaterialInfoSection.vue';
import RawMaterialSharedPriceSection from '@/pages/admin/master/raw-materials/form/RawMaterialSharedPriceSection.vue';
import RawMaterialVariantSection from '@/pages/admin/master/raw-materials/form/RawMaterialVariantSection.vue';
import type { CuttingRawMaterialCatalogItem } from '@/types/cutting';
import { appendMediaToFormData, createMediaUploadState } from '@/types/media';
import type { EnumOption, RawMaterialPriceFormItem } from '@/types/raw-material';
const props = defineProps<{
units: EnumOption[];
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
'created': [rawMaterial: CuttingRawMaterialCatalogItem];
}>();
const loading = ref(false);
const selectPortalTarget = ref<HTMLElement>();
const isUploading = computed(() =>
prices.value.some((p) => p.media.pendingUploads > 0)
);
function createClientId(): string {
return `price-${crypto.randomUUID()}`;
}
const form = ref({
name: '',
unit: '',
errors: {} as Record<string, string>,
});
const {
items: prices,
removeItem: removePrice,
setField: setPriceField,
appendToFormData,
itemErrors: priceErrors,
} = useVariantList<RawMaterialPriceFormItem>(
'prices',
() => ({
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}),
() => [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}],
);
const useSamePrice = ref(true);
function addPrice() {
const newPrice: RawMaterialPriceFormItem = {
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
};
if (useSamePrice.value && prices.value[0]) {
newPrice.price = prices.value[0].price;
}
prices.value = [...prices.value, newPrice];
}
function setPriceValue(clientId: string, value: string) {
setPriceField(clientId, 'price', value);
}
function setSharedPrice(value: string) {
prices.value = prices.value.map((price) => ({ ...price, price: value }));
}
function toggleUseSamePrice(checked: boolean) {
useSamePrice.value = checked;
if (!checked || !prices.value[0]) {
return;
}
const sourcePrice = prices.value[0].price;
prices.value = prices.value.map((price) => ({ ...price, price: sourcePrice }));
}
function applyPriceToAllVariants(sourceClientId: string) {
const source = prices.value.find((price) => price.client_id === sourceClientId);
if (!source) {
return;
}
prices.value = prices.value.map((price) => ({ ...price, price: source.price }));
}
function parseStockValue(value: string): number {
const parsed = Number.parseFloat(value.replace(',', '.'));
return Number.isNaN(parsed) ? 0 : parsed;
}
function resetForm() {
form.value = { name: '', unit: '', errors: {} };
prices.value = [{
client_id: createClientId(),
variant: '',
price: '',
stock: '0',
media: createMediaUploadState(),
}];
useSamePrice.value = true;
}
function buildFormData(): FormData {
const formData = new FormData();
formData.append('name', form.value.name.trim());
formData.append('unit', form.value.unit);
appendToFormData(formData, (formData, index, price) => {
formData.append(`prices[${index}][variant]`, price.variant.trim());
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
appendMediaToFormData(formData, `prices[${index}]`, price.media);
}, 'post');
return formData;
}
async function submit() {
loading.value = true;
form.value.errors = {};
try {
const payload = buildFormData();
const { raw_material } = await apiFetch<{ raw_material: CuttingRawMaterialCatalogItem }>(
'/admin/manage/cuttings/quick-create-raw-material',
{
method: 'POST',
body: payload,
},
);
toast.success(`Bahan baku "${raw_material.name}" berhasil ditambahkan.`);
emit('created', raw_material);
open.value = false;
resetForm();
} catch (error) {
if (error instanceof Error) {
toast.error(error.message);
}
} finally {
loading.value = false;
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="flex max-h-[90vh] flex-col sm:max-w-5xl">
<div class="scrollbar-thin flex-1 overflow-y-auto pr-1">
<form id="quick-create-raw-material-form" @submit.prevent="submit">
<RawMaterialInfoSection :form="form" :units="units" method="post"
:select-portal-target="selectPortalTarget" />
<RawMaterialSharedPriceSection :form="form" :prices="prices" :use-same-price="useSamePrice"
@toggle-use-same-price="toggleUseSamePrice" @set-shared-price="setSharedPrice" />
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
@remove="removePrice(price.client_id)"
@apply-price-to-all="applyPriceToAllVariants(price.client_id)"
@update:variant="setPriceField(price.client_id, 'variant', $event)"
@update:stock="setPriceField(price.client_id, 'stock', $event)"
@update:price="setPriceValue(price.client_id, $event)" />
<FieldError :errors="formErrors(form, 'prices')" />
</form>
</div>
<div class="flex items-center justify-between gap-2 border-t pt-4">
<Button type="button" variant="outline" @click="addPrice">
<Plus class="size-4" />
Tambah Varian
</Button>
<div class="flex gap-2">
<Button type="button" variant="outline" :disabled="loading" @click="open = false">
Batal
</Button>
<Button type="submit" form="quick-create-raw-material-form" :disabled="loading || isUploading">
<Save class="size-4" />
{{ isUploading ? 'Mengunggah...' : loading ? 'Menyimpan...' : 'Simpan' }}
</Button>
</div>
</div>
<div ref="selectPortalTarget" class="contents"></div>
</DialogContent>
</Dialog>
</template>

View File

@ -370,10 +370,6 @@
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('draft_combinations.destroy');
Route::post('quick-create-raw-material', [CuttingDraftItemController::class, 'quickCreateRawMaterial'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('quick_create_raw_material');
Route::post('quick-create-product', [CuttingDraftItemController::class, 'quickCreateProduct'])
->middleware('permission:'.Permission::CUTTINGS_CREATE->value)
->name('quick_create_product');

View File

@ -6,11 +6,15 @@
use App\Models\OwnerVerificationRequest;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\Supplier;
use App\Models\User;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
@ -64,7 +68,7 @@ function approveLatestPurchaseVerificationRequest(User $verifier): OwnerVerifica
function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2): array
{
$unit = $price->rawMaterial->unit;
$unitVal = $unit instanceof \BackedEnum ? $unit->value : $unit;
$unitVal = $unit instanceof BackedEnum ? $unit->value : $unit;
return [
[
@ -74,7 +78,7 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2):
'variant' => $price->variant,
'quantity' => $quantity,
'price' => $price->price,
]
],
];
}
@ -791,9 +795,9 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2):
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key, $imageContent);
$key = 'raw-materials/'.Str::uuid().'.jpg';
$imageContent = UploadedFile::fake()->image('Standard.jpg', 100, 100)->get();
Storage::disk($disk)->put($key, $imageContent);
$this->actingAs($user)
->postJson(route('admin.manage.purchases.draft_items.store_new_raw_material'), [
@ -846,11 +850,11 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2):
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key1 = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$key2 = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key1, $imageContent);
\Illuminate\Support\Facades\Storage::disk($disk)->put($key2, $imageContent);
$key1 = 'raw-materials/'.Str::uuid().'.jpg';
$key2 = 'raw-materials/'.Str::uuid().'.jpg';
$imageContent = UploadedFile::fake()->image('Standard.jpg', 100, 100)->get();
Storage::disk($disk)->put($key1, $imageContent);
Storage::disk($disk)->put($key2, $imageContent);
// Add first variant
$this->actingAs($user)
@ -877,11 +881,11 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2):
->assertOk();
// Check that raw_materials table only has 1 record for this name
$rawMaterialsCount = \App\Models\RawMaterial::where('name', 'Kain Toyobo Unik')->count();
$rawMaterialsCount = RawMaterial::where('name', 'Kain Toyobo Unik')->count();
expect($rawMaterialsCount)->toBe(1);
// Check that raw_material_prices has both variants for the same raw_material_id
$rawMaterial = \App\Models\RawMaterial::where('name', 'Kain Toyobo Unik')->first();
$rawMaterial = RawMaterial::where('name', 'Kain Toyobo Unik')->first();
$this->assertDatabaseHas('raw_material_prices', [
'raw_material_id' => $rawMaterial->id,
'variant' => 'Standard',
@ -901,9 +905,9 @@ function getPurchaseItemsPayload(RawMaterialPrice $price, float $quantity = 2):
);
$disk = config('filesystems.default') === 's3' ? 's3' : config('filesystems.default', 'public');
$key = 'raw-materials/'.\Illuminate\Support\Str::uuid().'.jpg';
$imageContent = \Illuminate\Http\UploadedFile::fake()->image("Standard.jpg", 100, 100)->get();
\Illuminate\Support\Facades\Storage::disk($disk)->put($key, $imageContent);
$key = 'raw-materials/'.Str::uuid().'.jpg';
$imageContent = UploadedFile::fake()->image('Standard.jpg', 100, 100)->get();
Storage::disk($disk)->put($key, $imageContent);
// Add standard variant first time (qty = 10)
$this->actingAs($user)