feat: enhance product variant management by adding price fields and functionality for copying, pasting, and applying prices across variants

This commit is contained in:
Yoga Pangestu 2026-07-01 17:28:31 +07:00
parent 5b49bda043
commit 4a647cb3e6
9 changed files with 316 additions and 37 deletions

View File

@ -43,6 +43,15 @@ public function rules(): array
'variants.*.name' => ['required', 'string', 'max:200'],
'variants.*.stock' => ['required', 'integer', 'min:0'],
'variants.*.retail_stock' => ['required', 'integer', 'min:0'],
'variants.*.prices' => ['required', 'array'],
'variants.*.prices.distributor' => ['required', 'integer', 'min:0'],
'variants.*.prices.agent' => ['required', 'integer', 'min:0'],
'variants.*.prices.sub_agent' => ['required', 'integer', 'min:0'],
'variants.*.prices.grosir' => ['required', 'integer', 'min:0'],
'variants.*.prices.retail' => ['required', 'integer', 'min:0'],
'variants.*.prices.tiktok' => ['required', 'integer', 'min:0'],
'variants.*.prices.shopee' => ['required', 'integer', 'min:0'],
'variants.*.prices.harga_modal' => ['required', 'integer', 'min:0'],
...$this->variantImageRules(),
];
}
@ -61,6 +70,15 @@ public function attributes(): array
'variants.*.name' => 'Nama Varian',
'variants.*.stock' => 'Stok',
'variants.*.retail_stock' => 'Stok Ecer',
'variants.*.prices' => 'Harga',
'variants.*.prices.distributor' => 'Distributor',
'variants.*.prices.agent' => 'Agen',
'variants.*.prices.sub_agent' => 'Sub Agen',
'variants.*.prices.grosir' => 'Grosir',
'variants.*.prices.retail' => 'Eceran',
'variants.*.prices.tiktok' => 'TikTok',
'variants.*.prices.shopee' => 'Shopee',
'variants.*.prices.harga_modal' => 'Harga Modal',
...$this->variantImageAttributes('variants', 'Foto Varian'),
];
}

View File

@ -4,13 +4,14 @@
use App\Enums\PriceType;
use App\Models\CuttingResultPrice;
use App\Models\ProductPrice;
use Illuminate\Support\Collection;
class CuttingResultPriceResolver
{
public function resolve(int $productVariantId, PriceType $priceType): ?CuttingResultPrice
{
return CuttingResultPrice::query()
$price = CuttingResultPrice::query()
->where('product_variant_id', $productVariantId)
->where('price_type', $priceType)
->whereHas('cutting', fn ($query) => $query->verified())
@ -18,6 +19,26 @@ public function resolve(int $productVariantId, PriceType $priceType): ?CuttingRe
->orderByDesc('cuttings.created_at')
->select('cutting_result_prices.*')
->first();
if ($price !== null) {
return $price;
}
$productPrice = ProductPrice::query()
->where('variant_id', $productVariantId)
->where('type', $priceType)
->first();
if ($productPrice !== null) {
$cp = new CuttingResultPrice;
$cp->product_variant_id = $productVariantId;
$cp->price_type = $priceType;
$cp->price = $productPrice->price;
return $cp;
}
return null;
}
public function latestPricesForVariant(int $productVariantId): array
@ -41,7 +62,7 @@ public function latestPricesForVariants(array $variantIds): Collection
return collect();
}
return CuttingResultPrice::query()
$cuttingPrices = CuttingResultPrice::query()
->whereIn('product_variant_id', $variantIds)
->whereHas('cutting', fn ($query) => $query->verified())
->join('cuttings', 'cutting_result_prices.cutting_id', '=', 'cuttings.id')
@ -49,8 +70,30 @@ public function latestPricesForVariants(array $variantIds): Collection
->select('cutting_result_prices.*')
->get()
->groupBy(fn (CuttingResultPrice $price) => $price->product_variant_id.'-'.$price->price_type->value)
->map(fn (Collection $group) => $group->first())
->values()
->groupBy('product_variant_id');
->map(fn (Collection $group) => $group->first());
$productPrices = ProductPrice::query()
->whereIn('variant_id', $variantIds)
->get()
->groupBy(fn (ProductPrice $price) => $price->variant_id.'-'.$price->type->value);
$results = collect();
foreach ($variantIds as $variantId) {
foreach (PriceType::cases() as $priceType) {
$key = $variantId.'-'.$priceType->value;
if ($cuttingPrices->has($key)) {
$results->push($cuttingPrices->get($key));
} elseif ($productPrices->has($key)) {
$pp = $productPrices->get($key)->first();
$cp = new CuttingResultPrice;
$cp->product_variant_id = $variantId;
$cp->price_type = $priceType;
$cp->price = $pp->price;
$results->push($cp);
}
}
}
return $results->groupBy('product_variant_id');
}
}

