feat: enhance product variant management by adding price fields and functionality for copying, pasting, and applying prices across variants
This commit is contained in:
parent
5b49bda043
commit
4a647cb3e6
@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -22,6 +22,7 @@ const initialData = computed(() => ({
|
||||
name: variant.name,
|
||||
stock: variant.stock,
|
||||
retail_stock: variant.retail_stock,
|
||||
prices: variant.prices ?? [],
|
||||
images: variant.images ?? [],
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}`"
|
||||
|
||||
@ -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[];
|
||||
}>;
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user