feat: enhance RawMaterialController and RawMaterialService to support owner verification permissions; add RawMaterialVariantEditModal for editing price variants and implement JSON response for raw material details
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-08 15:48:38 +07:00
parent 5b9c294bf2
commit 8c3847ade3
7 changed files with 426 additions and 91 deletions

View File

@ -274,7 +274,6 @@ public function permissions(): array
Permission::RAW_MATERIALS_VIEW,
Permission::RAW_MATERIALS_CREATE,
Permission::RAW_MATERIALS_UPDATE,
Permission::RAW_MATERIALS_DELETE,
Permission::RAW_MATERIALS_TOGGLE_STATUS,
Permission::OWNER_VERIFICATIONS_VIEW,

View File

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Admin\Master;
use App\Enums\Permission;
use App\Enums\RawMaterialUnit;
use App\Http\Controllers\Concerns\FlashesEntityMessage;
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
@ -10,6 +11,8 @@
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\RawMaterial;
use App\Services\Master\RawMaterialService;
use App\Support\Media\MediaPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
@ -52,7 +55,11 @@ public function store(RawMaterialRequest $request): RedirectResponse
{
$this->rawMaterialService->create($request->validated(), $request->user());
$this->flashSuccess('Bahan baku berhasil diajukan dan menunggu verifikasi owner.');
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashCreated('Bahan baku');
} else {
$this->flashSuccess('Bahan baku berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.master.raw_materials.index');
}
@ -69,7 +76,11 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
{
$this->rawMaterialService->update($rawMaterial, $request->validated(), $request->user());
$this->flashSuccess('Perubahan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashUpdated('Bahan baku');
} else {
$this->flashSuccess('Perubahan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.master.raw_materials.index');
}
@ -78,7 +89,11 @@ public function destroy(Request $request, RawMaterial $rawMaterial): RedirectRes
{
$this->rawMaterialService->delete($rawMaterial, $request->user());
$this->flashSuccess('Penghapusan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashDeleted('Bahan baku');
} else {
$this->flashSuccess('Penghapusan bahan baku berhasil diajukan dan menunggu verifikasi owner.');
}
return redirect()->route('admin.master.raw_materials.index');
}
@ -87,8 +102,27 @@ public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMater
{
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated(), $request->user());
$this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.');
if ($request->user()->can(Permission::OWNER_VERIFICATIONS_VERIFY->value)) {
$this->flashStatusUpdated('bahan baku');
} else {
$this->flashSuccess('Perubahan status bahan baku berhasil diajukan dan menunggu verifikasi owner.');
}
return back();
}
public function show(RawMaterial $rawMaterial): JsonResponse
{
$rawMaterial->load([
'prices' => fn ($query) => $query->orderBy('created_at')->with('media'),
]);
$rawMaterial->prices->each(function ($price) use ($rawMaterial): void {
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
$price->setAttribute('unit_abbreviation', $rawMaterial->unit->abbreviation());
$price->unsetRelation('rawMaterial');
});
return response()->json($rawMaterial);
}
}

View File

@ -208,13 +208,49 @@ function () use ($validated, $rawMaterial, $user, $isOwner): void {
}
if (! $isOwner) {
$this->notifyForPendingRequest(
$user,
'Ubah Bahan Baku',
"Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]),
$rawMaterial->name,
);
$changedVariants = [];
foreach ($validated['prices'] as $priceData) {
if (! empty($priceData['id'])) {
$originalPrice = $rawMaterial->prices->firstWhere('id', $priceData['id']);
if ($originalPrice) {
$isChanged = false;
if ($originalPrice->variant !== $priceData['variant']) {
$isChanged = true;
}
if ($originalPrice->price !== (int) $priceData['price']) {
$isChanged = true;
}
if (rtrim(rtrim(number_format((float) $originalPrice->stock, 4, '.', ''), '0'), '.') !== rtrim(rtrim(number_format((float) $priceData['stock'], 4, '.', ''), '0'), '.')) {
$isChanged = true;
}
if ($isChanged) {
$changedVariants[] = $priceData['variant'];
}
}
} else {
$changedVariants[] = $priceData['variant'];
}
}
if (! empty($changedVariants)) {
$variantsStr = implode(', ', $changedVariants);
$this->notifyForPendingRequest(
$user,
'Ubah Varian Bahan Baku',
"Pengajuan ubah varian '{$variantsStr}' pada bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]),
$rawMaterial->name,
);
} else {
$this->notifyForPendingRequest(
$user,
'Ubah Bahan Baku',
"Pengajuan ubah bahan baku '{$rawMaterial->name}' menunggu verifikasi owner.",
route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]),
$rawMaterial->name,
);
}
}
}