View File

@ -104,7 +104,7 @@ public function findForEdit(Product $product): Product
$product->load([
'categories',
'variants' => fn ($query) => $query
->with('media')
->with(['media', 'prices'])
->orderBy('created_at'),
]);
@ -137,6 +137,13 @@ public function create(array $validated, User $user): void
]);
$this->syncVariantImages($variant, $variantData, $index);
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
}
}
if (! $isOwner) {
@ -191,6 +198,14 @@ public function update(Product $product, array $validated, User $user): void
$variant = $product->variants()->find($variantData['id']);
if ($variant) {
$this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->updateOrCreate(
['type' => $type],
['price' => $priceValue]
);
}
}
}
} else {
$variant = $product->variants()->create([
@ -199,6 +214,14 @@ public function update(Product $product, array $validated, User $user): void
'retail_stock' => $variantData['retail_stock'],
]);
$this->syncVariantImages($variant, $variantData, $index);
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
}
}
}
}
} else {
@ -444,6 +467,15 @@ private function applyPayloadToProduct(
$this->applyVariantImageChanges($verificationRequest, $variant, $variantData, (int) $index);
}
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->updateOrCreate(
['type' => $type],
['price' => $priceValue]
);
}
}
continue;
}
@ -456,6 +488,15 @@ private function applyPayloadToProduct(
if ($verificationRequest !== null) {
$this->copyRequestVariantImages($verificationRequest, (int) $index, $variant);
}
if (! empty($variantData['prices'])) {
foreach ($variantData['prices'] as $type => $priceValue) {
$variant->prices()->create([
'type' => $type,
'price' => $priceValue,
]);
}
}
}
}
@ -584,7 +625,7 @@ private function payloadNew(OwnerVerificationRequest $verificationRequest): arra
private function snapshotProduct(Product $product): array
{
$product->load(['categories', 'variants']);
$product->load(['categories', 'variants.prices']);
return $this->enrichPayload([
'name' => $product->name,
@ -597,6 +638,9 @@ private function snapshotProduct(Product $product): array
'name' => $variant->name,
'stock' => $variant->stock,
'retail_stock' => $variant->retail_stock,
'prices' => $variant->prices
->mapWithKeys(fn ($price) => [$price->type->value => $price->price])
->all(),
])
->all(),
]);
@ -625,6 +669,7 @@ private function buildPayloadFromValidated(array $validated): array
'name' => $variantData['name'],
'stock' => $variantData['stock'],
'retail_stock' => $variantData['retail_stock'],
'prices' => $variantData['prices'] ?? [],
'remove_media_ids' => $variantData['remove_media_ids'] ?? [],
])
->all(),

View File

