feat: enhance ProductController and ProductService to support owner verification permissions; add ProductVariantEditModal for editing product variants and implement JSON response for product details
This commit is contained in:
parent
8c3847ade3
commit
897d9c62b7
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
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;
|
||||||
@ -10,6 +11,8 @@
|
|||||||
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;
|
||||||
@ -53,7 +56,11 @@ public function store(ProductRequest $request): RedirectResponse
|
|||||||
{
|
{
|
||||||
$this->productService->create($request->validated(), $request->user());
|
$this->productService->create($request->validated(), $request->user());
|
||||||
|
|
||||||
$this->flashSuccess('Produk berhasil diajukan dan menunggu verifikasi owner.');
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$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');
|
||||||
}
|
}
|
||||||
@ -70,7 +77,11 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
|
|||||||
{
|
{
|
||||||
$this->productService->update($product, $request->validated(), $request->user());
|
$this->productService->update($product, $request->validated(), $request->user());
|
||||||
|
|
||||||
$this->flashSuccess('Perubahan produk berhasil diajukan dan menunggu verifikasi owner.');
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$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');
|
||||||
}
|
}
|
||||||
@ -79,7 +90,11 @@ public function destroy(Request $request, Product $product): RedirectResponse
|
|||||||
{
|
{
|
||||||
$this->productService->delete($product, $request->user());
|
$this->productService->delete($product, $request->user());
|
||||||
|
|
||||||
$this->flashSuccess('Penghapusan produk berhasil diajukan dan menunggu verifikasi owner.');
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$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');
|
||||||
}
|
}
|
||||||
@ -88,8 +103,28 @@ public function toggleStatus(ToggleStatusRequest $request, Product $product): Re
|
|||||||
{
|
{
|
||||||
$this->productService->toggleStatus($product, $request->validated(), $request->user());
|
$this->productService->toggleStatus($product, $request->validated(), $request->user());
|
||||||
|
|
||||||
$this->flashSuccess('Perubahan status produk berhasil diajukan dan menunggu verifikasi owner.');
|
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
|
||||||
|
$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,13 +246,58 @@ function () use ($validated, $product, $user, $isOwner): void {
|
|||||||
$this->cacheForget('homepage:page_data');
|
$this->cacheForget('homepage:page_data');
|
||||||
|
|
||||||
if (! $isOwner) {
|
if (! $isOwner) {
|
||||||
$this->notifyForPendingRequest(
|
$changedVariants = [];
|
||||||
$user,
|
foreach ($validated['variants'] as $variantData) {
|
||||||
'Ubah Produk',
|
if (! empty($variantData['id'])) {
|
||||||
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
|
$originalVariant = $product->variants->firstWhere('id', $variantData['id']);
|
||||||
route('admin.master.products.index', ['search' => $product->name]),
|
if ($originalVariant) {
|
||||||
$product->name,
|
$isChanged = false;
|
||||||
);
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,239 @@
|
|||||||
|
<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,5 +1,6 @@
|
|||||||
<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';
|
||||||
@ -26,7 +27,8 @@ import {
|
|||||||
PRICE_TYPES,
|
PRICE_TYPES,
|
||||||
PRICE_TYPE_LABELS,
|
PRICE_TYPE_LABELS,
|
||||||
} from '@/types/product';
|
} from '@/types/product';
|
||||||
import type { ProductListItem } from '@/types/product';
|
import type { ProductListItem, Variant } 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';
|
||||||
|
|
||||||
@ -74,6 +76,17 @@ 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>
|
||||||
@ -142,11 +155,12 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
<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="6" class="text-muted-foreground">
|
<TableCell :colspan="8" class="text-muted-foreground">
|
||||||
Belum ada varian
|
Belum ada varian
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -187,6 +201,10 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
</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>
|
||||||
@ -198,5 +216,12 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
<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>
|
||||||
|
|||||||
@ -115,6 +115,9 @@
|
|||||||
])
|
])
|
||||||
->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()->total_stock_formatted)->toBe('30');
|
expect($product->fresh(['variants'])->total_stock_formatted)->toBe('30');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('product has slug auto-generated', function () {
|
test('product has slug auto-generated', function () {
|
||||||
@ -1097,3 +1097,69 @@ 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