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;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user