feat: enhance raw material management with stock status filtering and improved form structure

This commit is contained in:
Yoga Pangestu 2026-06-20 22:46:16 +07:00
parent d3a33c46f6
commit 98e3ffefc7
10 changed files with 67 additions and 199 deletions

View File

@ -12,11 +12,11 @@ enum RawMaterialUnit: string
case METER = 'meter';
case KILOGRAM = 'kilogram';
private const MIN_STOCK_YARD = 5.0;
private const MIN_STOCK_YARD = 20;
private const MIN_STOCK_METER = 5.0;
private const MIN_STOCK_METER = 10;
private const MIN_STOCK_KILOGRAM = 2.0;
private const MIN_STOCK_KILOGRAM = 5;
private const CM_PER_YARD = 91.44;

View File

@ -7,6 +7,7 @@
use App\Http\Controllers\Concerns\ParsesDataTableQuery;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\Master\RawMaterialRequest;
use App\Http\Requests\Admin\ToggleStatusRequest;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Master\RawMaterialService;
@ -28,13 +29,13 @@ public function index(Request $request): Response
{
$tableQuery = $this->parseDataTableQuery($request);
$isActive = $request->string('is_active')->toString();
$stockStatus = $request->string('stock_status')->toString();
return Inertia::render('admin/master/raw-materials/Index', [
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive),
'outOfStockGroups' => $this->rawMaterialService->outOfStockGroups(),
'stockWarningGroups' => $this->rawMaterialService->stockWarningGroups(),
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive, $stockStatus),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
'stock_status' => $stockStatus,
]),
]);
}
@ -80,13 +81,9 @@ public function update(RawMaterialRequest $request, RawMaterial $rawMaterial): R
return redirect()->route('admin.master.raw-materials.index');
}
public function toggleStatus(Request $request, RawMaterial $rawMaterial): RedirectResponse
public function toggleStatus(ToggleStatusRequest $request, RawMaterial $rawMaterial): RedirectResponse
{
$validated = $request->validate([
'is_active' => ['required', 'boolean'],
]);
$this->rawMaterialService->toggleStatus($rawMaterial, $validated['is_active']);
$this->rawMaterialService->toggleStatus($rawMaterial, $request->validated());
$this->flashStatusUpdated('bahan baku');

View File

@ -2,6 +2,7 @@
namespace App\Services\Master;
use App\Enums\RawMaterialUnit;
use App\Models\RawMaterial;
use App\Models\RawMaterialPrice;
use App\Services\Media\MediaService;
@ -18,119 +19,10 @@ public function __construct(
private readonly MediaService $mediaService,
) {}
/**
* @return list<array{
* id: int,
* name: string,
* unit_label: string,
* edit_url: string,
* variants: list<array{
* id: int,
* name: string,
* stock_formatted: string,
* images: list<array<string, mixed>>,
* }>,
* }>
*/
public function outOfStockGroups(): array
{
return $this->inventoryAlertGroups(
fn (RawMaterialPrice $price): bool => (float) $price->stock <= 0,
);
}
/**
* @return list<array{
* id: int,
* name: string,
* unit_label: string,
* edit_url: string,
* variants: list<array{
* id: int,
* name: string,
* stock_formatted: string,
* images: list<array<string, mixed>>,
* }>,
* }>
*/
public function stockWarningGroups(): array
{
return $this->inventoryAlertGroups(function (RawMaterialPrice $price): bool {
$stock = (float) $price->stock;
if ($stock <= 0) {
return false;
}
$minStock = $price->rawMaterial->unit->minStock();
return $stock < $minStock;
});
}
/**
* @return list<array{
* id: int,
* name: string,
* unit_label: string,
* edit_url: string,
* variants: list<array{
* id: int,
* name: string,
* stock_formatted: string,
* images: list<array<string, mixed>>,
* }>,
* }>
*/
private function inventoryAlertGroups(callable $filter): array
{
return RawMaterialPrice::query()
->whereHas('rawMaterial', fn (Builder $query) => $query->where('is_active', true))
->with(['rawMaterial', 'media'])
->get()
->filter($filter)
->sortBy([
fn (RawMaterialPrice $price) => $price->rawMaterial->name,
fn (RawMaterialPrice $price) => $price->variant,
])
->groupBy('raw_material_id')
->map(function ($prices, $rawMaterialId) {
$material = $prices->first()->rawMaterial;
return [
'id' => (int) $rawMaterialId,
'name' => $material->name,
'unit_label' => $material->unit->label(),
'edit_url' => route('admin.master.raw-materials.edit', $rawMaterialId),
'variants' => $prices->map(function (RawMaterialPrice $price) use ($material): array {
return [
'id' => $price->id,
'name' => $price->variant,
'stock_formatted' => $this->formatStockAmount((float) $price->stock, $material->unit->abbreviation()),
'images' => MediaPresenter::collection($price, 'images'),
];
})->values()->all(),
];
})
->values()
->all();
}
private function formatStockAmount(float $amount, ?string $unit = null): string
{
$formatted = rtrim(rtrim(number_format($amount, 4, ',', '.'), '0'), ',');
if ($unit === null) {
return $formatted;
}
return "{$formatted} {$unit}";
}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/
public function paginateForIndex(array $tableQuery, string $isActive): LengthAwarePaginator
public function paginateForIndex(array $tableQuery, string $isActive, string $stockStatus = ''): LengthAwarePaginator
{
$query = RawMaterial::query()
->with([
@ -145,10 +37,22 @@ public function paginateForIndex(array $tableQuery, string $isActive): LengthAwa
->orWhereHas('prices', fn (Builder $query) => $query->where('variant', 'like', "%{$search}%"));
});
})
->when(
$isActive !== '',
fn (Builder $query) => $query->where('is_active', $isActive === '1')
);
->when($isActive !== '', fn (Builder $query) => $query->where('is_active', $isActive === '1'))
->when($stockStatus === 'out_of_stock', function (Builder $query): void {
$query->whereHas('prices', fn (Builder $q) => $q->where('stock', '<=', 0));
})
->when($stockStatus === 'low_stock', function (Builder $query): void {
$query->whereHas('prices', function (Builder $q): void {
$q->where('stock', '>', 0)->where(function (Builder $q): void {
foreach (RawMaterialUnit::cases() as $unit) {
$q->orWhere(function (Builder $q) use ($unit): void {
$q->whereHas('rawMaterial', fn (Builder $rm) => $rm->where('unit', $unit))
->where('stock', '<', $unit->minStock());
});
}
});
});
});
$this->applySorting($query, $tableQuery['sort'], $tableQuery['direction']);
@ -224,9 +128,9 @@ public function update(RawMaterial $rawMaterial, array $validated): void
});
}
public function toggleStatus(RawMaterial $rawMaterial, bool $isActive): void
public function toggleStatus(RawMaterial $rawMaterial, array $validated): void
{
$rawMaterial->is_active = $isActive;
$rawMaterial->is_active = $validated['is_active'];
$rawMaterial->save();
}

