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 METER = 'meter';
case KILOGRAM = 'kilogram'; 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 public function label(): string
{ {
return match ($this) { return match ($this) {
@ -37,4 +43,13 @@ public static function values(): array
{ {
return array_column(self::cases(), 'value'); 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', [ return Inertia::render('admin/master/products/Index', [
'products' => $this->productService->paginateForIndex($tableQuery, $isActive), 'products' => $this->productService->paginateForIndex($tableQuery, $isActive),
'outOfStockGroups' => $this->productService->outOfStockGroups(), 'outOfStockGroups' => $this->productService->outOfStockGroups(),
'stockWarningGroups' => $this->productService->stockWarningGroups(),
'filters' => $this->dataTableFilters($tableQuery, [ 'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive, 'is_active' => $isActive,
]), ]),

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { ChevronDown, PackageX } from '@lucide/vue'; import { ChevronDown, PackageX, TriangleAlert } from '@lucide/vue';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue'; import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue'; import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
@ -19,19 +19,42 @@ import {
} from '@/components/ui/empty'; } from '@/components/ui/empty';
import type { MasterOutOfStockGroup } from '@/types/master-inventory'; import type { MasterOutOfStockGroup } from '@/types/master-inventory';
const props = defineProps<{ const props = withDefaults(defineProps<{
title: string; title: string;
description?: string; description?: string;
groups: MasterOutOfStockGroup[]; 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( const variantCount = computed(() => props.groups.reduce(
(total, group) => total + group.variants.length, (total, group) => total + group.variants.length,
0, 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 { function visitEdit(url: string): void {
router.visit(url); 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"> 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="min-w-0 space-y-1">
<div class="flex items-center gap-2"> <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"> <h3 class="font-semibold leading-tight">
{{ title }} {{ title }}
</h3> </h3>
<Badge v-if="variantCount > 0" variant="destructive"> <Badge v-if="variantCount > 0" :variant="badgeVariant">
{{ variantCount }} {{ variantCount }}
</Badge> </Badge>
</div> </div>
@ -80,7 +104,7 @@ function visitEdit(url: string): void {
<p class="truncate text-sm font-medium"> <p class="truncate text-sm font-medium">
{{ variant.name }} {{ variant.name }}
</p> </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 }} {{ variant.stock_formatted }}
</p> </p>
</div> </div>
@ -90,9 +114,9 @@ function visitEdit(url: string): void {
<Empty v-else class="py-8"> <Empty v-else class="py-8">
<EmptyHeader> <EmptyHeader>
<EmptyTitle>Tidak ada stok habis</EmptyTitle> <EmptyTitle>{{ emptyTitle }}</EmptyTitle>
<EmptyDescription> <EmptyDescription>
Semua varian masih memiliki stok tersedia. {{ emptyDescription }}
</EmptyDescription> </EmptyDescription>
</EmptyHeader> </EmptyHeader>
</Empty> </Empty>

View File

@ -10,11 +10,12 @@ import { useCan } from '@/composables/useCan';
import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery'; import { useDataTableQuery, useDataTableQuerySync } from '@/composables/useDataTableQuery';
import AdminLayout from '@/layouts/AdminLayout.vue'; import AdminLayout from '@/layouts/AdminLayout.vue';
import type { DataTableFilterDef } from '@/types/data-table'; 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<{ const props = defineProps<{
products: PaginatedProducts; products: PaginatedProducts;
outOfStockGroups: ProductOutOfStockGroup[]; outOfStockGroups: ProductOutOfStockGroup[];
stockWarningGroups: ProductStockWarningGroup[];
filters: { filters: {
search: string; search: string;
sort?: string; sort?: string;
@ -95,6 +96,15 @@ watch(
</Button> </Button>
</div> </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 <MasterOutOfStockCatalogSection
title="Produk Stok Habis" title="Produk Stok Habis"
description="Hanya produk aktif · klik kartu atau varian untuk edit" 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<{ const props = defineProps<{
rawMaterials: PaginatedRawMaterials; rawMaterials: PaginatedRawMaterials;
outOfStockGroups: MasterOutOfStockGroup[]; outOfStockGroups: MasterOutOfStockGroup[];
stockWarningGroups: MasterOutOfStockGroup[];
filters: { filters: {
search: string; search: string;
sort?: string; sort?: string;
@ -96,6 +97,15 @@ watch(
</Button> </Button>
</div> </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 <MasterOutOfStockCatalogSection
title="Bahan Baku Stok Habis" title="Bahan Baku Stok Habis"
description="Klik kartu atau varian untuk membuka halaman edit" 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'; import type { MediaItem, MediaUploadState } from '@/types/media';
export type { MasterOutOfStockGroup as ProductOutOfStockGroup }; export type { MasterOutOfStockGroup as ProductOutOfStockGroup };
export type { MasterOutOfStockGroup as ProductStockWarningGroup };
export type EnumOption = { export type EnumOption = {
value: string; value: string;