Compare commits
No commits in common. "814814f1a5d3d9ced9d6fe6d364063422eafb1b6" and "8c3847ade3086c595f1687b7c80241aeeb673f59" have entirely different histories.
814814f1a5
...
8c3847ade3
@ -275,7 +275,6 @@ public function permissions(): array
|
|||||||
Permission::RAW_MATERIALS_CREATE,
|
Permission::RAW_MATERIALS_CREATE,
|
||||||
Permission::RAW_MATERIALS_UPDATE,
|
Permission::RAW_MATERIALS_UPDATE,
|
||||||
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
Permission::RAW_MATERIALS_TOGGLE_STATUS,
|
||||||
Permission::RAW_MATERIALS_DELETE,
|
|
||||||
|
|
||||||
Permission::OWNER_VERIFICATIONS_VIEW,
|
Permission::OWNER_VERIFICATIONS_VIEW,
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Admin\Master;
|
namespace App\Http\Controllers\Admin\Master;
|
||||||
|
|
||||||
use App\Enums\Permission;
|
|
||||||
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
use App\Http\Controllers\Concerns\FlashesEntityMessage;
|
||||||
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@ -11,8 +10,6 @@
|
|||||||
use App\Models\Product;
|
use App\Models\Product;
|
||||||
use App\Services\Master\CategoryService;
|
use App\Services\Master\CategoryService;
|
||||||
use App\Services\Master\ProductService;
|
use App\Services\Master\ProductService;
|
||||||
use App\Support\Media\MediaPresenter;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
@ -56,11 +53,7 @@ public function store(ProductRequest $request): RedirectResponse
|
|||||||
{
|
{
|
||||||
$this->productService->create($request->validated(), $request->user());
|
$this->productService->create($request->validated(), $request->user());
|
||||||
|
|
||||||
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
$this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
$this->flashCreated('Produk');
|
|
||||||
} else {
|
|
||||||
$this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('admin.master.products.index');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
@ -77,11 +70,7 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
|
|||||||
{
|
{
|
||||||
$this->productService->update($product, $request->validated(), $request->user());
|
$this->productService->update($product, $request->validated(), $request->user());
|
||||||
|
|
||||||
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
$this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
$this->flashUpdated('Produk');
|
|
||||||
} else {
|
|
||||||
$this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('admin.master.products.index');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
@ -90,11 +79,7 @@ public function destroy(Request $request, Product $product): RedirectResponse
|
|||||||
{
|
{
|
||||||
$this->productService->delete($product, $request->user());
|
$this->productService->delete($product, $request->user());
|
||||||
|
|
||||||
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
$this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
$this->flashDeleted('Produk');
|
|
||||||
} else {
|
|
||||||
$this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect()->route('admin.master.products.index');
|
return redirect()->route('admin.master.products.index');
|
||||||
}
|
}
|
||||||
@ -103,28 +88,8 @@ public function toggleStatus(ToggleStatusRequest $request, Product $product): Re
|
|||||||
{
|
{
|
||||||
$this->productService->toggleStatus($product, $request->validated(), $request->user());
|
$this->productService->toggleStatus($product, $request->validated(), $request->user());
|
||||||
|
|
||||||
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
$this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.');
|
||||||
$this->flashStatusUpdated('produk');
|
|
||||||
} else {
|
|
||||||
$this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(Product $product): JsonResponse
|
|
||||||
{
|
|
||||||
$product->load([
|
|
||||||
'categories',
|
|
||||||
'variants' => fn ($query) => $query
|
|
||||||
->with(['media', 'prices'])
|
|
||||||
->orderBy('created_at'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
$product->variants->each(function ($variant): void {
|
|
||||||
$variant->setAttribute('images', MediaPresenter::collection($variant, 'images'));
|
|
||||||
});
|
|
||||||
|
|
||||||
return response()->json($product);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -246,58 +246,13 @@ function () use ($validated, $product, $user, $isOwner): void {
|
|||||||
$this->cacheForget('homepage:page_data');
|
$this->cacheForget('homepage:page_data');
|
||||||
|
|
||||||
if (! $isOwner) {
|
if (! $isOwner) {
|
||||||
$changedVariants = [];
|
$this->notifyForPendingRequest(
|
||||||
foreach ($validated['variants'] as $variantData) {
|
$user,
|
||||||
if (! empty($variantData['id'])) {
|
'Ubah Produk',
|
||||||
$originalVariant = $product->variants->firstWhere('id', $variantData['id']);
|
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
|
||||||
if ($originalVariant) {
|
route('admin.master.products.index', ['search' => $product->name]),
|
||||||
$isChanged = false;
|
$product->name,
|
||||||
if ($originalVariant->name !== $variantData['name']) {
|
);
|
||||||
$isChanged = true;
|
|
||||||
}
|
|
||||||
if (rtrim(rtrim(number_format((float) $originalVariant->stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $variantData['stock'], 4, '.', ''), '0'), '.')) {
|
|
||||||
$isChanged = true;
|
|
||||||
}
|
|
||||||
if (rtrim(rtrim(number_format((float) $originalVariant->retail_stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $variantData['retail_stock'], 4, '.', ''), '0'), '.')) {
|
|
||||||
$isChanged = true;
|
|
||||||
}
|
|
||||||
if (! empty($variantData['prices'])) {
|
|
||||||
foreach ($variantData['prices'] as $type => $priceValue) {
|
|
||||||
$originalPrice = $originalVariant->prices->firstWhere('type', $type);
|
|
||||||
if (! $originalPrice || $originalPrice->price !== (int) $priceValue) {
|
|
||||||
$isChanged = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($isChanged) {
|
|
||||||
$changedVariants[] = $variantData['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$changedVariants[] = $variantData['name'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! empty($changedVariants)) {
|
|
||||||
$variantsStr = implode(', ', $changedVariants);
|
|
||||||
$this->notifyForPendingRequest(
|
|
||||||
$user,
|
|
||||||
'Ubah Varian Produk',
|
|
||||||
"Pengajuan ubah varian '{$variantsStr}' pada produk '{$product->name}' menunggu verifikasi owner.",
|
|
||||||
route('admin.master.products.index', ['search' => $product->name]),
|
|
||||||
$product->name,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$this->notifyForPendingRequest(
|
|
||||||
$user,
|
|
||||||
'Ubah Produk',
|
|
||||||
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
|
|
||||||
route('admin.master.products.index', ['search' => $product->name]),
|
|
||||||
$product->name,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Plus, Save } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useCan } from '@/composables/useCan';
|
import { useCan } from '@/composables/useCan';
|
||||||
@ -13,7 +12,6 @@ import type { CategoryOption, ProductFormInitialData, ProductVariantFormItem } f
|
|||||||
import ProductInfoSection from './ProductInfoSection.vue';
|
import ProductInfoSection from './ProductInfoSection.vue';
|
||||||
import ProductVariantSection from './ProductVariantSection.vue';
|
import ProductVariantSection from './ProductVariantSection.vue';
|
||||||
|
|
||||||
|
|
||||||
const { hasRole } = useCan();
|
const { hasRole } = useCan();
|
||||||
const showPrices = !hasRole('admin-bahan-baku');
|
const showPrices = !hasRole('admin-bahan-baku');
|
||||||
|
|
||||||
@ -35,7 +33,7 @@ function createClientId(): string {
|
|||||||
return `variant-${crypto.randomUUID()}`;
|
return `variant-${crypto.randomUUID()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isUploading = computed(() =>
|
const isUploading = computed(() =>
|
||||||
variants.value.some((v) => v.media.pendingUploads > 0)
|
variants.value.some((v) => v.media.pendingUploads > 0)
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -124,8 +122,8 @@ function copyPrices(variantPrices: Record<string, string>) {
|
|||||||
|
|
||||||
function pastePrices(clientId: string) {
|
function pastePrices(clientId: string) {
|
||||||
if (!copiedPrices.value) {
|
if (!copiedPrices.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setVariantField(clientId, 'prices', { ...copiedPrices.value });
|
setVariantField(clientId, 'prices', { ...copiedPrices.value });
|
||||||
}
|
}
|
||||||
@ -136,23 +134,6 @@ function applyToAllPrices(variantPrices: Record<string, string>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const showDeleteConfirm = ref(false);
|
|
||||||
const variantToDelete = ref<string | null>(null);
|
|
||||||
|
|
||||||
function confirmRemoveVariant(clientId: string) {
|
|
||||||
variantToDelete.value = clientId;
|
|
||||||
showDeleteConfirm.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveVariant() {
|
|
||||||
if (variantToDelete.value) {
|
|
||||||
removeVariant(variantToDelete.value);
|
|
||||||
variantToDelete.value = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
showDeleteConfirm.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
description: props.initialData?.description ?? '',
|
description: props.initialData?.description ?? '',
|
||||||
@ -224,16 +205,27 @@ function submit() {
|
|||||||
<CardTitle>Informasi Produk</CardTitle>
|
<CardTitle>Informasi Produk</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<ProductInfoSection :form="form" :categories="categories" :category-error="categoryError"
|
<ProductInfoSection
|
||||||
@update:name="form.name = $event" @update:description="form.description = $event"
|
:form="form"
|
||||||
@toggle-category="toggleCategory" />
|
:categories="categories"
|
||||||
|
:category-error="categoryError"
|
||||||
|
@update:name="form.name = $event"
|
||||||
|
@update:description="form.description = $event"
|
||||||
|
@toggle-category="toggleCategory"
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<ProductVariantSection v-for="(variant, index) in variants" :key="variant.client_id" :form="form"
|
<ProductVariantSection
|
||||||
:variant="variant" :index="index" :can-remove="variants.length > 1" :has-copied-prices="!!copiedPrices"
|
v-for="(variant, index) in variants"
|
||||||
|
:key="variant.client_id"
|
||||||
|
:form="form"
|
||||||
|
:variant="variant"
|
||||||
|
:index="index"
|
||||||
|
:can-remove="variants.length > 1"
|
||||||
|
:has-copied-prices="!!copiedPrices"
|
||||||
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
:variant-errors="(clientId, field) => variantErrors(form, clientId, field)"
|
||||||
@remove="confirmRemoveVariant(variant.client_id)"
|
@remove="removeVariant(variant.client_id)"
|
||||||
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
@update:name="setVariantField(variant.client_id, 'name', $event)"
|
||||||
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
@update:stock="setVariantField(variant.client_id, 'stock', $event)"
|
||||||
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
@update:retail-stock="setVariantField(variant.client_id, 'retail_stock', $event)"
|
||||||
@ -241,7 +233,8 @@ function submit() {
|
|||||||
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
@update:media="setVariantField(variant.client_id, 'media', $event)"
|
||||||
@copy-prices="copyPrices(variant.prices as Record<string, string>)"
|
@copy-prices="copyPrices(variant.prices as Record<string, string>)"
|
||||||
@paste-prices="pastePrices(variant.client_id)"
|
@paste-prices="pastePrices(variant.client_id)"
|
||||||
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)" />
|
@apply-to-all-prices="applyToAllPrices(variant.prices as Record<string, string>)"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Button type="button" variant="outline" @click="addVariant">
|
<Button type="button" variant="outline" @click="addVariant">
|
||||||
@ -256,8 +249,4 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ConfirmDialog v-model:open="showDeleteConfirm" title="Hapus Varian?"
|
|
||||||
description="Apakah Anda yakin ingin menghapus varian ini? Tindakan ini tidak dapat dibatalkan."
|
|
||||||
confirm-label="Hapus" cancel-label="Batal" destructive @confirm="handleRemoveVariant" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,239 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useForm } from '@inertiajs/vue3';
|
|
||||||
import { Save } from '@lucide/vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { toast } from 'vue-sonner';
|
|
||||||
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 {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import { Field, FieldError, FieldLabel, FieldGroup, FieldSet } from '@/components/ui/field';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { useCan } from '@/composables/useCan';
|
|
||||||
import { useFormDialog } from '@/composables/useFormDialog';
|
|
||||||
import { formErrors } from '@/lib/form';
|
|
||||||
import { update } from '@/routes/admin/master/products';
|
|
||||||
import { createMediaUploadState, appendMediaToFormData } from '@/types/media';
|
|
||||||
import type { ProductListItem, Variant } from '@/types/product';
|
|
||||||
import { PRICE_TYPES, PRICE_TYPE_LABELS } from '@/types/product';
|
|
||||||
|
|
||||||
const open = defineModel<boolean>('open', { default: false });
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
variant: Variant | null;
|
|
||||||
product: ProductListItem | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const editForm = useForm({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
category_ids: [] as number[],
|
|
||||||
variants: [] as any[],
|
|
||||||
});
|
|
||||||
|
|
||||||
function resetForm() {
|
|
||||||
editForm.reset();
|
|
||||||
editForm.clearErrors();
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateForm(variant: Variant | null) {
|
|
||||||
resetForm();
|
|
||||||
|
|
||||||
if (!variant || !props.product) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
editForm.name = props.product.name;
|
|
||||||
editForm.description = props.product.description ?? '';
|
|
||||||
editForm.category_ids = (props.product.categories ?? []).map((c) => c.id);
|
|
||||||
|
|
||||||
editForm.variants = (props.product.variants ?? []).map((v) => {
|
|
||||||
const prices: Record<string, string> = {
|
|
||||||
distributor: '0',
|
|
||||||
agent: '0',
|
|
||||||
sub_agent: '0',
|
|
||||||
grosir: '0',
|
|
||||||
retail: '0',
|
|
||||||
tiktok: '0',
|
|
||||||
shopee: '0',
|
|
||||||
harga_modal: '0',
|
|
||||||
};
|
|
||||||
v.prices?.forEach((price) => {
|
|
||||||
if (price.type) {
|
|
||||||
prices[price.type] = String(price.price);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
client_id: `variant-${crypto.randomUUID()}`,
|
|
||||||
id: v.id,
|
|
||||||
name: v.name,
|
|
||||||
stock: String(v.stock),
|
|
||||||
retail_stock: String(v.retail_stock),
|
|
||||||
prices,
|
|
||||||
media: createMediaUploadState(v.images ?? []),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useFormDialog({
|
|
||||||
open,
|
|
||||||
source: () => props.variant,
|
|
||||||
populate: populateForm,
|
|
||||||
reset: resetForm,
|
|
||||||
});
|
|
||||||
|
|
||||||
const editingVariantIndex = computed(() => {
|
|
||||||
if (!props.variant || !editForm.variants.length) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editForm.variants.findIndex((v) => v.id === props.variant!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
const editingVariantFormItem = computed(() => {
|
|
||||||
const idx = editingVariantIndex.value;
|
|
||||||
|
|
||||||
if (idx === -1) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return editForm.variants[idx];
|
|
||||||
});
|
|
||||||
|
|
||||||
const isUploading = computed(() => {
|
|
||||||
return editForm.variants.some((v) => v.media?.pendingUploads > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
function submit() {
|
|
||||||
if (!props.product) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('_method', 'PUT');
|
|
||||||
formData.append('name', editForm.name.trim());
|
|
||||||
formData.append('description', editForm.description.trim());
|
|
||||||
|
|
||||||
editForm.category_ids.forEach((id) => {
|
|
||||||
formData.append('category_ids[]', String(id));
|
|
||||||
});
|
|
||||||
|
|
||||||
editForm.variants.forEach((v, index) => {
|
|
||||||
if (v.id) {
|
|
||||||
formData.append(`variants[${index}][id]`, String(v.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.append(`variants[${index}][name]`, v.name.trim());
|
|
||||||
formData.append(`variants[${index}][stock]`, String(Number.parseInt(v.stock, 10) || 0));
|
|
||||||
formData.append(`variants[${index}][retail_stock]`, String(Number.parseInt(v.retail_stock, 10) || 0));
|
|
||||||
|
|
||||||
if (v.prices) {
|
|
||||||
Object.entries(v.prices).forEach(([type, value]) => {
|
|
||||||
formData.append(`variants[${index}][prices][${type}]`, String(Number.parseInt(String(value), 10) || 0));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
appendMediaToFormData(formData, `variants[${index}]`, v.media);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { can } = useCan();
|
|
||||||
const isOwner = can('owner_verifications.verify');
|
|
||||||
|
|
||||||
editForm.transform(() => formData).post(update.url(props.product.id), {
|
|
||||||
forceFormData: true,
|
|
||||||
onSuccess: () => {
|
|
||||||
open.value = false;
|
|
||||||
|
|
||||||
if (isOwner) {
|
|
||||||
toast.success('Varian produk berhasil diperbarui.');
|
|
||||||
} else {
|
|
||||||
toast.success('Perubahan varian berhasil diajukan dan menunggu verifikasi owner.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
if (errors.system) {
|
|
||||||
toast.error(errors.system);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Dialog v-model:open="open">
|
|
||||||
<DialogContent class="sm:max-w-lg max-h-[85vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Ubah Varian Produk</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Mengubah properti varian untuk produk <strong>{{ product?.name }}</strong>.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form v-if="editingVariantFormItem" @submit.prevent="submit">
|
|
||||||
<FieldGroup>
|
|
||||||
<FieldSet class="grid gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="variant-name" required>Nama Varian</FieldLabel>
|
|
||||||
<Input id="variant-name" v-model="editingVariantFormItem.name" type="text"
|
|
||||||
placeholder="Masukkan nama varian" />
|
|
||||||
<FieldError :errors="formErrors(editForm, `variants.${editingVariantIndex}.name`)" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="variant-stock" required>Stok</FieldLabel>
|
|
||||||
<NumberInput id="variant-stock" v-model="editingVariantFormItem.stock" />
|
|
||||||
<FieldError :errors="formErrors(editForm, `variants.${editingVariantIndex}.stock`)" />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field>
|
|
||||||
<FieldLabel for="variant-retail-stock" required>Stok Ecer</FieldLabel>
|
|
||||||
<NumberInput id="variant-retail-stock" v-model="editingVariantFormItem.retail_stock" />
|
|
||||||
<FieldError
|
|
||||||
:errors="formErrors(editForm, `variants.${editingVariantIndex}.retail_stock`)" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t pt-4 mt-2">
|
|
||||||
<h4 class="text-sm font-semibold mb-3">Harga Varian</h4>
|
|
||||||
<div class="grid gap-3 grid-cols-2 md:grid-cols-3">
|
|
||||||
<Field v-for="type in PRICE_TYPES" :key="type">
|
|
||||||
<FieldLabel class="text-xs" :for="`variant-price-${type}`" required>
|
|
||||||
{{ PRICE_TYPE_LABELS[type] }}
|
|
||||||
</FieldLabel>
|
|
||||||
<RupiahInput :id="`variant-price-${type}`"
|
|
||||||
v-model="editingVariantFormItem.prices[type]" />
|
|
||||||
<FieldError
|
|
||||||
:errors="formErrors(editForm, `variants.${editingVariantIndex}.prices.${type}`)" />
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MediaDropzone :id="`variant_images_${editingVariantFormItem.client_id}`"
|
|
||||||
v-model="editingVariantFormItem.media" label="Foto Varian" :max-files="5" required
|
|
||||||
:errors="formErrors(editForm, `variants.${editingVariantIndex}.images`)" />
|
|
||||||
</FieldSet>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<DialogFooter class="mt-6">
|
|
||||||
<Button type="button" variant="outline" :disabled="editForm.processing" @click="open = false">
|
|
||||||
Batal
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" :disabled="editForm.processing || isUploading">
|
|
||||||
<Save class="size-4" />
|
|
||||||
{{ isUploading ? 'Mengunggah...' : editForm.processing ? 'Menyimpan...' : 'Simpan' }}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</template>
|
|
||||||
@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { RowEditAction } from '@/components/button';
|
|
||||||
import { DataTableEmpty } from '@/components/data-table';
|
import { DataTableEmpty } from '@/components/data-table';
|
||||||
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
|
||||||
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
|
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
|
||||||
@ -27,8 +26,7 @@ import {
|
|||||||
PRICE_TYPES,
|
PRICE_TYPES,
|
||||||
PRICE_TYPE_LABELS,
|
PRICE_TYPE_LABELS,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
import type { ProductListItem, Variant } from '@/types/product';
|
import type { ProductListItem } from '@/types/product';
|
||||||
import ProductVariantEditModal from '../form/ProductVariantEditModal.vue';
|
|
||||||
import DataTableActions from './data-table-actions.vue';
|
import DataTableActions from './data-table-actions.vue';
|
||||||
import ProductStatusToggle from './product-status-toggle.vue';
|
import ProductStatusToggle from './product-status-toggle.vue';
|
||||||
|
|
||||||
@ -76,17 +74,6 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
verificationModalOpen.value = true;
|
verificationModalOpen.value = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Modal Edit Varian State ─────────────────────────────────────────────────
|
|
||||||
const isEditing = ref(false);
|
|
||||||
const editingProduct = ref<ProductListItem | null>(null);
|
|
||||||
const editingVariant = ref<Variant | null>(null);
|
|
||||||
|
|
||||||
function openEditModal(variant: Variant, product: ProductListItem) {
|
|
||||||
editingProduct.value = product;
|
|
||||||
editingVariant.value = variant;
|
|
||||||
isEditing.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -155,12 +142,11 @@ function openEditModal(variant: Variant, product: ProductListItem) {
|
|||||||
<TableHead>Stok Reject</TableHead>
|
<TableHead>Stok Reject</TableHead>
|
||||||
<TableHead>Stok Ecer</TableHead>
|
<TableHead>Stok Ecer</TableHead>
|
||||||
<TableHead>Harga</TableHead>
|
<TableHead>Harga</TableHead>
|
||||||
<TableHead class="text-center w-20">Aksi</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
<TableRow v-if="!product.variants.length" :key="`${product.id}-empty`">
|
||||||
<TableCell :colspan="8" class="text-muted-foreground">
|
<TableCell :colspan="6" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -201,10 +187,6 @@ function openEditModal(variant: Variant, product: ProductListItem) {
|
|||||||
</div>
|
</div>
|
||||||
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
|
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
|
||||||
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
|
|
||||||
@click="openEditModal(variant, product)" />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@ -216,12 +198,5 @@ function openEditModal(variant: Variant, product: ProductListItem) {
|
|||||||
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
|
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
|
||||||
|
|
||||||
<VerificationDetailModal v-model:open="verificationModalOpen" :request-id="selectedRequestId" />
|
<VerificationDetailModal v-model:open="verificationModalOpen" :request-id="selectedRequestId" />
|
||||||
|
|
||||||
<!-- Dialog Modal Edit Varian -->
|
|
||||||
<ProductVariantEditModal
|
|
||||||
v-model:open="isEditing"
|
|
||||||
:variant="editingVariant"
|
|
||||||
:product="editingProduct"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import { useForm } from '@inertiajs/vue3';
|
|||||||
import { Plus, Save } from '@lucide/vue';
|
import { Plus, Save } from '@lucide/vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { FieldError } from '@/components/ui/field';
|
import { FieldError } from '@/components/ui/field';
|
||||||
import { useVariantList } from '@/composables/useVariantList';
|
import { useVariantList } from '@/composables/useVariantList';
|
||||||
@ -90,22 +89,6 @@ const {
|
|||||||
|
|
||||||
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue());
|
const useSamePrice = ref(prices.value.length <= 1 || allPricesHaveSameValue());
|
||||||
|
|
||||||
const showDeleteConfirm = ref(false);
|
|
||||||
const priceToDelete = ref<string | null>(null);
|
|
||||||
|
|
||||||
function confirmRemovePrice(clientId: string) {
|
|
||||||
priceToDelete.value = clientId;
|
|
||||||
showDeleteConfirm.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemovePrice() {
|
|
||||||
if (priceToDelete.value) {
|
|
||||||
removePrice(priceToDelete.value);
|
|
||||||
priceToDelete.value = null;
|
|
||||||
}
|
|
||||||
showDeleteConfirm.value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
name: props.initialData?.name ?? '',
|
name: props.initialData?.name ?? '',
|
||||||
unit: props.initialData?.unit ?? '',
|
unit: props.initialData?.unit ?? '',
|
||||||
@ -213,7 +196,7 @@ function submit() {
|
|||||||
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
|
<RawMaterialVariantSection v-for="(price, index) in prices" :key="price.client_id" :form="form"
|
||||||
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
|
:price="price" :index="index" :total-prices="prices.length" :use-same-price="useSamePrice"
|
||||||
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
:price-errors="(clientId, field) => priceErrors(form, clientId, field)"
|
||||||
@remove="confirmRemovePrice(price.client_id)" @apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
@remove="removePrice(price.client_id)" @apply-price-to-all="applyPriceToAllVariants(price.client_id)"
|
||||||
@update:variant="setPriceField(price.client_id, 'variant', $event)"
|
@update:variant="setPriceField(price.client_id, 'variant', $event)"
|
||||||
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
@update:stock="setPriceField(price.client_id, 'stock', $event)"
|
||||||
@update:price="setPriceValue(price.client_id, $event)" />
|
@update:price="setPriceValue(price.client_id, $event)" />
|
||||||
@ -233,14 +216,4 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model:open="showDeleteConfirm"
|
|
||||||
title="Hapus Varian?"
|
|
||||||
description="Apakah Anda yakin ingin menghapus varian ini? Tindakan ini tidak dapat dibatalkan."
|
|
||||||
confirm-label="Hapus"
|
|
||||||
cancel-label="Batal"
|
|
||||||
destructive
|
|
||||||
@confirm="handleRemovePrice"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -115,9 +115,6 @@
|
|||||||
])
|
])
|
||||||
->name('edit');
|
->name('edit');
|
||||||
|
|
||||||
Route::get('{product}', [ProductController::class, 'show'])
|
|
||||||
->name('show');
|
|
||||||
|
|
||||||
Route::put('{product}', [ProductController::class, 'update'])
|
Route::put('{product}', [ProductController::class, 'update'])
|
||||||
->middleware([
|
->middleware([
|
||||||
'permission:'.Permission::PRODUCTS_UPDATE->value,
|
'permission:'.Permission::PRODUCTS_UPDATE->value,
|
||||||
|
|||||||
@ -872,7 +872,7 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
|
|||||||
ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10]);
|
ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10]);
|
||||||
ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 20]);
|
ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 20]);
|
||||||
|
|
||||||
expect($product->fresh(['variants'])->total_stock_formatted)->toBe('30');
|
expect($product->fresh()->total_stock_formatted)->toBe('30');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('product has slug auto-generated', function () {
|
test('product has slug auto-generated', function () {
|
||||||
@ -1097,69 +1097,3 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
|
|||||||
expect($variant->fresh()->name)->toBe($originalVariantName);
|
expect($variant->fresh()->name)->toBe($originalVariantName);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Show JSON ────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('Product Show JSON', function () {
|
|
||||||
test('authenticated user with permission can get product details in JSON', function () {
|
|
||||||
$user = createProductUserWithPermission(PermissionEnum::PRODUCTS_VIEW);
|
|
||||||
|
|
||||||
$product = createProductWithVariants();
|
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
|
||||||
->get(route('admin.master.products.show', $product))
|
|
||||||
->assertOk();
|
|
||||||
|
|
||||||
$response->assertJsonStructure([
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
'description',
|
|
||||||
'categories' => [
|
|
||||||
'*' => [
|
|
||||||
'id',
|
|
||||||
'name',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'variants' => [
|
|
||||||
'*' => [
|
|
||||||
'id',
|
|
||||||
'product_id',
|
|
||||||
'name',
|
|
||||||
'stock',
|
|
||||||
'reject_stock',
|
|
||||||
'retail_stock',
|
|
||||||
'prices' => [
|
|
||||||
'*' => [
|
|
||||||
'id',
|
|
||||||
'variant_id',
|
|
||||||
'type',
|
|
||||||
'price',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'images',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$data = $response->json();
|
|
||||||
expect($data['id'])->toBe($product->id);
|
|
||||||
expect($data['variants'])->toHaveCount(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('guest cannot get product details in JSON', function () {
|
|
||||||
$product = createProductWithVariants();
|
|
||||||
|
|
||||||
$this->get(route('admin.master.products.show', $product))
|
|
||||||
->assertRedirect(route('login'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('user without view permission cannot get product details in JSON', function () {
|
|
||||||
$user = User::factory()->create();
|
|
||||||
|
|
||||||
$product = createProductWithVariants();
|
|
||||||
|
|
||||||
$this->actingAs($user)
|
|
||||||
->get(route('admin.master.products.show', $product))
|
|
||||||
->assertForbidden();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user