@ -2,6 +2,8 @@
namespace App\Support\OwnerVerification;
use App\Enums\PriceType;
class VerificationChangeFormatter
{
/**
@ -144,10 +146,22 @@ private static function presentValue(string $field, mixed $value): mixed
if ($field === 'variants' && is_array($value)) {
return collect($value)
->map(fn (array $variant) => [
'name' => $variant['name'] ?? '-',
'stock' => (int) ($variant['stock'] ?? 0),
])
->map(function (array $variant) {
$pricesStr = '';
if (! empty($variant['prices'])) {
$priceParts = [];
foreach ($variant['prices'] as $type => $price) {
$label = PriceType::tryFrom($type)?->label() ?? $type;
$priceParts[] = "{$label}: Rp ".number_format((int) $price, 0, ',', '.');
}
$pricesStr = ' ('.implode(', ', $priceParts).')';
}
return [
'name' => ($variant['name'] ?? '-').$pricesStr,
'stock' => (int) ($variant['stock'] ?? 0),
];
})
->values()
->all();
}

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Head, router } from '@inertiajs/vue3';
import { Banknote, Package, Percent, ShoppingCart, TrendingDown, TrendingUp, UserCheck } from '@lucide/vue';
import { Banknote, Package, Percent, ShoppingCart, TrendingUp, UserCheck } from '@lucide/vue';
import { VisAxis, VisGroupedBar, VisXYContainer } from '@unovis/vue';
import { computed, ref, watch } from 'vue';
import StatCard from '@/components/card/StatCard.vue';
@ -564,7 +564,7 @@ watch([startDate, endDate], () => {
</Card>
<!-- Profit Metrics Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<StatCard v-if="can('analysis.profit_orders')" title="Total Order" :icon="ShoppingCart" main-label="Pesanan Selesai"
:main-value="profitMetrics.total_orders.toLocaleString('id-ID')"
:sub-label="'AOV: Rp' + formatRupiah(profitMetrics.aov)" :items="[
@ -578,19 +578,7 @@ watch([startDate, endDate], () => {
},
]" />
<StatCard v-if="can('analysis.profit_hpp')" title="HPP" :icon="TrendingDown" main-label="Harga Pokok"
:main-value="'Rp' + formatRupiah(profitMetrics.hpp)" sub-label="Total biaya produksi" :items="[
{
label: 'Laba Kotor',
value: 'Rp' + formatRupiah(profitMetrics.laba_kotor),
},
{
label: 'Laba Bersih',
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih),
},
]" />
<StatCard v-if="can('analysis.profit_gross')" title="Laba Kotor" :icon="TrendingUp" main-label="Gross Profit"
<StatCard v-if="can('analysis.profit_gross') || can('analysis.profit_hpp')" title="Laba Kotor & HPP" :icon="TrendingUp" main-label="Gross Profit"
:main-value="'Rp' + formatRupiah(profitMetrics.laba_kotor)"
:sub-label="profitMetrics.laba_kotor >= 0 ? 'Positif' : 'Negatif'" :items="[
{
@ -601,6 +589,10 @@ watch([startDate, endDate], () => {
label: 'HPP',
value: 'Rp' + formatRupiah(profitMetrics.hpp),
},
{
label: 'Laba Bersih',
value: 'Rp' + formatRupiah(profitMetrics.laba_bersih),
},
]" />
<StatCard v-if="can('analysis.profit_margin')" title="Profit Margin" :icon="Percent" main-label="Margin"

View File

@ -22,6 +22,7 @@ const initialData = computed(() => ({
name: variant.name,
stock: variant.stock,
retail_stock: variant.retail_stock,
prices: variant.prices ?? [],
images: variant.images ?? [],
})),
}));

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Plus, Save } from '@lucide/vue';
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -43,6 +43,16 @@ const {
name: '',
stock: '0',
retail_stock: '0',
prices: {
distributor: '0',
agent: '0',
sub_agent: '0',
grosir: '0',
retail: '0',
tiktok: '0',
shopee: '0',
harga_modal: '0',
},
media: createMediaUploadState(),
}),
() => {
@ -52,21 +62,66 @@ const {
name: '',
stock: '0',
retail_stock: '0',
prices: {
distributor: '0',
agent: '0',
sub_agent: '0',
grosir: '0',
retail: '0',
tiktok: '0',
shopee: '0',
harga_modal: '0',
},
media: createMediaUploadState(),
}];
}
return props.initialData.variants.map((variant) => ({
client_id: createClientId(),
id: variant.id,
name: variant.name ?? '',
stock: variant.stock != null ? String(variant.stock) : '0',
retail_stock: variant.retail_stock != null ? String(variant.retail_stock) : '0',
media: createMediaUploadState(variant.images ?? []),
}));
return props.initialData.variants.map((variant) => {
const prices: Record<string, string> = {
distributor: '0',
agent: '0',
sub_agent: '0',
grosir: '0',
retail: '0',
tiktok: '0',
shopee: '0',
harga_modal: '0',
};
variant.prices?.forEach((price) => {
if (price.type) {
prices[price.type] = String(price.price);
}
});
return {
client_id: createClientId(),
id: variant.id,
name: variant.name ?? '',
stock: variant.stock != null ? String(variant.stock) : '0',
retail_stock: variant.retail_stock != null ? String(variant.retail_stock) : '0',
prices,
media: createMediaUploadState(variant.images ?? []),
};
});
},
);
const copiedPrices = ref<Record<string, string> | null>(null);
function copyPrices(variantPrices: Record<string, string>) {
copiedPrices.value = { ...variantPrices };
}
function pastePrices(clientId: string) {
if (!copiedPrices.value) return;
setVariantField(clientId, 'prices', { ...copiedPrices.value });
}
function applyToAllPrices(variantPrices: Record<string, string>) {
variants.value.forEach((v) => {
setVariantField(v.client_id, 'prices', { ...variantPrices });
});
}
const form = useForm({
name: props.initialData?.name ?? '',
description: props.initialData?.description ?? '',
@ -101,6 +156,11 @@ function buildFormData(): FormData {
formData.append(`variants[${index}][name]`, variant.name.trim());
formData.append(`variants[${index}][stock]`, String(Number.parseInt(String(variant.stock), 10) || 0));
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(String(variant.retail_stock), 10) || 0));
if (variant.prices) {
Object.entries(variant.prices as Record<string, string>).forEach(([type, value]) => {
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(value, 10) || 0));
});
}
appendMediaToFormData(formData, `variants[${index}]`, variant.media);
}, props.method);
@ -149,12 +209,17 @@ function submit() {
:variant="variant"
:index="index"
:can-remove="variants.length > 1"
:has-copied-prices="!!copiedPrices"
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
@remove="removeVariant(variant.client_id)"
@update:name="setVariantField(variant.client_id, 'name', $event)"
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
@update:prices="setVariantField(variant.client_id, 'prices', $event)"
@update:media="setVariantField(variant.client_id, 'media', $event)"
@copy-prices="copyPrices(variant.prices as Record<string, string>)"
@paste-prices="pastePrices(variant.client_id)"
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)"
/>
<div class="flex items-center justify-between gap-2">

View File

@ -1,6 +1,8 @@
<script setup lang="ts">
import { Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { Trash2, Copy, Clipboard, Check } from '@lucide/vue';
import { NumberInput } from '@/components/form/number-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -13,16 +15,18 @@ import {
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { FIELD_LIMITS } from '@/lib/field-limits';
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
import type { ProductVariantFormItem } from '@/types/product';
import type { MediaUploadState } from '@/types/media';
defineProps<{
const props = defineProps<{
form: {
errors: Record<string, string>;
};
variant: ProductVariantFormItem;
index: number;
canRemove: boolean;
hasCopiedPrices: boolean;
variantErrors: (clientId: string, field: string) => string[];
}>();
@ -31,8 +35,48 @@ const emit = defineEmits<{
'update:name': [value: string];
'update:stock': [value: string];
'update:retail-stock': [value: string];
'update:prices': [value: Record<string, string>];
'update:media': [value: MediaUploadState];
'copy-prices': [];
'paste-prices': [];
'apply-to-all-prices': [];
}>();
const isCopied = ref(false);
const isPasted = ref(false);
const isApplied = ref(false);
function handleCopy() {
emit('copy-prices');
isCopied.value = true;
setTimeout(() => {
isCopied.value = false;
}, 2000);
}
function handlePaste() {
emit('paste-prices');
isPasted.value = true;
setTimeout(() => {
isPasted.value = false;
}, 2000);
}
function handleApply() {
emit('apply-to-all-prices');
isApplied.value = true;
setTimeout(() => {
isApplied.value = false;
}, 2000);
}
function updatePrice(type: string, value: string) {
const updatedPrices = {
...(props.variant.prices as Record<string, string> || {}),
[type]: value,
};
emit('update:prices', updatedPrices);
}
</script>
<template>
@ -91,6 +135,61 @@ const emit = defineEmits<{
</Field>
</FieldSet>
<div class="border-t pt-4 mt-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-3">
<h4 class="text-sm font-semibold">Harga Varian</h4>
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
@click="handleCopy"
>
<Check v-if="isCopied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Copy v-else class="size-3.5" />
{{ isCopied ? 'Berhasil' : 'Salin Harga' }}
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
:disabled="!hasCopiedPrices"
@click="handlePaste"
>
<Check v-if="isPasted" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Clipboard v-else class="size-3.5" />
{{ isPasted ? 'Berhasil' : 'Tempel Harga' }}
</Button>
<Button
type="button"
variant="outline"
size="sm"
class="h-8 px-2.5 text-xs flex items-center gap-1.5"
@click="handleApply"
>
<Check v-if="isApplied" class="size-3.5 text-green-600 animate-in fade-in zoom-in-50 duration-200" />
<Check v-else class="size-3.5" />
{{ isApplied ? 'Berhasil' : 'Terapkan ke Semua' }}
</Button>
</div>
</div>
<div class="grid gap-3 grid-cols-2 md:grid-cols-4">
<Field v-for="type in PRICE_TYPES" :key="`${variant.client_id}-${type}`">
<FieldLabel class="text-xs" :for="`variant_price_${variant.client_id}_${type}`" required>
{{ PRICE_TYPE_LABELS[type] }}
</FieldLabel>
<RupiahInput
:id="`variant_price_${variant.client_id}_${type}`"
:model-value="(variant.prices as Record<string, string>)?.[type] ?? '0'"
@update:model-value="updatePrice(type, $event)"
/>
<FieldError :errors="variantErrors(variant.client_id, `prices.${type}`)" />
</Field>
</div>
</div>
<div>
<MediaDropzone
:id="`variant_images_${variant.client_id}`"

View File

@ -93,6 +93,7 @@ export interface ProductVariantFormItem {
name: string;
stock: string | number;
retail_stock: string | number;
prices?: Record<string, string>;
media: MediaUploadState;
[key: string]: unknown;
}
@ -106,6 +107,7 @@ export type ProductFormInitialData = {
name?: string;
stock?: number | string;
retail_stock?: number | string;
prices?: Price[];
images?: MediaItem[];
}>;
};