View File

@ -0,0 +1,184 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Save } from '@lucide/vue';
import { computed } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { RupiahInput } from '@/components/form/rupiah-input';
import MediaDropzone from '@/components/media/MediaDropzone.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field, FieldError, FieldLabel, FieldGroup, FieldSet } from '@/components/ui/field';
import { useFormDialog } from '@/composables/useFormDialog';
import { useCan } from '@/composables/useCan';
import { formErrors } from '@/lib/form';
import { parseRupiah } from '@/lib/rupiah';
import { update } from '@/routes/admin/master/raw_materials';
import { createMediaUploadState, appendMediaToFormData } from '@/types/media';
import type { RawMaterialListItem, RawMaterialPrice } from '@/types/raw-material';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
price: RawMaterialPrice | null;
material: RawMaterialListItem | null;
}>();
const editForm = useForm({
name: '',
unit: '',
prices: [] as any[],
});
function resetForm() {
editForm.reset();
editForm.clearErrors();
}
function populateForm(price: RawMaterialPrice | null) {
resetForm();
if (!price || !props.material) {
return;
}
editForm.name = props.material.name;
editForm.unit = props.material.unit;
editForm.prices = (props.material.prices ?? []).map((p) => ({
client_id: `price-${crypto.randomUUID()}`,
id: p.id,
variant: p.variant,
price: p.price_input,
stock: p.stock_input,
media: createMediaUploadState(p.images ?? []),
}));
}
useFormDialog({
open,
source: () => props.price,
populate: populateForm,
reset: resetForm,
});
const editingPriceIndex = computed(() => {
if (!props.price || !editForm.prices.length) {
return -1;
}
return editForm.prices.findIndex((p) => p.id === props.price!.id);
});
const editingPriceFormItem = computed(() => {
const idx = editingPriceIndex.value;
if (idx === -1) {
return null;
}
return editForm.prices[idx];
});
function parseStockValue(value: string): number {
const parsed = Number.parseFloat(value.replace(',', '.'));
return Number.isNaN(parsed) ? 0 : parsed;
}
const isUploading = computed(() => {
return editForm.prices.some((p) => p.media?.pendingUploads > 0);
});
function submit() {
if (!props.material) {
return;
}
const formData = new FormData();
formData.append('_method', 'PUT');
formData.append('name', editForm.name.trim());
formData.append('unit', editForm.unit);
editForm.prices.forEach((price, index) => {
formData.append(`prices[${index}][id]`, String(price.id));
formData.append(`prices[${index}][variant]`, price.variant.trim());
formData.append(`prices[${index}][price]`, String(Number.parseInt(parseRupiah(price.price), 10) || 0));
formData.append(`prices[${index}][stock]`, String(parseStockValue(price.stock)));
appendMediaToFormData(formData, `prices[${index}]`, price.media);
});
editForm.transform(() => formData).post(update.url(props.material.id), {
forceFormData: true,
onSuccess: () => {
open.value = false;
},
onError: (errors) => {
if (errors.system) {
toast.error(errors.system);
}
},
});
}
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ubah Varian Bahan Baku</DialogTitle>
<DialogDescription>
Mengubah properti varian untuk bahan baku <strong>{{ material?.name }}</strong>.
</DialogDescription>
</DialogHeader>
<form v-if="editingPriceFormItem" @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="variant-name" required>Nama Varian</FieldLabel>
<Input id="variant-name" v-model="editingPriceFormItem.variant" type="text"
placeholder="Masukkan nama varian" />
<FieldError :errors="formErrors(editForm, `prices.${editingPriceIndex}.variant`)" />
</Field>
<div class="grid grid-cols-2 gap-4">
<Field>
<FieldLabel for="variant-stock" required>Stok</FieldLabel>
<DecimalInput id="variant-stock" v-model="editingPriceFormItem.stock" />
<FieldError :errors="formErrors(editForm, `prices.${editingPriceIndex}.stock`)" />
</Field>
<Field>
<FieldLabel for="variant-price" required>Harga</FieldLabel>
<RupiahInput id="variant-price" v-model="editingPriceFormItem.price" />
<FieldError :errors="formErrors(editForm, `prices.${editingPriceIndex}.price`)" />
</Field>
</div>
<MediaDropzone :id="`price_images_${editingPriceFormItem.client_id}`"
v-model="editingPriceFormItem.media" label="Foto Varian" :max-files="5" required
:errors="formErrors(editForm, `prices.${editingPriceIndex}.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';
@ -21,7 +22,8 @@ import type {
DataTablePagination,
DataTablePaginationLink,
} from '@/types/data-table';
import type { RawMaterialListItem } from '@/types/raw-material';
import type { RawMaterialListItem, RawMaterialPrice } from '@/types/raw-material';
import RawMaterialVariantEditModal from '../form/RawMaterialVariantEditModal.vue';
import DataTableActions from './data-table-actions.vue';
import RawMaterialStatusToggle from './raw-material-status-toggle.vue';
@ -68,6 +70,17 @@ function openVerificationDetail(requestId: number | undefined) {
verificationModalOpen.value = true;
}
}
// Modal Edit Varian State
const isEditing = ref(false);
const editingMaterial = ref<RawMaterialListItem | null>(null);
const editingPrice = ref<RawMaterialPrice | null>(null);
function openEditModal(price: RawMaterialPrice, material: RawMaterialListItem) {
editingMaterial.value = material;
editingPrice.value = price;
isEditing.value = true;
}
</script>
<template>
@ -77,92 +90,100 @@ function openVerificationDetail(requestId: number | undefined) {
<div class="relative min-h-[100px]">
<!-- Subtle loading bar tidak ganggu layout -->
<div
v-if="loading"
class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10"
>
<div v-if="loading" class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10">
<div class="h-full w-1/3 animate-[loading-bar_1.2s_ease-in-out_infinite] bg-primary rounded-full" />
</div>
<div v-if="materials.length" class="space-y-4">
<div v-for="(material, index) in materials" :key="material.id" class="overflow-hidden rounded-md border">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-medium leading-tight">
{{ material.name }}
</h3>
<Badge variant="secondary">
{{ material.unit_label }}
</Badge>
</div>
<p v-if="material.has_pending_request" class="text-sm text-amber-600">
{{ material.pending_request_submitted_by_name }} mengajukan {{ material.pending_request_action_label?.toLowerCase() }} bahan baku ini
<button type="button" class="underline-offset-2 hover:underline" @click="openVerificationDetail(material.pending_request_id)">lihat</button>
</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok <strong class="text-primary">
{{ material.total_stock_formatted }}
</strong>
</span>
<span>
Total harga <strong class="text-primary">
{{ material.total_inventory_value_formatted }}
</strong>
</span>
<div v-for="(material, index) in materials" :key="material.id"
class="overflow-hidden rounded-md border bg-background">
<div
class="flex flex-col gap-3 border-b bg-muted/30 px-4 py-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 items-start gap-3">
<span class="text-muted-foreground w-8 shrink-0 pt-0.5 text-center text-sm tabular-nums">
{{ rowNumber(index) }}
</span>
<div class="min-w-0 space-y-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="font-semibold leading-tight text-foreground text-base">
{{ material.name }}
</h3>
<Badge variant="secondary">
{{ material.unit_label }}
</Badge>
</div>
<p v-if="material.has_pending_request" class="text-sm text-amber-600">
{{ material.pending_request_submitted_by_name }} mengajukan {{
material.pending_request_action_label?.toLowerCase() }} bahan baku ini
<button type="button" class="underline-offset-2 hover:underline font-semibold"
@click="openVerificationDetail(material.pending_request_id)">lihat</button>
</p>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
<span>
Total stok <strong class="text-primary font-semibold">
{{ material.total_stock_formatted }}
</strong>
</span>
<span>
Total harga <strong class="text-primary font-semibold">
{{ material.total_inventory_value_formatted }}
</strong>
</span>
</div>
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<RawMaterialStatusToggle :material="material" />
<DataTableActions :material="material" />
</div>
</div>
<div class="flex shrink-0 items-center justify-end gap-2 sm:pt-0.5">
<RawMaterialStatusToggle :material="material" />
<DataTableActions :material="material" />
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-10">#</TableHead>
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga per Satuan</TableHead>
<TableHead class="w-24 text-center">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!material.prices.length" :key="`${material.id}-empty`">
<TableCell colspan="6" class="text-muted-foreground text-center">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="(price, priceIndex) in material.prices" :key="price.id">
<TableCell class="text-muted-foreground tabular-nums text-center">
{{ priceIndex + 1 }}
</TableCell>
<TableCell class="font-medium">
{{ price.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1"
:title="`${material.name} - ${price.variant}`"
:all-urls="allVariantImageUrls(material)"
:all-titles="allVariantImageTitles(material)" />
</TableCell>
<TableCell class="tabular-nums">
{{ price.stock_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ price.price_formatted }}
</TableCell>
<TableCell class="text-center">
<RowEditAction :disabled="material.has_pending_request" tooltip="Ubah Varian"
@click="openEditModal(price, material)" />
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-10">#</TableHead>
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga per Satuan</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!material.prices.length" :key="`${material.id}-empty`">
<TableCell colspan="5" class="text-muted-foreground">
Belum ada varian
</TableCell>
</TableRow>
<TableRow v-for="(price, priceIndex) in material.prices" :key="price.id">
<TableCell class="text-muted-foreground tabular-nums text-center">
{{ priceIndex + 1 }}
</TableCell>
<TableCell class="font-medium">
{{ price.variant }}
</TableCell>
<TableCell>
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" :all-urls="allVariantImageUrls(material)" :all-titles="allVariantImageTitles(material)" />
</TableCell>
<TableCell class="tabular-nums">
{{ price.stock_formatted }}
</TableCell>
<TableCell class="tabular-nums">
{{ price.price_formatted }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
<DataTableEmpty v-else />
</div>
@ -170,5 +191,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 -->
<RawMaterialVariantEditModal
v-model:open="isEditing"
:price="editingPrice"
:material="editingMaterial"
/>
</div>
</template>

View File

@ -176,6 +176,7 @@
])
->name('destroy');
Route::get('{rawMaterial}', [RawMaterialController::class, 'show'])->name('show');
Route::get('/', [RawMaterialController::class, 'index'])->name('index');
});

