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:
parent
a473a6644d
commit
521e8c5538
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user