diff --git a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php index 66ab068..71196d2 100644 --- a/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php +++ b/app/Http/Controllers/Admin/Manage/Purchase/PurchaseDraftItemController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest; +use App\Http\Requests\Admin\Manage\PurchaseNewVariantRequest; use App\Models\RawMaterialPrice; use App\Services\Manage\PurchaseService; use Illuminate\Http\JsonResponse; @@ -22,6 +23,13 @@ public function store(PurchaseDraftItemRequest $request): JsonResponse return response()->json(['item' => $item]); } + public function storeNewVariant(PurchaseNewVariantRequest $request): JsonResponse + { + $price = $this->purchaseService->createVariantAndDraft($request->validated(), $request->user()); + + return response()->json(['price' => $price]); + } + public function destroy(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse { $this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice); diff --git a/app/Http/Requests/Admin/Manage/PurchaseNewVariantRequest.php b/app/Http/Requests/Admin/Manage/PurchaseNewVariantRequest.php new file mode 100644 index 0000000..2a75545 --- /dev/null +++ b/app/Http/Requests/Admin/Manage/PurchaseNewVariantRequest.php @@ -0,0 +1,50 @@ +user()?->can(Permission::PURCHASES_CREATE->value) ?? false; + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'raw_material_id' => [ + 'required', + 'integer', + Rule::exists('raw_materials', 'id')->whereNull('deleted_at'), + ], + 'variant' => ['required', 'string', 'max:200'], + 'price' => ['required', 'integer', 'min:0'], + 'stock' => ['nullable', 'numeric', 'decimal:0,4', 'min:0'], + ...$this->photoRules(), + ]; + } + + /** + * @return array + */ + public function attributes(): array + { + return [ + 'raw_material_id' => 'Bahan Baku', + 'variant' => 'Nama Varian', + 'price' => 'Harga', + 'stock' => 'Stok', + ...$this->photoUploadAttributes('Foto Varian'), + ]; + } +} diff --git a/app/Models/CuttingMaterial.php b/app/Models/CuttingMaterial.php index 2446c30..120fbb5 100644 --- a/app/Models/CuttingMaterial.php +++ b/app/Models/CuttingMaterial.php @@ -48,7 +48,8 @@ public function materialUsageInput(): Attribute public function unitAbbreviation(): Attribute { return Attribute::make( - get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), + get: fn () => $this->attributes['unit_abbreviation'] + ?? $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), ); } diff --git a/app/Models/PurchaseItem.php b/app/Models/PurchaseItem.php index b1ce55d..16d3751 100644 --- a/app/Models/PurchaseItem.php +++ b/app/Models/PurchaseItem.php @@ -40,8 +40,10 @@ public function quantityFormatted(): Attribute return Attribute::make( get: function () { $formatted = rtrim(rtrim(number_format((float) $this->quantity, 4, ',', '.'), '0'), ','); + $unitAbbreviation = $this->attributes['unit_abbreviation'] + ?? $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(); - return "{$formatted} {$this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation()}"; + return "{$formatted} {$unitAbbreviation}"; }, ); } @@ -63,7 +65,8 @@ public function subtotalFormatted(): Attribute public function unitAbbreviation(): Attribute { return Attribute::make( - get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), + get: fn () => $this->attributes['unit_abbreviation'] + ?? $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(), ); } diff --git a/app/Services/Manage/CuttingService.php b/app/Services/Manage/CuttingService.php index 38cebd7..67dd216 100644 --- a/app/Services/Manage/CuttingService.php +++ b/app/Services/Manage/CuttingService.php @@ -62,6 +62,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); + $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); return $cutting; }); @@ -84,6 +85,7 @@ public function getInProgressCuttings(User $user): Collection $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); + $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -104,6 +106,7 @@ public function getCompletedCuttings(User $user): Collection $cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user)); $cutting->setAttribute('is_editable', $cutting->status->isEditable()); $this->appendCostPreview($cutting); + $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -188,11 +191,10 @@ public function findForEdit(Cutting $cutting): Cutting $price = $material->rawMaterialPrice; if ($price) { - $price->setAttribute( - 'images', - MediaPresenter::collection($price, 'images'), - ); + $price->setAttribute('images', MediaPresenter::collection($price, 'images')); } + + $this->breakMaterialCircularReference($material); }); $cutting->results->each(function (CuttingResult $result): void { @@ -217,7 +219,12 @@ public function draftMaterialsForUser(User $user): array 'rawMaterialPrice.media', ]) ->get() - ->map(fn (CuttingMaterial $item) => $this->presentDraftMaterial($item)) + ->map(function (CuttingMaterial $item) { + $result = $this->presentDraftMaterial($item); + $this->breakMaterialCircularReference($item); + + return $result; + }) ->values() ->all(); } @@ -265,7 +272,10 @@ public function syncDraftMaterial(array $validated, User $user): array 'rawMaterialPrice.media', ]); - return $this->presentDraftMaterial($item); + $result = $this->presentDraftMaterial($item); + $this->breakMaterialCircularReference($item); + + return $result; } public function syncDraftResult(array $validated, User $user): array @@ -794,6 +804,25 @@ private function storeRejection(Cutting $cutting, ?string $reason, User $user): ]); } + private function breakMaterialCircularReference(CuttingMaterial $material): void + { + $price = $material->rawMaterialPrice; + + if ($price) { + $rawMaterial = $price->rawMaterial; + + if ($rawMaterial) { + $unitAbbreviation = $rawMaterial->unit->abbreviation(); + $price->setAttribute('unit_abbreviation', $unitAbbreviation); + $material->setAttribute('unit_abbreviation', $unitAbbreviation); + } + + $price->unsetRelation('rawMaterial'); + } + + $material->unsetRelation('rawMaterialPrice'); + } + private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'status'], true)) { diff --git a/app/Services/Manage/PurchaseService.php b/app/Services/Manage/PurchaseService.php index 8f8e91c..2e35fc9 100644 --- a/app/Services/Manage/PurchaseService.php +++ b/app/Services/Manage/PurchaseService.php @@ -71,6 +71,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator $purchase->setAttribute('pending_request_action', $pendingRequest?->action->value); $purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label()); + $purchase->items->each(fn (PurchaseItem $item) => $this->breakItemCircularReference($item)); + return $purchase; }); } @@ -136,11 +138,10 @@ public function findForEdit(Purchase $purchase): Purchase $price = $item->rawMaterialPrice; if ($price) { - $price->setAttribute( - 'images', - MediaPresenter::collection($price, 'images'), - ); + $price->setAttribute('images', MediaPresenter::collection($price, 'images')); } + + $this->breakItemCircularReference($item); }); return $purchase; @@ -154,7 +155,12 @@ public function draftItemsForUser(User $user): array 'rawMaterialPrice.media', ]) ->get() - ->map(fn (PurchaseItem $item) => $this->presentDraftItem($item)) + ->map(function (PurchaseItem $item) { + $result = $this->presentDraftItem($item); + $this->breakItemCircularReference($item); + + return $result; + }) ->values() ->all(); } @@ -187,7 +193,48 @@ public function syncDraftItem(array $validated, User $user): array 'rawMaterialPrice.media', ]); - return $this->presentDraftItem($item); + $result = $this->presentDraftItem($item); + $this->breakItemCircularReference($item); + + return $result; + } + + public function createVariantAndDraft(array $validated, User $user): array + { + $rawMaterial = RawMaterial::query()->findOrFail($validated['raw_material_id']); + + $price = RawMaterialPrice::create([ + 'raw_material_id' => $rawMaterial->id, + 'variant' => $validated['variant'], + 'price' => (int) $validated['price'], + 'stock' => (float) ($validated['stock'] ?? 0), + ]); + + if (! empty($validated['photos'])) { + $this->mediaService->syncCollection( + $price, + 'images', + $validated['photos'], + null, + 5, + required: false, + errorKey: 'photos', + ); + } + + $price->setAttribute('images', MediaPresenter::collection($price, 'images')); + + return [ + 'id' => $price->id, + 'variant' => $price->variant, + 'price' => $price->price, + 'price_formatted' => $price->price_formatted, + 'price_input' => $price->price_input, + 'stock' => $price->stock, + 'stock_formatted' => $price->stock_formatted, + 'stock_input' => $price->stock_input, + 'images' => $price->images, + ]; } public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void @@ -741,6 +788,25 @@ private function payloadNew(OwnerVerificationRequest $verificationRequest): arra return is_array($payload) ? $payload : []; } + private function breakItemCircularReference(PurchaseItem $item): void + { + $price = $item->rawMaterialPrice; + + if ($price) { + $rawMaterial = $price->rawMaterial; + + if ($rawMaterial) { + $unitAbbreviation = $rawMaterial->unit->abbreviation(); + $price->setAttribute('unit_abbreviation', $unitAbbreviation); + $item->setAttribute('unit_abbreviation', $unitAbbreviation); + } + + $price->unsetRelation('rawMaterial'); + } + + $item->unsetRelation('rawMaterialPrice'); + } + private function applySorting(Builder $query, string $sort, string $direction): void { if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) { diff --git a/app/Services/Manage/StockService.php b/app/Services/Manage/StockService.php index 7109d90..7793c7f 100644 --- a/app/Services/Manage/StockService.php +++ b/app/Services/Manage/StockService.php @@ -4,9 +4,11 @@ use App\Enums\CuttingStatus; use App\Models\Cutting; +use App\Models\CuttingMaterial; use App\Models\CuttingResultPrice; use App\Models\ProductPrice; use App\Models\ProductVariant; +use App\Models\RawMaterialPrice; use App\Models\User; use App\Services\System\PushNotificationService; use Illuminate\Support\Collection; @@ -35,6 +37,7 @@ public function getPendingVerificationCuttings(User $user): Collection ->get() ->each(function (Cutting $cutting): void { $this->appendCostPreview($cutting); + $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -55,6 +58,7 @@ public function getPendingApprovalCuttings(User $user): Collection ->get() ->each(function (Cutting $cutting): void { $this->appendCostPreview($cutting); + $cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m)); }); } @@ -223,6 +227,25 @@ public function rejectVerification( } } + private function breakMaterialCircularReference(CuttingMaterial $material): void + { + $price = $material->rawMaterialPrice; + + if ($price) { + $rawMaterial = $price->rawMaterial; + + if ($rawMaterial) { + $unitAbbreviation = $rawMaterial->unit->abbreviation(); + $price->setAttribute('unit_abbreviation', $unitAbbreviation); + $material->setAttribute('unit_abbreviation', $unitAbbreviation); + } + + $price->unsetRelation('rawMaterial'); + } + + $material->unsetRelation('rawMaterialPrice'); + } + private function applyProductStockOnVerify(Cutting $cutting): void { foreach ($cutting->results as $result) { diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue new file mode 100644 index 0000000..fdc1457 --- /dev/null +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosAddVariantDialog.vue @@ -0,0 +1,173 @@ + + + diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosCatalogPanel.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosCatalogPanel.vue index ea310cb..bc865b9 100644 --- a/resources/js/pages/admin/manage/purchases/form/PurchasePosCatalogPanel.vue +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosCatalogPanel.vue @@ -1,5 +1,5 @@ @@ -64,6 +65,19 @@ const emit = defineEmits<{ +
+ +
+

Belum ada varian

diff --git a/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue b/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue index 0aa647e..e3b907d 100644 --- a/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue +++ b/resources/js/pages/admin/manage/purchases/form/PurchasePosForm.vue @@ -12,6 +12,8 @@ import { parseRupiah } from '@/lib/rupiah'; import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media'; import type { MediaItem, MediaUploadState } from '@/types/media'; import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase'; +import type { RawMaterialPrice } from '@/types/raw-material'; +import PurchasePosAddVariantDialog from './PurchasePosAddVariantDialog.vue'; import PurchasePosCartDetailDialog from './PurchasePosCartDetailDialog.vue'; import PurchasePosCartSummaryItems from './PurchasePosCartSummaryItems.vue'; import PurchasePosCatalogPanel from './PurchasePosCatalogPanel.vue'; @@ -38,6 +40,8 @@ const props = defineProps<{ const isCreateMode = computed(() => props.method === 'post'); const cartDetailOpen = ref(false); +const addVariantOpen = ref(false); +const addVariantRawMaterial = ref(null); const photoState = ref(createMediaUploadState()); const form = useForm({ @@ -88,6 +92,19 @@ function populateForm() { watch(() => props.initialData, populateForm, { immediate: true }); loadDraftItems(props.draftItems ?? []); +function openAddVariant(rawMaterial: PurchaseCatalogItem) { + addVariantRawMaterial.value = rawMaterial; + addVariantOpen.value = true; +} + +function handleVariantAdded(rawMaterialId: number, newPrice: RawMaterialPrice) { + const rawMaterial = props.catalog.find((rm) => rm.id === rawMaterialId); + + if (rawMaterial) { + rawMaterial.prices.push(newPrice); + } +} + function buildFormData(): FormData { const formData = new FormData(); @@ -146,6 +163,7 @@ function submit() { :get-cart-item="getCartItem" @add-to-cart="addToCart" @decrease-qty="decreasePriceQty" + @add-variant="openAddVariant" /> @@ -194,6 +212,12 @@ function submit() { + + middleware('permission:'.Permission::PURCHASES_CREATE->value) ->name('draft_items.store'); + Route::post('draft-items/new-variant', [PurchaseDraftItemController::class, 'storeNewVariant']) + ->middleware('permission:'.Permission::PURCHASES_CREATE->value) + ->name('draft_items.store_new_variant'); + Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy']) ->middleware('permission:'.Permission::PURCHASES_CREATE->value) ->name('draft_items.destroy');