Compare commits

...

2 Commits

Author SHA1 Message Date
Yoga Pangestu
8c3847ade3 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
2026-07-08 15:48:38 +07:00
Yoga Pangestu
5b9c294bf2 feat: refactor cache configuration to use class constants for improved readability and maintainability; update Cutting and Order tests to utilize service classes directly 2026-07-08 15:35:26 +07:00
10 changed files with 589 additions and 180 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

@ -1,6 +1,78 @@
<?php
use App\Enums\ActivityEventLabel;
use App\Enums\CashTransactionType;
use App\Enums\CuttingStatus;
use App\Enums\EmployeeAdvanceStatus;
use App\Enums\EmploymentStatus;
use App\Enums\Gender;
use App\Enums\LeaveRequestStatus;
use App\Enums\MarketplaceFeeScope;
use App\Enums\MarketplaceFeeValueType;
use App\Enums\OrderChannel;
use App\Enums\OrderStatus;
use App\Enums\OwnerVerificationAction;
use App\Enums\OwnerVerificationStatus;
use App\Enums\PaymentType;
use App\Enums\PayrollAdjustmentType;
use App\Enums\PayrollPeriodStatus;
use App\Enums\PayrollStatus;
use App\Enums\Permission;
use App\Enums\PriceType;
use App\Enums\ProductStockQuality;
use App\Enums\RawMaterialUnit;
use App\Enums\Role;
use App\Enums\StokOpnameStatus;
use App\Models\Attendance;
use App\Models\CashAccount;
use App\Models\CashTransaction;
use App\Models\Category;
use App\Models\Customer;
use App\Models\Cutting;
use App\Models\CuttingMaterial;
use App\Models\CuttingMaterialCombination;
use App\Models\CuttingResult;
use App\Models\CuttingResultPrice;
use App\Models\Employee;
use App\Models\EmployeeAdvance;
use App\Models\EmployeeAdvancePayment;
use App\Models\Expense;
use App\Models\HomepageConfiguration;
use App\Models\LeaveRequest;
use App\Models\Notification;
use App\Models\Order;
use App\Models\OrderItem;
use App\Models\OwnerVerificationRequest;
use App\Models\Payroll;
use App\Models\PayrollAdjustment;
use App\Models\PayrollPeriod;
use App\Models\Product;
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\Purchase;
use App\Models\PurchaseItem;
use App\Models\PushSubscription;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\Rejection;
use App\Models\RetailStockHistory;
use App\Models\StokOpname;
use App\Models\StokOpnameItem;
use App\Models\Supplier;
use App\Models\SystemConfiguration;
use App\Models\User;
use App\Models\UserProfile;
use App\Settings\HomepageSettings;
use App\Settings\HrSettings;
use App\Settings\MarketplaceSettings;
use App\Settings\SocialMediaSettings;
use App\Settings\SystemSettings;
use Illuminate\Database\Eloquent\Relations\MorphPivot;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Spatie\Activitylog\Models\Activity;
use Spatie\MediaLibrary\MediaCollections\Models\Media;
return [
@ -133,92 +205,92 @@
'serializable_classes' => [
// Core PHP and Laravel Classes
Illuminate\Support\Collection::class,
Collection::class,
Illuminate\Database\Eloquent\Collection::class,
Carbon\Carbon::class,
Illuminate\Support\Carbon::class,
stdClass::class,
Illuminate\Database\Eloquent\Relations\Pivot::class,
Illuminate\Database\Eloquent\Relations\MorphPivot::class,
Pivot::class,
MorphPivot::class,
// Spatie Packages Classes
Spatie\Permission\Models\Role::class,
Spatie\Permission\Models\Permission::class,
Spatie\Activitylog\Models\Activity::class,
Spatie\MediaLibrary\MediaCollections\Models\Media::class,
Activity::class,
Media::class,
// Application Settings
App\Settings\HomepageSettings::class,
App\Settings\HrSettings::class,
App\Settings\MarketplaceSettings::class,
App\Settings\SocialMediaSettings::class,
App\Settings\SystemSettings::class,
HomepageSettings::class,
HrSettings::class,
MarketplaceSettings::class,
SocialMediaSettings::class,
SystemSettings::class,
// Application Models
App\Models\Attendance::class,
App\Models\CashAccount::class,
App\Models\CashTransaction::class,
App\Models\Category::class,
App\Models\Customer::class,
App\Models\Cutting::class,
App\Models\CuttingMaterial::class,
App\Models\CuttingMaterialCombination::class,
App\Models\CuttingResult::class,
App\Models\CuttingResultPrice::class,
App\Models\Employee::class,
App\Models\EmployeeAdvance::class,
App\Models\EmployeeAdvancePayment::class,
App\Models\Expense::class,
App\Models\HomepageConfiguration::class,
App\Models\LeaveRequest::class,
App\Models\Notification::class,
App\Models\Order::class,
App\Models\OrderItem::class,
App\Models\OwnerVerificationRequest::class,
App\Models\Payroll::class,
App\Models\PayrollAdjustment::class,
App\Models\PayrollPeriod::class,
App\Models\Product::class,
App\Models\ProductPrice::class,
App\Models\ProductVariant::class,
App\Models\Purchase::class,
App\Models\PurchaseItem::class,
App\Models\PushSubscription::class,
App\Models\RawMaterial::class,
App\Models\RawMaterialPrice::class,
App\Models\Rejection::class,
App\Models\RetailStockHistory::class,
App\Models\StokOpname::class,
App\Models\StokOpnameItem::class,
App\Models\Supplier::class,
App\Models\SystemConfiguration::class,
App\Models\User::class,
App\Models\UserProfile::class,
Attendance::class,
CashAccount::class,
CashTransaction::class,
Category::class,
Customer::class,
Cutting::class,
CuttingMaterial::class,
CuttingMaterialCombination::class,
CuttingResult::class,
CuttingResultPrice::class,
Employee::class,
EmployeeAdvance::class,
EmployeeAdvancePayment::class,
Expense::class,
HomepageConfiguration::class,
LeaveRequest::class,
Notification::class,
Order::class,
OrderItem::class,
OwnerVerificationRequest::class,
Payroll::class,
PayrollAdjustment::class,
PayrollPeriod::class,
Product::class,
ProductPrice::class,
ProductVariant::class,
Purchase::class,
PurchaseItem::class,
PushSubscription::class,
RawMaterial::class,
RawMaterialPrice::class,
Rejection::class,
RetailStockHistory::class,
StokOpname::class,
StokOpnameItem::class,
Supplier::class,
SystemConfiguration::class,
User::class,
UserProfile::class,
// Application Enums
App\Enums\ActivityEventLabel::class,
App\Enums\CashTransactionType::class,
App\Enums\CuttingStatus::class,
App\Enums\EmployeeAdvanceStatus::class,
App\Enums\EmploymentStatus::class,
App\Enums\Gender::class,
App\Enums\LeaveRequestStatus::class,
App\Enums\MarketplaceFeeScope::class,
App\Enums\MarketplaceFeeValueType::class,
App\Enums\OrderChannel::class,
App\Enums\OrderStatus::class,
App\Enums\OwnerVerificationAction::class,
App\Enums\OwnerVerificationStatus::class,
App\Enums\PaymentType::class,
App\Enums\PayrollAdjustmentType::class,
App\Enums\PayrollPeriodStatus::class,
App\Enums\PayrollStatus::class,
App\Enums\Permission::class,
App\Enums\PriceType::class,
App\Enums\ProductStockQuality::class,
App\Enums\RawMaterialUnit::class,
App\Enums\Role::class,
App\Enums\StokOpnameStatus::class,
ActivityEventLabel::class,
CashTransactionType::class,
CuttingStatus::class,
EmployeeAdvanceStatus::class,
EmploymentStatus::class,
Gender::class,
LeaveRequestStatus::class,
MarketplaceFeeScope::class,
MarketplaceFeeValueType::class,
OrderChannel::class,
OrderStatus::class,
OwnerVerificationAction::class,
OwnerVerificationStatus::class,
PaymentType::class,
PayrollAdjustmentType::class,
PayrollPeriodStatus::class,
PayrollStatus::class,
Permission::class,
PriceType::class,
ProductStockQuality::class,
RawMaterialUnit::class,
Role::class,
StokOpnameStatus::class,
],
];

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

