diff --git a/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php b/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php index f146559..084cf05 100644 --- a/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/Cutting/CuttingDraftItemController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Admin\Manage\Cutting; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\Manage\CuttingDraftCombinationRequest; use App\Http\Requests\Admin\Manage\CuttingDraftMaterialRequest; use App\Http\Requests\Admin\Manage\CuttingDraftResultRequest; use App\Http\Requests\Admin\Manage\CuttingQuickCreateProductRequest; @@ -47,6 +48,20 @@ public function destroyResult(Request $request, ProductVariant $productVariant): return response()->json(['ok' => true]); } + public function storeCombination(CuttingDraftCombinationRequest $request): JsonResponse + { + $items = $this->cuttingService->syncDraftCombination($request->validated(), $request->user()); + + return response()->json(['items' => $items]); + } + + public function destroyCombination(Request $request, int $combinationId): JsonResponse + { + $this->cuttingService->removeDraftCombination($request->user(), $combinationId); + + return response()->json(['ok' => true]); + } + public function quickCreateRawMaterial(CuttingQuickCreateRawMaterialRequest $request): JsonResponse { $rawMaterial = $this->cuttingService->quickCreateRawMaterial($request->validated()); diff --git a/app/Http/Requests/Admin/Manage/CuttingDraftCombinationRequest.php b/app/Http/Requests/Admin/Manage/CuttingDraftCombinationRequest.php new file mode 100644 index 0000000..83fcdb1 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/CuttingDraftCombinationRequest.php @@ -0,0 +1,31 @@ +user()?->can(Permission::CUTTINGS_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'materials' => ['required', 'array', 'min:2'], + 'materials.*.raw_material_price_id' => [ + 'required', + 'integer', + Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), + ], + 'materials.*.material_usage' => ['required', 'numeric', 'decimal:0,4', 'gt:0'], + ]; + } +} diff --git a/app/Http/Requests/Admin/Manage/CuttingRequest.php b/app/Http/Requests/Admin/Manage/CuttingRequest.php index 73c12a7..72702cd 100644 --- a/app/Http/Requests/Admin/Manage/CuttingRequest.php +++ b/app/Http/Requests/Admin/Manage/CuttingRequest.php @@ -39,10 +39,10 @@ public function rules(): array $rules['materials.*.raw_material_price_id'] = [ 'required', 'integer', - 'distinct', Rule::exists('raw_material_prices', 'id')->whereNull('deleted_at'), ]; $rules['materials.*.material_usage'] = ['required', 'numeric', 'decimal:0,4', 'gt:0']; + $rules['materials.*.combination_id'] = ['nullable', 'integer', 'exists:cutting_material_combinations,id']; $rules['results'] = ['required', 'array', 'min:1']; $rules['results.*.product_variant_id'] = [ diff --git a/app/Models/CuttingMaterial.php b/app/Models/CuttingMaterial.php index 120fbb5..a9de9d5 100644 --- a/app/Models/CuttingMaterial.php +++ b/app/Models/CuttingMaterial.php @@ -88,6 +88,11 @@ public function rawMaterialPrice(): BelongsTo return $this->belongsTo(RawMaterialPrice::class); } + public function combination(): BelongsTo + { + return $this->belongsTo(CuttingMaterialCombination::class, 'combination_id'); + } + public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/CuttingMaterialCombination.php b/app/Models/CuttingMaterialCombination.php new file mode 100644 index 0000000..4b03cf3 --- /dev/null +++ b/app/Models/CuttingMaterialCombination.php @@ -0,0 +1,32 @@ +belongsTo(Cutting::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function materials(): HasMany + { + return $this->hasMany(CuttingMaterial::class, 'combination_id'); + } +} diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index e6f0c2c..0f673f8 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -7,6 +7,7 @@ use App\Enums\Role; use App\Models\Cutting; use App\Models\CuttingMaterial; +use App\Models\CuttingMaterialCombination; use App\Models\CuttingResult; use App\Models\CuttingResultPrice; use App\Models\Product; @@ -42,6 +43,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', + 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) @@ -85,6 +87,7 @@ public function getInProgressCuttings(User $user): Collection 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', + 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) @@ -109,6 +112,7 @@ public function getCompletedCuttings(User $user): Collection 'rejection.rejectedBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', + 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]) @@ -197,6 +201,7 @@ public function findForEdit(Cutting $cutting): Cutting 'media', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', + 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', 'rejection.rejectedBy.profile', @@ -215,6 +220,7 @@ public function findForShare(Cutting $cutting): Cutting 'createdBy.profile', 'materials.rawMaterialPrice.rawMaterial:id,name,unit', 'materials.rawMaterialPrice.media', + 'materials.combination', 'results.productVariant.product:id,name', 'results.productVariant.media', ]); @@ -256,6 +262,7 @@ public function draftMaterialsForUser(User $user): array ->with([ 'rawMaterialPrice.rawMaterial:id,name,unit', 'rawMaterialPrice.media', + 'combination', ]) ->get() ->map(function (CuttingMaterial $item) { @@ -398,6 +405,71 @@ public function removeDraftResult(User $user, ProductVariant $productVariant): v ->delete(); } + /** + * @return list + */ + public function syncDraftCombination(array $validated, User $user): array + { + $combination = CuttingMaterialCombination::create([ + 'user_id' => $user->id, + 'cutting_id' => null, + ]); + + $items = []; + + foreach ($validated['materials'] as $index => $materialData) { + $price = RawMaterialPrice::query() + ->with('rawMaterial') + ->findOrFail($materialData['raw_material_price_id']); + + $materialUsage = round((float) $materialData['material_usage'], 2); + + if ($materialUsage <= 0) { + throw ValidationException::withMessages([ + "materials.{$index}.material_usage" => 'Pemakaian bahan harus lebih dari 0.', + ]); + } + + $item = CuttingMaterial::create([ + 'user_id' => $user->id, + 'cutting_id' => null, + 'raw_material_price_id' => $price->id, + 'combination_id' => $combination->id, + 'material_usage' => $materialUsage, + ]); + + $item->load([ + 'rawMaterialPrice.rawMaterial:id,name,unit', + 'rawMaterialPrice.media', + ]); + + $items[] = $this->presentDraftMaterial($item); + $this->breakMaterialCircularReference($item); + } + + return $items; + } + + public function removeDraftCombination(User $user, int $combinationId): void + { + $combination = CuttingMaterialCombination::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id) + ->where('id', $combinationId) + ->first(); + + if ($combination === null) { + return; + } + + // Hard delete associated materials first (cascadeOnDelete only works with hard delete) + CuttingMaterial::query() + ->where('combination_id', $combination->id) + ->forceDelete(); + + $combination->forceDelete(); + } + public function create(array $validated, User $user): Cutting { $cutting = $this->runInTransaction( @@ -434,6 +506,19 @@ function () use ($validated, $user): Cutting { $this->syncImages($cutting, $validated); + // Transfer draft combinations to cutting + $draftCombinations = CuttingMaterialCombination::query() + ->whereNull('cutting_id') + ->where('user_id', $user->id) + ->lockForUpdate() + ->get(); + + foreach ($draftCombinations as $combination) { + $combination->cutting_id = $cutting->id; + $combination->user_id = null; + $combination->save(); + } + foreach ($draftMaterials as $material) { $material->cutting_id = $cutting->id; $material->user_id = null; @@ -675,6 +760,7 @@ private function buildMaterials(array $materials): array return [ 'raw_material_price_id' => $price->id, 'material_usage' => $materialUsage, + 'combination_id' => $itemData['combination_id'] ?? null, ]; }) ->all(); @@ -844,7 +930,9 @@ private function breakMaterialCircularReference(CuttingMaterial $material): void $price->unsetRelation('rawMaterial'); } + $material->setAttribute('combination_id', $material->combination_id); $material->unsetRelation('rawMaterialPrice'); + $material->unsetRelation('combination'); } private function applySorting(Builder $query, string $sort, string $direction): void @@ -886,6 +974,7 @@ private function presentDraftMaterial(CuttingMaterial $item): array 'stock_input' => $price?->stock_input ?? '', 'material_usage' => $this->formatQuantityInput((float) $item->material_usage), 'images' => $price ? MediaPresenter::collection($price, 'images') : [], + 'combination_id' => $item->combination_id, ]; } diff --git a/database/factories/CuttingMaterialCombinationFactory.php b/database/factories/CuttingMaterialCombinationFactory.php new file mode 100644 index 0000000..4ee38e8 --- /dev/null +++ b/database/factories/CuttingMaterialCombinationFactory.php @@ -0,0 +1,24 @@ + + */ +class CuttingMaterialCombinationFactory extends Factory +{ + protected $model = CuttingMaterialCombination::class; + + public function definition(): array + { + return [ + 'cutting_id' => Cutting::factory(), + 'user_id' => null, + ]; + } +} diff --git a/database/migrations/2026_07_04_000001_create_cutting_material_combinations_table.php b/database/migrations/2026_07_04_000001_create_cutting_material_combinations_table.php new file mode 100644 index 0000000..df70652 --- /dev/null +++ b/database/migrations/2026_07_04_000001_create_cutting_material_combinations_table.php @@ -0,0 +1,27 @@ +id(); + + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('cutting_id')->nullable()->constrained()->cascadeOnDelete(); + + $table->timestamp('created_at')->useCurrent(); + $table->timestamp('updated_at')->useCurrent()->useCurrentOnUpdate(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('cutting_material_combinations'); + } +}; diff --git a/database/migrations/2026_07_04_000002_add_combination_id_to_cutting_materials_table.php b/database/migrations/2026_07_04_000002_add_combination_id_to_cutting_materials_table.php new file mode 100644 index 0000000..e4119fa --- /dev/null +++ b/database/migrations/2026_07_04_000002_add_combination_id_to_cutting_materials_table.php @@ -0,0 +1,22 @@ +foreignId('combination_id')->nullable()->after('raw_material_price_id')->constrained('cutting_material_combinations')->cascadeOnDelete(); + }); + } + + public function down(): void + { + Schema::table('cutting_materials', function (Blueprint $table): void { + $table->dropConstrainedForeignId('combination_id'); + }); + } +}; diff --git a/resources/js/pages/admin/manage/cuttings/Edit.vue b/resources/js/pages/admin/manage/cuttings/Edit.vue index 3745204..90b22b1 100644 --- a/resources/js/pages/admin/manage/cuttings/Edit.vue +++ b/resources/js/pages/admin/manage/cuttings/Edit.vue @@ -35,6 +35,7 @@ const initialData = computed(() => ({ stock_input: item.stock_input ?? '', material_usage: item.material_usage_input, images: item.images ?? [], + combination_id: item.combination_id ?? null, })), results: props.cutting.results.map((item) => ({ product_variant_id: item.product_variant_id, diff --git a/resources/js/pages/admin/manage/cuttings/Share.vue b/resources/js/pages/admin/manage/cuttings/Share.vue index fd81777..f2852bc 100644 --- a/resources/js/pages/admin/manage/cuttings/Share.vue +++ b/resources/js/pages/admin/manage/cuttings/Share.vue @@ -22,6 +22,7 @@ interface GroupedMaterials { name: string; unitLabel?: string; items: typeof props.cutting.materials; + isCombination?: boolean; } interface GroupedResults { @@ -31,9 +32,37 @@ interface GroupedResults { } const groupedMaterials = computed(() => { - const groups: Record = {}; + // First, group by combination_id + const combinationGroups: Record = {}; + const nonCombinationItems: typeof props.cutting.materials = []; props.cutting.materials.forEach((item) => { + if (item.combination_id) { + if (!combinationGroups[item.combination_id]) { + combinationGroups[item.combination_id] = []; + } + combinationGroups[item.combination_id].push(item); + } else { + nonCombinationItems.push(item); + } + }); + + const result: GroupedMaterials[] = []; + + // Add combination groups + Object.entries(combinationGroups).forEach(([combinationId, items]) => { + result.push({ + key: Number(combinationId) * -1, + name: 'Kombinasi', + items, + isCombination: true, + }); + }); + + // Group non-combination items by raw material + const groups: Record = {}; + + nonCombinationItems.forEach((item) => { const key = item.raw_material_id ?? 0; const name = item.raw_material_name || 'Bahan Baku Tidak Diketahui'; @@ -49,7 +78,9 @@ const groupedMaterials = computed(() => { groups[key].items.push(item); }); - return Object.values(groups); + result.push(...Object.values(groups)); + + return result; }); const groupedResults = computed(() => { @@ -178,14 +209,25 @@ const groupedResults = computed(() => { colspan="3" class="font-semibold" > - {{ group.name }} - - {{ group.unitLabel }} - + + +import { Layers, Plus, Search, X } from '@lucide/vue'; +import { computed, ref, watch } from 'vue'; +import { toast } from 'vue-sonner'; +import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { DecimalInput } from '@/components/form/decimal-input'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { apiFetch } from '@/lib/api'; +import draft_combinations from '@/routes/admin/manage/cuttings/draft_combinations'; +import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting'; + +export type CuttingCatalogPrice = CuttingRawMaterialCatalogItem['prices'][number]; + +type SelectedMaterial = { + raw_material_price_id: number; + raw_material_name: string; + variant: string; + unit: string; + unit_abbreviation: string; + stock_input: string; + material_usage: string; + images?: CuttingCatalogPrice['images']; + is_initial?: boolean; +}; + +const props = defineProps<{ + rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; + existingCartItems: CuttingMaterialCartItem[]; + initialVariant?: { + rawMaterial: CuttingRawMaterialCatalogItem; + price: CuttingCatalogPrice; + } | null; +}>(); + +const open = defineModel('open', { required: true }); + +const emit = defineEmits<{ + 'combination-created': [items: CuttingMaterialCartItem[]]; +}>(); + +const search = ref(''); +const selectedMaterials = ref([]); +const loading = ref(false); + +const filteredRawMaterials = computed(() => { + const keyword = search.value.trim().toLowerCase(); + const catalog = props.rawMaterialCatalog; + + if (!keyword) { + return catalog; + } + + return catalog.filter( + (rawMaterial) => + rawMaterial.name.toLowerCase().includes(keyword) + || rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)), + ); +}); + +function isSelected(priceId: number): boolean { + return selectedMaterials.value.some((item) => item.raw_material_price_id === priceId); +} + +function toggleVariant(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) { + const index = selectedMaterials.value.findIndex( + (item) => item.raw_material_price_id === price.id, + ); + + if (index !== -1) { + if (!selectedMaterials.value[index].is_initial) { + selectedMaterials.value.splice(index, 1); + } + } else { + selectedMaterials.value.push({ + raw_material_price_id: price.id, + raw_material_name: rawMaterial.name, + variant: price.variant, + unit: rawMaterial.unit, + unit_abbreviation: rawMaterial.unit_abbreviation, + stock_input: price.stock_input, + material_usage: '1', + images: price.images, + }); + } +} + +function removeSelected(priceId: number) { + selectedMaterials.value = selectedMaterials.value.filter( + (item) => item.raw_material_price_id !== priceId || item.is_initial, + ); +} + +function resetForm() { + search.value = ''; + selectedMaterials.value = []; +} + +function initFromVariant() { + if (props.initialVariant) { + const { rawMaterial, price } = props.initialVariant; + selectedMaterials.value = [{ + raw_material_price_id: price.id, + raw_material_name: rawMaterial.name, + variant: price.variant, + unit: rawMaterial.unit, + unit_abbreviation: rawMaterial.unit_abbreviation, + stock_input: price.stock_input, + material_usage: '1', + images: price.images, + is_initial: true, + }]; + } +} + +async function submit() { + if (selectedMaterials.value.length < 2) { + toast.error('Pilih minimal 2 bahan baku untuk dikombinasikan.'); + return; + } + + loading.value = true; + + try { + const data = await apiFetch<{ items: CuttingMaterialCartItem[] }>(draft_combinations.store.url(), { + method: 'POST', + body: JSON.stringify({ + materials: selectedMaterials.value.map((item) => ({ + raw_material_price_id: item.raw_material_price_id, + material_usage: item.material_usage, + })), + }), + }); + + toast.success('Kombinasi bahan baku berhasil ditambahkan.'); + emit('combination-created', data.items); + open.value = false; + resetForm(); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message); + } + } finally { + loading.value = false; + } +} + +watch(open, (value) => { + if (value) { + initFromVariant(); + } else { + resetForm(); + } +}); + + + diff --git a/resources/js/pages/admin/manage/cuttings/form/CuttingPosForm.vue b/resources/js/pages/admin/manage/cuttings/form/CuttingPosForm.vue index 4e95952..54e1102 100644 --- a/resources/js/pages/admin/manage/cuttings/form/CuttingPosForm.vue +++ b/resources/js/pages/admin/manage/cuttings/form/CuttingPosForm.vue @@ -99,6 +99,8 @@ const { decreaseResultQty, syncMaterialField, syncResultField, + syncDraftCombination, + removeCombination, } = useCuttingPosCart({ rawMaterialCatalog: rawMaterialCatalogState, productCatalog: productCatalogState, @@ -151,6 +153,12 @@ function buildFormData(): FormData { `materials[${index}][material_usage]`, item.material_usage, ); + if (item.combination_id) { + formData.append( + `materials[${index}][combination_id]`, + String(item.combination_id), + ); + } }); resultCart.value.forEach((item, index) => { formData.append( @@ -217,6 +225,14 @@ function onRawMaterialCreated(rawMaterial: CuttingRawMaterialCatalogItem) { } } +function onCombinationCreated(items: CuttingMaterialCartItem[]) { + for (const item of items) { + // Combination items use raw_material_price_id + combination_id as unique key + // Same variant can exist in multiple combinations + materialCart.value.push({ ...item }); + } +} + function onProductCreated(product: CuttingProductCatalogItem) { // Add to catalog productCatalogState.value.push(product); @@ -234,9 +250,10 @@ function onProductCreated(product: CuttingProductCatalogItem) {
+ @raw-material-created="onRawMaterialCreated" @combination-created="onCombinationCreated" /> + @sync-result-totals="syncResultTotals" @sync-result-field="syncResultField" + @remove-combination="removeCombination" />
+import { Layers, Minus, Plus, Search } from '@lucide/vue'; import { ref } from 'vue'; -import { Minus, Plus, Search } from '@lucide/vue'; import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue'; import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue'; import { Badge } from '@/components/ui/badge'; @@ -16,11 +16,14 @@ import { Input } from '@/components/ui/input'; import { getFirstCoverImage } from '@/lib/catalog-cover'; import type { CuttingMaterialCartItem, CuttingRawMaterialCatalogItem } from '@/types/cutting'; import type { EnumOption } from '@/types/raw-material'; -import type { CuttingCatalogPrice } from './useCuttingPosCart'; +import CuttingPosCombinationDialog from './CuttingPosCombinationDialog.vue'; import QuickCreateRawMaterialModal from './QuickCreateRawMaterialModal.vue'; +import type { CuttingCatalogPrice } from './useCuttingPosCart'; defineProps<{ filteredRawMaterials: CuttingRawMaterialCatalogItem[]; + rawMaterialCatalog: CuttingRawMaterialCatalogItem[]; + materialCart: CuttingMaterialCartItem[]; getMaterialCartItem: (priceId: number) => CuttingMaterialCartItem | undefined; units: EnumOption[]; }>(); @@ -31,9 +34,20 @@ 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; + price: CuttingCatalogPrice; +} | null>(null); + +function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: CuttingCatalogPrice) { + combinationInitialVariant.value = { rawMaterial, price }; + combinationOpen.value = true; +}