View File

@ -738,8 +738,8 @@ function createRawMaterialVerifierUser(): User
RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 10.5]);
RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 5.25]);
expect($rawMaterial->fresh()->total_stock_formatted)->toContain('15,75');
expect($rawMaterial->fresh()->total_stock_formatted)->toContain('m');
expect($rawMaterial->fresh(['prices'])->total_stock_formatted)->toContain('15,75');
expect($rawMaterial->fresh(['prices'])->total_stock_formatted)->toContain('m');
});
});
@ -907,3 +907,56 @@ function createRawMaterialVerifierUser(): User
expect($price->fresh()->variant)->toBe($originalVariant);
});
});
// ─── Show JSON ────────────────────────────────────────────
describe('Raw Material Show JSON', function () {
test('authenticated user with permission can get raw material details in JSON', function () {
$user = createRawMaterialUserWithPermission(PermissionEnum::RAW_MATERIALS_VIEW);
$rawMaterial = createRawMaterialWithPrices();
$response = $this->actingAs($user)
->get(route('admin.master.raw_materials.show', $rawMaterial))
->assertOk();
$response->assertJsonStructure([
'id',
'name',
'unit',
'prices' => [
'*' => [
'id',
'variant',
'price',
'price_formatted',
'price_input',
'stock_formatted',
'stock_input',
'images',
],
],
]);
$data = $response->json();
expect($data['id'])->toBe($rawMaterial->id);
expect($data['prices'])->toHaveCount(2);
});
test('guest cannot get raw material details in JSON', function () {
$rawMaterial = createRawMaterialWithPrices();
$this->get(route('admin.master.raw_materials.show', $rawMaterial))
->assertRedirect(route('login'));
});
test('user without view permission cannot get raw material details in JSON', function () {
$user = User::factory()->create();
$rawMaterial = createRawMaterialWithPrices();
$this->actingAs($user)
->get(route('admin.master.raw_materials.show', $rawMaterial))
->assertForbidden();
});
});