@ -12,6 +12,7 @@
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Models\User;
use App\Services\Manage\CuttingService;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@ -1130,15 +1131,15 @@ function setupDraftItems(User $user): array
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create(['is_active' => true]);
$priceWithStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 10]);
$priceWithStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 10]);
$priceWithoutStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 0]);
$this->actingAs($user)
->get(route('admin.manage.cuttings.create'))
->assertOk();
$service = app(\App\Services\Manage\CuttingService::class);
$catalog = $service->rawMaterialCatalog(user: $user);
$service = app(CuttingService::class);
$catalog = $service->rawMaterialCatalog(user: $user);
$material = $catalog->firstWhere('id', $rawMaterial->id);
$priceIds = $material->prices->pluck('id')->all();
@ -1155,12 +1156,12 @@ function setupDraftItems(User $user): array
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
CuttingMaterial::factory()->create([
'cutting_id' => $cutting->id,
'cutting_id' => $cutting->id,
'raw_material_price_id' => $priceZeroStock->id,
]);
$service = app(\App\Services\Manage\CuttingService::class);
$catalog = $service->rawMaterialCatalog($cutting);
$service = app(CuttingService::class);
$catalog = $service->rawMaterialCatalog($cutting);
$material = $catalog->firstWhere('id', $rawMaterial->id);
$priceIds = $material?->prices->pluck('id')->all() ?? [];

