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:
Yoga Pangestu 2026-07-08 16:09:57 +07:00
parent 8c3847ade3
commit 897d9c62b7
6 changed files with 427 additions and 14 deletions

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Master;
use App\Enums\Permission;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
@ -10,6 +11,8 @@
use App\Models\Product;
use App\Services\Master\CategoryService;
use App\Services\Master\ProductService;
use App\Support\Media\MediaPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -53,7 +56,11 @@ public function store(ProductRequest $request): RedirectResponse
{
$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');
}
@ -70,7 +77,11 @@ public function update(ProductRequest $request, Product $product): RedirectRespo
{
$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');
}
@ -79,7 +90,11 @@ public function destroy(Request $request, Product $product): RedirectResponse
{
$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');
}
@ -88,8 +103,28 @@ public function toggleStatus(ToggleStatusRequest $request, Product $product): Re
{
$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();
}
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);
}
}

View File

@ -246,13 +246,58 @@ function () use ($validated, $product, $user, $isOwner): void {
$this->cacheForget('homepage:page_data');
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Ubah Produk',
"Pengajuan ubah produk '{$product->name}' menunggu verifikasi owner.",
route('admin.master.products.index', ['search' => $product->name]),
$product->name,
);
$changedVariants = [];
foreach ($validated['variants'] as $variantData) {
if (! empty($variantData['id'])) {
$originalVariant = $product->variants->firstWhere('id', $variantData['id']);
if ($originalVariant) {
$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,
);
}
}
}

View File

@ -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>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { RowEditAction } from '@/components/button';
import { DataTableEmpty } from '@/components/data-table';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import GroupedTableFooter from '@/components/data-table/GroupedTableFooter.vue';
@ -26,7 +27,8 @@ import {
PRICE_TYPES,
PRICE_TYPE_LABELS,
} 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 ProductStatusToggle from './product-status-toggle.vue';
@ -74,6 +76,17 @@ function openVerificationDetail(requestId: number | undefined) {
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>
<template>
@ -142,11 +155,12 @@ function openVerificationDetail(requestId: number | undefined) {
<TableHead>Stok Reject</TableHead>
<TableHead>Stok Ecer</TableHead>
<TableHead>Harga</TableHead>
<TableHead class="text-center w-20">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<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
</TableCell>
</TableRow>
@ -187,6 +201,10 @@ function openVerificationDetail(requestId: number | undefined) {
</div>
<span v-else class="text-xs text-muted-foreground">Belum ada harga</span>
</TableCell>
<TableCell class="text-center">
<RowEditAction :disabled="product.has_pending_request" tooltip="Ubah Varian"
@click="openEditModal(variant, product)" />
</TableCell>
</TableRow>
</TableBody>
</Table>
@ -198,5 +216,12 @@ function openVerificationDetail(requestId: number | undefined) {
<GroupedTableFooter :summary="paginationSummary" :pagination="pagination" :pagination-links="paginationLinks" />
<VerificationDetailModal v-model:open="verificationModalOpen" :request-id="selectedRequestId" />
<!-- Dialog Modal Edit Varian -->
<ProductVariantEditModal
v-model:open="isEditing"
:variant="editingVariant"
:product="editingProduct"
/>
</div>
</template>

View File

@ -115,6 +115,9 @@
])
->name('edit');
Route::get('{product}', [ProductController::class, 'show'])
->name('show');
Route::put('{product}', [ProductController::class, 'update'])
->middleware([
'permission:'.Permission::PRODUCTS_UPDATE->value,

View File

@ -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' => 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 () {
@ -1097,3 +1097,69 @@ function variantUpdateData(int $id, string $name = 'Updated Variant', int $stock
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();
});
});