Implement stock warning functionality for products and raw materials. Add minStock method to RawMaterialUnit enum and ProductVariant model. Enhance ProductService and RawMaterialService with stockWarningGroups method to identify items below minimum stock levels. Update admin index pages to display stock warning groups in MasterOutOfStockCatalogSection component.

This commit is contained in:
Yoga Pangestu 2026-06-13 00:24:05 +07:00
parent a473a6644d
commit 521e8c5538
10 changed files with 175 additions and 12 deletions

View File

@ -12,6 +12,12 @@ enum RawMaterialUnit: string
case METER = 'meter';
case KILOGRAM = 'kilogram';
private const MIN_STOCK_YARD = 5.0;
private const MIN_STOCK_METER = 5.0;
private const MIN_STOCK_KILOGRAM = 2.0;
public function label(): string
{
return match ($this) {
@ -37,4 +43,13 @@ public static function values(): array
{
return array_column(self::cases(), 'value');
}
public function minStock(): float
{
return match ($this) {
self::YARD => self::MIN_STOCK_YARD,
self::METER => self::MIN_STOCK_METER,
self::KILOGRAM => self::MIN_STOCK_KILOGRAM,
};
}
}

View File

@ -33,6 +33,7 @@ public function index(Request $request): Response
return Inertia::render('admin/master/products/Index', [
'products' => $this->productService->paginateForIndex($tableQuery, $isActive),
'outOfStockGroups' => $this->productService->outOfStockGroups(),
'stockWarningGroups' => $this->productService->stockWarningGroups(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),

View File

@ -33,6 +33,7 @@ public function index(Request $request): Response
return Inertia::render('admin/master/raw-materials/Index', [
'rawMaterials' => $this->rawMaterialService->paginateForIndex($tableQuery, $isActive),
'outOfStockGroups' => $this->rawMaterialService->outOfStockGroups(),
'stockWarningGroups' => $this->rawMaterialService->stockWarningGroups(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),

View File

@ -20,6 +20,8 @@ class ProductVariant extends Model implements HasMedia
use InteractsWithActivityLog;
use SoftDeletes;
private const MIN_STOCK = 5;
protected function casts(): array
{
return [
@ -47,6 +49,11 @@ public function product(): BelongsTo
return $this->belongsTo(Product::class);
}
public static function minStock(): int
{
return self::MIN_STOCK;
}
public static function mediaModuleName(): string
{
return 'product';

View File

@ -34,12 +34,56 @@ public function __construct(
* }>
*/
public function outOfStockGroups(): array
{
return $this->inventoryAlertGroups(
fn (ProductVariant $variant): bool => $variant->stock <= 0,
);
}
/**
* @return list<array{
* id: int,
* name: 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 (ProductVariant $variant): bool {
if ($variant->stock <= 0) {
return false;
}
return $variant->stock < ProductVariant::minStock();
});
}
/**
* @return list<array{
* id: int,
* name: 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 ProductVariant::query()
->with(['product', 'media'])
->where('stock', '<=', 0)
->whereHas('product', fn (Builder $query) => $query->where('is_active', true))
->get()
->filter($filter)
->sortBy([
fn (ProductVariant $variant) => $variant->product->name,
fn (ProductVariant $variant) => $variant->name,

View File

@ -33,11 +33,61 @@ public function __construct(
* }>
*/
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()
->with(['rawMaterial', 'media'])
->where('stock', '<=', 0)
->get()
->filter($filter)
->sortBy([
fn (RawMaterialPrice $price) => $price->rawMaterial->name,
fn (RawMaterialPrice $price) => $price->variant,

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ChevronDown, PackageX } from '@lucide/vue';
import { ChevronDown, PackageX, TriangleAlert } from '@lucide/vue';
import { computed, ref } from 'vue';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
@ -19,19 +19,42 @@ import {
} from '@/components/ui/empty';
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
const props = defineProps<{
const props = withDefaults(defineProps<{
title: string;
description?: string;
groups: MasterOutOfStockGroup[];
}>();
severity?: 'danger' | 'warning';
emptyTitle?: string;
emptyDescription?: string;
}>(), {
severity: 'danger',
emptyTitle: 'Tidak ada stok habis',
emptyDescription: 'Semua varian masih memiliki stok tersedia.',
});
const open = ref(true);
const open = ref(false);
const variantCount = computed(() => props.groups.reduce(
(total, group) => total + group.variants.length,
0,
));
const iconClass = computed(() => (
props.severity === 'warning'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
));
const stockTextClass = computed(() => (
props.severity === 'warning'
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400'
));
const badgeVariant = computed(() => (
props.severity === 'warning' ? 'outline' : 'destructive'
));
function visitEdit(url: string): void {
router.visit(url);
}
@ -44,11 +67,12 @@ function visitEdit(url: string): void {
class="flex w-full items-center justify-between gap-3 px-6 py-4 text-left transition-colors hover:bg-muted/30">
<div class="min-w-0 space-y-1">
<div class="flex items-center gap-2">
<PackageX class="size-4 shrink-0 text-red-600 dark:text-red-400" />
<TriangleAlert v-if="severity === 'warning'" class="size-4 shrink-0" :class="iconClass" />
<PackageX v-else class="size-4 shrink-0" :class="iconClass" />
<h3 class="font-semibold leading-tight">
{{ title }}
</h3>
<Badge v-if="variantCount > 0" variant="destructive">
<Badge v-if="variantCount > 0" :variant="badgeVariant">
{{ variantCount }}
</Badge>
</div>
@ -80,7 +104,7 @@ function visitEdit(url: string): void {
<p class="truncate text-sm font-medium">
{{ variant.name }}
</p>
<p class="text-xs font-medium tabular-nums text-red-600 dark:text-red-400">
<p class="text-xs font-medium tabular-nums" :class="stockTextClass">
{{ variant.stock_formatted }}
</p>
</div>
@ -90,9 +114,9 @@ function visitEdit(url: string): void {
<Empty v-else class="py-8">
<EmptyHeader>
<EmptyTitle>Tidak ada stok habis</EmptyTitle>
<EmptyTitle>{{ emptyTitle }}</EmptyTitle>
<EmptyDescription>
Semua varian masih memiliki stok tersedia.
{{ emptyDescription }}
</EmptyDescription>
</EmptyHeader>
</Empty>

View File

@ -10,11 +10,12 @@ import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table';
import type { PaginatedProducts, ProductOutOfStockGroup } from '@/types/product';
import type { PaginatedProducts, ProductOutOfStockGroup, ProductStockWarningGroup } from '@/types/product';
const props = defineProps<{
products: PaginatedProducts;
outOfStockGroups: ProductOutOfStockGroup[];
stockWarningGroups: ProductStockWarningGroup[];
filters: {
search: string;
sort?: string;
@ -95,6 +96,15 @@ watch(
</Button>
</div>
<MasterOutOfStockCatalogSection
severity="warning"
title="Produk Stok Menipis"
description="Hanya produk aktif · stok masih ada, tapi di bawah batas minimum · klik kartu atau varian untuk edit"
empty-title="Tidak ada peringatan stok"
empty-description="Semua varian masih berada di atas batas stok minimum."
:groups="stockWarningGroups"
/>
<MasterOutOfStockCatalogSection
title="Produk Stok Habis"
description="Hanya produk aktif · klik kartu atau varian untuk edit"

View File

@ -16,6 +16,7 @@ import type { PaginatedRawMaterials } from '@/types/raw-material';
const props = defineProps<{
rawMaterials: PaginatedRawMaterials;
outOfStockGroups: MasterOutOfStockGroup[];
stockWarningGroups: MasterOutOfStockGroup[];
filters: {
search: string;
sort?: string;
@ -96,6 +97,15 @@ watch(
</Button>
</div>
<MasterOutOfStockCatalogSection
severity="warning"
title="Bahan Baku Stok Menipis"
description="Stok masih ada, tapi di bawah batas minimum satuan · klik kartu atau varian untuk edit"
empty-title="Tidak ada peringatan stok"
empty-description="Semua varian masih berada di atas batas stok minimum."
:groups="stockWarningGroups"
/>
<MasterOutOfStockCatalogSection
title="Bahan Baku Stok Habis"
description="Klik kartu atau varian untuk membuka halaman edit"

View File

@ -2,6 +2,7 @@ import type { MasterOutOfStockGroup } from '@/types/master-inventory';
import type { MediaItem, MediaUploadState } from '@/types/media';
export type { MasterOutOfStockGroup as ProductOutOfStockGroup };
export type { MasterOutOfStockGroup as ProductStockWarningGroup };
export type EnumOption = {
value: string;