feat: add functionality to create new purchase variants and update related UI components for variant management

This commit is contained in:
Yoga Pangestu 2026-06-28 23:41:30 +07:00
parent f20889e962
commit 8da733ec08
12 changed files with 412 additions and 16 deletions

View File

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

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\Admin\Manage;
use App\Enums\Permission;
use App\Http\Requests\Concerns\ValidatesMediaUploads;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PurchaseNewVariantRequest extends FormRequest
{
use ValidatesMediaUploads;
public function authorize(): bool
{
return $this->user()?->can(Permission::PURCHASES_CREATE->value) ?? false;
}
/**
* @return array<string, mixed>
*/
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<string, string>
*/
public function attributes(): array
{
return [
'raw_material_id' => 'Bahan Baku',
'variant' => 'Nama Varian',
'price' => 'Harga',
'stock' => 'Stok',
...$this->photoUploadAttributes('Foto Varian'),
];
}
}

View File

@ -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(),
);
}

View File

@ -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(),
);
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,173 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldDescription, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { parseRupiah } from '@/lib/rupiah';
import { store_new_variant as storeNewVariantRoute } from '@/routes/admin/manage/purchases/draft_items';
import { createMediaUploadState } from '@/types/media';
import type { MediaUploadState } from '@/types/media';
import type { PurchaseCatalogItem } from '@/types/purchase';
import type { RawMaterialPrice } from '@/types/raw-material';
const props = defineProps<{
rawMaterial: PurchaseCatalogItem | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
'variant-added': [rawMaterialId: number, price: RawMaterialPrice];
}>();
const variant = ref('');
const price = ref('');
const stock = ref('0');
const media = ref<MediaUploadState>(createMediaUploadState());
const loading = ref(false);
watch(open, (isOpen) => {
if (isOpen) {
variant.value = '';
price.value = '';
stock.value = '0';
media.value = createMediaUploadState();
}
});
function parseStockValue(value: string): number {
const parsed = Number.parseFloat(value.replace(',', '.'));
return Number.isNaN(parsed) ? 0 : parsed;
}
function getXsrfToken(): string {
const match = document.cookie
.split('; ')
.find((row) => row.startsWith('XSRF-TOKEN='));
return match ? decodeURIComponent(match.split('=')[1] ?? '') : '';
}
async function submit() {
if (!props.rawMaterial) {
return;
}
loading.value = true;
try {
const formData = new FormData();
formData.append('raw_material_id', String(props.rawMaterial.id));
formData.append('variant', variant.value.trim());
formData.append('price', String(Number.parseInt(parseRupiah(price.value), 10) || 0));
formData.append('stock', String(parseStockValue(stock.value)));
media.value.newFiles.forEach((file) => {
formData.append('photos[]', file);
});
const response = await fetch(storeNewVariantRoute.url(), {
method: 'POST',
body: formData,
headers: {
Accept: 'application/json',
'X-XSRF-TOKEN': getXsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
credentials: 'same-origin',
});
if (!response.ok) {
const contentType = response.headers.get('content-type') ?? '';
const isJson = contentType.includes('application/json');
if (isJson) {
const error = (await response.json().catch(() => ({}))) as {
message?: string;
errors?: Record<string, string[]>;
};
const firstValidationError = error.errors
? Object.values(error.errors).flat()[0]
: undefined;
throw new Error(firstValidationError ?? error.message ?? 'Permintaan gagal diproses.');
}
throw new Error(`Permintaan gagal diproses (HTTP ${response.status}).`);
}
const data = (await response.json()) as { price: RawMaterialPrice };
emit('variant-added', props.rawMaterial.id, data.price);
open.value = false;
toast.success('Varian baru berhasil ditambahkan.');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal menambahkan varian baru.');
} finally {
loading.value = false;
}
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Tambah Varian Baru</DialogTitle>
</DialogHeader>
<p v-if="rawMaterial" class="text-sm text-muted-foreground">
Bahan baku: <span class="font-medium text-foreground">{{ rawMaterial.name }}</span>
</p>
<form class="space-y-4" @submit.prevent="submit">
<Field>
<FieldLabel for="new_variant_name" required>Nama Varian</FieldLabel>
<Input id="new_variant_name" v-model="variant" type="text" placeholder="Contoh: Premium / 40s"
:maxlength="FIELD_LIMITS.variantName" />
</Field>
<div class="grid gap-4 sm:grid-cols-2">
<Field>
<FieldLabel for="new_variant_stock" required>Stok</FieldLabel>
<DecimalInput id="new_variant_stock" v-model="stock" />
<FieldDescription>Stok awal varian ini.</FieldDescription>
</Field>
<Field>
<FieldLabel for="new_variant_price" required>Harga</FieldLabel>
<RupiahInput id="new_variant_price" v-model="price" />
<FieldDescription>Harga per satuan bahan baku.</FieldDescription>
</Field>
</div>
<MediaDropzone id="new_variant_images" v-model="media" label="Foto Varian" :max-files="5"
:required="false" />
<DialogFooter>
<Button type="button" variant="outline" @click="open = false">
Batal
</Button>
<Button type="submit" :disabled="loading || !variant || !price">
{{ loading ? 'Menyimpan...' : 'Tambah Varian' }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { Minus, Plus, Search } from '@lucide/vue';
import { Minus, Plus, Search, PlusCircle } from '@lucide/vue';
import PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
@ -26,6 +26,7 @@ const search = defineModel<string>('search', { required: true });
const emit = defineEmits<{
'add-to-cart': [rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice];
'decrease-qty': [priceId: number];
'add-variant': [rawMaterial: PurchaseCatalogItem];
}>();
</script>
@ -64,6 +65,19 @@ const emit = defineEmits<{
</Badge>
</template>
<div class="px-3 pt-1 pb-1.5">
<Button
type="button"
variant="ghost"
size="sm"
class="w-full text-xs text-muted-foreground hover:text-primary"
@click.stop="emit('add-variant', rawMaterial)"
>
<PlusCircle class="size-3.5" />
Tambah Varian
</Button>
</div>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>

View File

@ -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<PurchaseCatalogItem | null>(null);
const photoState = ref<MediaUploadState>(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"
/>
<Card class="h-fit xl:sticky xl:top-4">
@ -194,6 +212,12 @@ function submit() {
</Card>
</div>
<PurchasePosAddVariantDialog
v-model:open="addVariantOpen"
:raw-material="addVariantRawMaterial"
@variant-added="handleVariantAdded"
/>
<PurchasePosCartDetailDialog
v-model:open="cartDetailOpen"
:cart="cart"

View File

@ -202,5 +202,6 @@ export function usePurchasePosCart(options: {
adjustQuantity,
syncCartItemQuantity,
decreasePriceQty,
upsertCartItem,
};
}

View File

@ -227,6 +227,10 @@
->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');