feat: add functionality to create new purchase variants and update related UI components for variant management
This commit is contained in:
parent
f20889e962
commit
8da733ec08
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest;
|
use App\Http\Requests\Admin\Manage\PurchaseDraftItemRequest;
|
||||||
|
use App\Http\Requests\Admin\Manage\PurchaseNewVariantRequest;
|
||||||
use App\Models\RawMaterialPrice;
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Services\Manage\PurchaseService;
|
use App\Services\Manage\PurchaseService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@ -22,6 +23,13 @@ public function store(PurchaseDraftItemRequest $request): JsonResponse
|
|||||||
return response()->json(['item' => $item]);
|
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
|
public function destroy(Request $request, RawMaterialPrice $rawMaterialPrice): JsonResponse
|
||||||
{
|
{
|
||||||
$this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice);
|
$this->purchaseService->removeDraftItem($request->user(), $rawMaterialPrice);
|
||||||
|
|||||||
50
app/Http/Requests/Admin/Manage/PurchaseNewVariantRequest.php
Normal file
50
app/Http/Requests/Admin/Manage/PurchaseNewVariantRequest.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,7 +48,8 @@ public function materialUsageInput(): Attribute
|
|||||||
public function unitAbbreviation(): Attribute
|
public function unitAbbreviation(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
|
get: fn () => $this->attributes['unit_abbreviation']
|
||||||
|
?? $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,8 +40,10 @@ public function quantityFormatted(): Attribute
|
|||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: function () {
|
get: function () {
|
||||||
$formatted = rtrim(rtrim(number_format((float) $this->quantity, 4, ',', '.'), '0'), ',');
|
$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
|
public function unitAbbreviation(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
get: fn () => $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
|
get: fn () => $this->attributes['unit_abbreviation']
|
||||||
|
?? $this->rawMaterialPrice?->rawMaterial?->unit?->abbreviation(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -62,6 +62,7 @@ public function paginateForIndex(array $tableQuery, User $user): LengthAwarePagi
|
|||||||
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
|
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
|
||||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||||
$this->appendCostPreview($cutting);
|
$this->appendCostPreview($cutting);
|
||||||
|
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
|
||||||
|
|
||||||
return $cutting;
|
return $cutting;
|
||||||
});
|
});
|
||||||
@ -84,6 +85,7 @@ public function getInProgressCuttings(User $user): Collection
|
|||||||
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
|
$cutting->setAttribute('available_actions', $this->filterActionsForUser($cutting, $user));
|
||||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||||
$this->appendCostPreview($cutting);
|
$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('available_actions', $this->filterActionsForUser($cutting, $user));
|
||||||
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
$cutting->setAttribute('is_editable', $cutting->status->isEditable());
|
||||||
$this->appendCostPreview($cutting);
|
$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;
|
$price = $material->rawMaterialPrice;
|
||||||
|
|
||||||
if ($price) {
|
if ($price) {
|
||||||
$price->setAttribute(
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
'images',
|
|
||||||
MediaPresenter::collection($price, 'images'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->breakMaterialCircularReference($material);
|
||||||
});
|
});
|
||||||
|
|
||||||
$cutting->results->each(function (CuttingResult $result): void {
|
$cutting->results->each(function (CuttingResult $result): void {
|
||||||
@ -217,7 +219,12 @@ public function draftMaterialsForUser(User $user): array
|
|||||||
'rawMaterialPrice.media',
|
'rawMaterialPrice.media',
|
||||||
])
|
])
|
||||||
->get()
|
->get()
|
||||||
->map(fn (CuttingMaterial $item) => $this->presentDraftMaterial($item))
|
->map(function (CuttingMaterial $item) {
|
||||||
|
$result = $this->presentDraftMaterial($item);
|
||||||
|
$this->breakMaterialCircularReference($item);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
})
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -265,7 +272,10 @@ public function syncDraftMaterial(array $validated, User $user): array
|
|||||||
'rawMaterialPrice.media',
|
'rawMaterialPrice.media',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->presentDraftMaterial($item);
|
$result = $this->presentDraftMaterial($item);
|
||||||
|
$this->breakMaterialCircularReference($item);
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function syncDraftResult(array $validated, User $user): array
|
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
|
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||||
{
|
{
|
||||||
if (in_array($sort, ['created_at', 'status'], true)) {
|
if (in_array($sort, ['created_at', 'status'], true)) {
|
||||||
|
|||||||
@ -71,6 +71,8 @@ public function paginateForIndex(array $tableQuery): LengthAwarePaginator
|
|||||||
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
$purchase->setAttribute('pending_request_action', $pendingRequest?->action->value);
|
||||||
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
$purchase->setAttribute('pending_request_action_label', $pendingRequest?->action->label());
|
||||||
|
|
||||||
|
$purchase->items->each(fn (PurchaseItem $item) => $this->breakItemCircularReference($item));
|
||||||
|
|
||||||
return $purchase;
|
return $purchase;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -136,11 +138,10 @@ public function findForEdit(Purchase $purchase): Purchase
|
|||||||
$price = $item->rawMaterialPrice;
|
$price = $item->rawMaterialPrice;
|
||||||
|
|
||||||
if ($price) {
|
if ($price) {
|
||||||
$price->setAttribute(
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
'images',
|
|
||||||
MediaPresenter::collection($price, 'images'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->breakItemCircularReference($item);
|
||||||
});
|
});
|
||||||
|
|
||||||
return $purchase;
|
return $purchase;
|
||||||
@ -154,7 +155,12 @@ public function draftItemsForUser(User $user): array
|
|||||||
'rawMaterialPrice.media',
|
'rawMaterialPrice.media',
|
||||||
])
|
])
|
||||||
->get()
|
->get()
|
||||||
->map(fn (PurchaseItem $item) => $this->presentDraftItem($item))
|
->map(function (PurchaseItem $item) {
|
||||||
|
$result = $this->presentDraftItem($item);
|
||||||
|
$this->breakItemCircularReference($item);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
})
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -187,7 +193,48 @@ public function syncDraftItem(array $validated, User $user): array
|
|||||||
'rawMaterialPrice.media',
|
'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
|
public function removeDraftItem(User $user, RawMaterialPrice $rawMaterialPrice): void
|
||||||
@ -741,6 +788,25 @@ private function payloadNew(OwnerVerificationRequest $verificationRequest): arra
|
|||||||
return is_array($payload) ? $payload : [];
|
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
|
private function applySorting(Builder $query, string $sort, string $direction): void
|
||||||
{
|
{
|
||||||
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {
|
if (in_array($sort, ['created_at', 'total', 'discount', 'subtotal'], true)) {
|
||||||
|
|||||||
@ -4,9 +4,11 @@
|
|||||||
|
|
||||||
use App\Enums\CuttingStatus;
|
use App\Enums\CuttingStatus;
|
||||||
use App\Models\Cutting;
|
use App\Models\Cutting;
|
||||||
|
use App\Models\CuttingMaterial;
|
||||||
use App\Models\CuttingResultPrice;
|
use App\Models\CuttingResultPrice;
|
||||||
use App\Models\ProductPrice;
|
use App\Models\ProductPrice;
|
||||||
use App\Models\ProductVariant;
|
use App\Models\ProductVariant;
|
||||||
|
use App\Models\RawMaterialPrice;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\System\PushNotificationService;
|
use App\Services\System\PushNotificationService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@ -35,6 +37,7 @@ public function getPendingVerificationCuttings(User $user): Collection
|
|||||||
->get()
|
->get()
|
||||||
->each(function (Cutting $cutting): void {
|
->each(function (Cutting $cutting): void {
|
||||||
$this->appendCostPreview($cutting);
|
$this->appendCostPreview($cutting);
|
||||||
|
$cutting->materials->each(fn (CuttingMaterial $m) => $this->breakMaterialCircularReference($m));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,6 +58,7 @@ public function getPendingApprovalCuttings(User $user): Collection
|
|||||||
->get()
|
->get()
|
||||||
->each(function (Cutting $cutting): void {
|
->each(function (Cutting $cutting): void {
|
||||||
$this->appendCostPreview($cutting);
|
$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
|
private function applyProductStockOnVerify(Cutting $cutting): void
|
||||||
{
|
{
|
||||||
foreach ($cutting->results as $result) {
|
foreach ($cutting->results as $result) {
|
||||||
|
|||||||
@ -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>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<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 PosCatalogCard from '@/components/catalog/PosCatalogCard.vue';
|
||||||
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
import PosCatalogVariantThumb from '@/components/catalog/PosCatalogVariantThumb.vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -26,6 +26,7 @@ const search = defineModel<string>('search', { required: true });
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'add-to-cart': [rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice];
|
'add-to-cart': [rawMaterial: PurchaseCatalogItem, price: PurchaseCatalogPrice];
|
||||||
'decrease-qty': [priceId: number];
|
'decrease-qty': [priceId: number];
|
||||||
|
'add-variant': [rawMaterial: PurchaseCatalogItem];
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -64,6 +65,19 @@ const emit = defineEmits<{
|
|||||||
</Badge>
|
</Badge>
|
||||||
</template>
|
</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">
|
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import { parseRupiah } from '@/lib/rupiah';
|
|||||||
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
|
||||||
import type { MediaItem, MediaUploadState } from '@/types/media';
|
import type { MediaItem, MediaUploadState } from '@/types/media';
|
||||||
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
|
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 PurchasePosCartDetailDialog from './PurchasePosCartDetailDialog.vue';
|
||||||
import PurchasePosCartSummaryItems from './PurchasePosCartSummaryItems.vue';
|
import PurchasePosCartSummaryItems from './PurchasePosCartSummaryItems.vue';
|
||||||
import PurchasePosCatalogPanel from './PurchasePosCatalogPanel.vue';
|
import PurchasePosCatalogPanel from './PurchasePosCatalogPanel.vue';
|
||||||
@ -38,6 +40,8 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const isCreateMode = computed(() => props.method === 'post');
|
const isCreateMode = computed(() => props.method === 'post');
|
||||||
const cartDetailOpen = ref(false);
|
const cartDetailOpen = ref(false);
|
||||||
|
const addVariantOpen = ref(false);
|
||||||
|
const addVariantRawMaterial = ref<PurchaseCatalogItem | null>(null);
|
||||||
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
const photoState = ref<MediaUploadState>(createMediaUploadState());
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
@ -88,6 +92,19 @@ function populateForm() {
|
|||||||
watch(() => props.initialData, populateForm, { immediate: true });
|
watch(() => props.initialData, populateForm, { immediate: true });
|
||||||
loadDraftItems(props.draftItems ?? []);
|
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 {
|
function buildFormData(): FormData {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
||||||
@ -146,6 +163,7 @@ function submit() {
|
|||||||
:get-cart-item="getCartItem"
|
:get-cart-item="getCartItem"
|
||||||
@add-to-cart="addToCart"
|
@add-to-cart="addToCart"
|
||||||
@decrease-qty="decreasePriceQty"
|
@decrease-qty="decreasePriceQty"
|
||||||
|
@add-variant="openAddVariant"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Card class="h-fit xl:sticky xl:top-4">
|
<Card class="h-fit xl:sticky xl:top-4">
|
||||||
@ -194,6 +212,12 @@ function submit() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PurchasePosAddVariantDialog
|
||||||
|
v-model:open="addVariantOpen"
|
||||||
|
:raw-material="addVariantRawMaterial"
|
||||||
|
@variant-added="handleVariantAdded"
|
||||||
|
/>
|
||||||
|
|
||||||
<PurchasePosCartDetailDialog
|
<PurchasePosCartDetailDialog
|
||||||
v-model:open="cartDetailOpen"
|
v-model:open="cartDetailOpen"
|
||||||
:cart="cart"
|
:cart="cart"
|
||||||
|
|||||||
@ -202,5 +202,6 @@ export function usePurchasePosCart(options: {
|
|||||||
adjustQuantity,
|
adjustQuantity,
|
||||||
syncCartItemQuantity,
|
syncCartItemQuantity,
|
||||||
decreasePriceQty,
|
decreasePriceQty,
|
||||||
|
upsertCartItem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -227,6 +227,10 @@
|
|||||||
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
|
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
|
||||||
->name('draft_items.store');
|
->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'])
|
Route::delete('draft-items/{rawMaterialPrice}', [PurchaseDraftItemController::class, 'destroy'])
|
||||||
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
|
->middleware('permission:'.Permission::PURCHASES_CREATE->value)
|
||||||
->name('draft_items.destroy');
|
->name('draft_items.destroy');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user