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