View File

@ -1,10 +1,10 @@
<script setup lang="ts">
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import RawMaterialForm from '@/components/admin/master/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption } from '@/types/raw-material';
import RawMaterialForm from './form/RawMaterialForm.vue';
defineProps<{
units: EnumOption[];

View File

@ -2,10 +2,10 @@
import { Head, Link } from '@inertiajs/vue3';
import { ArrowLeft } from '@lucide/vue';
import { computed } from 'vue';
import RawMaterialForm from '@/components/admin/master/raw-materials/RawMaterialForm.vue';
import { Button } from '@/components/ui/button';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { EnumOption, RawMaterialListItem } from '@/types/raw-material';
import RawMaterialForm from './form/RawMaterialForm.vue';
const props = defineProps<{
rawMaterial: RawMaterialListItem;

View File

@ -2,8 +2,6 @@
import { Head, Link } from '@inertiajs/vue3';
import { Plus } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import MasterOutOfStockCatalogSection from '@/components/admin/master/MasterOutOfStockCatalogSection.vue';
import RawMaterialGroupedTable from '@/components/admin/master/raw-materials/RawMaterialGroupedTable.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useCan } from '@/composables/useCan';
@ -13,18 +11,17 @@ import {
} from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table';
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
import type { PaginatedRawMaterials } from '@/types/raw-material';
import RawMaterialGroupedTable from './table/RawMaterialGroupedTable.vue';
const props = defineProps<{
rawMaterials: PaginatedRawMaterials;
outOfStockGroups: MasterOutOfStockGroup[];
stockWarningGroups: MasterOutOfStockGroup[];
filters: {
search: string;
sort?: string;
direction?: 'asc' | 'desc';
is_active?: string;
stock_status?: string;
};
}>();
@ -35,7 +32,7 @@ const { query, setSearch, setFilter, resetFilters, syncFromServer } =
useDataTableQuery({
url: '/admin/master/raw-materials',
initial: { ...props.filters },
filterKeys: ['is_active'],
filterKeys: ['is_active', 'stock_status'],
});
useDataTableQuerySync(() => props.filters, syncFromServer);
@ -50,13 +47,23 @@ const filterDefs = computed<DataTableFilterDef[]>(() => [
{ value: '0', label: 'Nonaktif' },
],
},
{
key: 'stock_status',
label: 'Stok',
type: 'select',
options: [
{ value: 'out_of_stock', label: 'Stok Habis' },
{ value: 'low_stock', label: 'Stok Menipis' },
],
},
]);
const filterValues = computed(() => ({
is_active: query.value.is_active ?? '',
stock_status: query.value.stock_status ?? '',
}));
const tablePagination = computed(() => ({
const pagination = computed(() => ({
currentPage: props.rawMaterials.current_page,
perPage: props.rawMaterials.per_page,
lastPage: props.rawMaterials.last_page,
@ -100,16 +107,10 @@ watch(
</Button>
</div>
<MasterOutOfStockCatalogSection severity="warning" title="Stok Menipis" empty-title="Tidak ada peringatan stok"
empty-description="Stok bahan baku dibawah 5 pcs." :groups="stockWarningGroups" />
<MasterOutOfStockCatalogSection title="Stok Habis" empty-title="Tidak ada stok habis"
empty-description="Stok bahan baku dibawah 0 pcs." :groups="outOfStockGroups" />
<Card class="min-w-0">
<CardContent class="min-w-0">
<RawMaterialGroupedTable v-model:search="search" :materials="rawMaterials.data" :first-item="firstItem"
:pagination="tablePagination" :pagination-links="rawMaterials.links" :filter-defs="filterDefs"
:pagination="pagination" :pagination-links="rawMaterials.links" :filter-defs="filterDefs"
:filter-values="filterValues" @filter-change="setFilter" @filters-reset="resetFilters" />
</CardContent>
</Card>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { ref, computed } from 'vue';
import { ref } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
@ -94,14 +94,6 @@ const form = useForm({
unit: props.initialData?.unit ?? '',
});
const isEditMode = computed(() => props.method === 'put');
const unitLabel = computed(() => {
const option = props.units.find((item) => item.value === form.unit);
return option?.label ?? form.unit;
});
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
if (items.length <= 1) {
return true;
@ -198,20 +190,6 @@ function buildFormData(): FormData {
return formData;
}
function formError(key: string): string | undefined {
return (form.errors as Record<string, string>)[key];
}
function priceError(clientId: string, field: string): string | undefined {
const index = prices.value.findIndex((price) => price.client_id === clientId);
if (index === -1) {
return undefined;
}
return formError(`prices.${index}.${field}`);
}
function submit() {
const options = {
forceFormData: true,
@ -281,7 +259,7 @@ function submit() {
<RupiahInput :id="`shared_price_${prices[0].client_id}`" :model-value="prices[0].price"
@update:model-value="setSharedPrice" />
<FieldError
:errors="priceError(prices[0].client_id, 'price') ? [priceError(prices[0].client_id, 'price')!] : []" />
:errors="formErrors(form, 'prices.0.price')" />
</Field>
</FieldGroup>
</CardContent>
@ -314,7 +292,7 @@ function submit() {
placeholder="Contoh: Premium / 40s" :maxlength="FIELD_LIMITS.variantName"
@update:model-value="setPriceField(price.client_id, 'variant', String($event))" />
<FieldError
:errors="priceError(price.client_id, 'variant') ? [priceError(price.client_id, 'variant')!] : []" />
:errors="formErrors(form, `prices.${index}.variant`)" />
</Field>
<Field v-if="method !== 'put'">
<FieldLabel :for="`stock_${price.client_id}`" required>
@ -323,7 +301,7 @@ function submit() {
<DecimalInput :id="`stock_${price.client_id}`" :model-value="price.stock"
@update:model-value="setPriceField(price.client_id, 'stock', String($event))" />
<FieldError
:errors="priceError(price.client_id, 'stock') ? [priceError(price.client_id, 'stock')!] : []" />
:errors="formErrors(form, `prices.${index}.stock`)" />
</Field>
<Field v-if="!useSamePrice || prices.length === 1">
<FieldLabel :for="`price_${price.client_id}`" required>
@ -332,14 +310,14 @@ function submit() {
<RupiahInput :id="`price_${price.client_id}`" :model-value="price.price"
@update:model-value="setPriceValue(price.client_id, $event)" />
<FieldError
:errors="priceError(price.client_id, 'price') ? [priceError(price.client_id, 'price')!] : []" />
:errors="formErrors(form, `prices.${index}.price`)" />
</Field>
</FieldSet>
<div class="mt-4">
<MultipleImageUploadField :id="`price_images_${price.client_id}`" v-model="price.media"
label="Foto Varian" :max-files="5" required
:errors="priceError(price.client_id, 'images') ? [priceError(price.client_id, 'images')!] : []" />
:errors="formErrors(form, `prices.${index}.images`)" />
</div>
</FieldGroup>
</CardContent>

View File

@ -1,8 +1,6 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { computed } from 'vue';
import DataTableActions from '@/components/admin/master/raw-materials/data-table-actions.vue';
import RawMaterialStatusToggle from '@/components/admin/master/raw-materials/raw-material-status-toggle.vue';
import DataTableToolbar from '@/components/data-table/DataTableToolbar.vue';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
import { Badge } from '@/components/ui/badge';
@ -28,6 +26,8 @@ import type {
DataTablePaginationLink,
} from '@/types/data-table';
import type { RawMaterialListItem } from '@/types/raw-material';
import DataTableActions from './data-table-actions.vue';
import RawMaterialStatusToggle from './raw-material-status-toggle.vue';
const props = defineProps<{
materials: RawMaterialListItem[];
@ -90,15 +90,17 @@ function rowNumber(index: number): number {
</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
<span>
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) => acc +
Total stok <strong class="text-primary"> {{material.prices.reduce((acc, price) =>
acc +
Number(price.stock), 0)
}}
{{ material.unit_abbreviation }}
</strong>
</span>
<span>
Total harga <strong class="text-primary"> Rp {{formatRupiah(material.prices.reduce((acc, price) => acc +
Number(price.stock) * Number(price.price), 0))
Total harga <strong class="text-primary"> Rp
{{formatRupiah(material.prices.reduce((acc, price) => acc +
Number(price.stock) * Number(price.price), 0))
}}
</strong>
</span>
@ -118,7 +120,7 @@ function rowNumber(index: number): number {
<TableHead>Varian</TableHead>
<TableHead>Foto</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Harga</TableHead>
<TableHead>Harga per Satuan</TableHead>
</TableRow>
</TableHeader>
<TableBody>

View File

@ -51,12 +51,8 @@ function destroyRawMaterial() {
<Tooltip v-if="can('raw-materials.delete')">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true"
>
<Button variant="ghost" size="icon" class="text-destructive hover:text-destructive size-8"
@click="deleteConfirmOpen = true">
<Trash2 class="size-4" />
<span class="sr-only">Hapus</span>
</Button>
@ -65,15 +61,8 @@ function destroyRawMaterial() {
</Tooltip>
</div>
<ConfirmDialog
v-if="can('raw-materials.delete')"
v-model:open="deleteConfirmOpen"
title="Hapus bahan baku?"
<ConfirmDialog v-if="can('raw-materials.delete')" v-model:open="deleteConfirmOpen" title="Hapus bahan baku?"
:description="`Bahan baku ${material.name} akan dihapus beserta seluruh variannya. Tindakan ini tidak dapat dibatalkan.`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyRawMaterial"
/>
confirm-label="Hapus" cancel-label="Batal" destructive :loading="deleteProcessing"
@confirm="destroyRawMaterial" />
</template>

View File

@ -49,11 +49,8 @@ function toggleStatus(checked: boolean) {
<template>
<div class="flex items-center gap-2">
<Switch
:model-value="isActive"
:disabled="processing || !can('raw-materials.toggle-status')"
@update:model-value="toggleStatus"
/>
<Switch :model-value="isActive" :disabled="processing || !can('raw-materials.toggle-status')"
@update:model-value="toggleStatus" />
<Badge :variant="isActive ? 'default' : 'secondary'">
{{ isActive ? 'Aktif' : 'Nonaktif' }}
</Badge>