View File

@ -12,6 +12,7 @@
use App\Models\ProductPrice;
use App\Models\ProductVariant;
use App\Models\User;
use App\Services\Manage\OrderService;
use Database\Seeders\CashAccountSeeder;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
@ -622,14 +623,14 @@ function setupOrderDraftItems(User $user): ProductVariant
$product = Product::factory()->create(['is_active' => true]);
$variantWithStock = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantAllZero = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantRejectOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 5, 'retail_stock' => 0]);
$variantRetailOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 3]);
$variantWithStock = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantAllZero = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantRejectOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 5, 'retail_stock' => 0]);
$variantRetailOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 3]);
$service = app(\App\Services\Manage\OrderService::class);
$service = app(OrderService::class);
$catalog = $service->catalogItems(user: $user);
$found = $catalog->firstWhere('id', $product->id);
$found = $catalog->firstWhere('id', $product->id);
$variantIds = $found?->variants->pluck('id')->all() ?? [];
@ -644,21 +645,21 @@ function setupOrderDraftItems(User $user): ProductVariant
$product = Product::factory()->create(['is_active' => true]);
$variantAllZero = ProductVariant::factory()->create([
'product_id' => $product->id,
'stock' => 0,
'product_id' => $product->id,
'stock' => 0,
'reject_stock' => 0,
'retail_stock' => 0,
]);
$order = Order::factory()->create(['created_by_id' => $user->id]);
OrderItem::factory()->create([
'order_id' => $order->id,
'order_id' => $order->id,
'product_variant_id' => $variantAllZero->id,
]);
$service = app(\App\Services\Manage\OrderService::class);
$service = app(OrderService::class);
$catalog = $service->catalogItems($order);
$found = $catalog->firstWhere('id', $product->id);
$found = $catalog->firstWhere('id', $product->id);
$variantIds = $found?->variants->pluck('id')->all() ?? [];

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();
});
});