Update dependencies and enhance out-of-stock product management. Upgrade @lucide/vue to version 1.18.0 in package.json and package-lock.json. Implement outOfStockGroups method in ProductService and RawMaterialService to retrieve out-of-stock items. Add MasterOutOfStockCatalogSection component for displaying out-of-stock products in the admin interface, and integrate it into the products and raw materials index pages.

This commit is contained in:
Yoga Pangestu 2026-06-13 00:09:27 +07:00
parent e77f0ebc8a
commit c757b3c9b2
17 changed files with 323 additions and 9 deletions

View File

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

View File

@ -32,6 +32,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(),
'filters' => $this->dataTableFilters($tableQuery, [
'is_active' => $isActive,
]),

View File

@ -20,6 +20,52 @@ public function __construct(
private readonly MediaService $mediaService,
) {}
/**
* @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 outOfStockGroups(): array
{
return ProductVariant::query()
->with(['product', 'media'])
->where('stock', '<=', 0)
->whereHas('product', fn (Builder $query) => $query->where('is_active', true))
->get()
->sortBy([
fn (ProductVariant $variant) => $variant->product->name,
fn (ProductVariant $variant) => $variant->name,
])
->groupBy('product_id')
->map(function ($variants, $productId) {
$product = $variants->first()->product;
return [
'id' => (int) $productId,
'name' => $product->name,
'edit_url' => route('admin.master.products.edit', $productId),
'variants' => $variants->map(function (ProductVariant $variant): array {
return [
'id' => $variant->id,
'name' => $variant->name,
'stock_formatted' => number_format($variant->stock, 0, ',', '.').' pcs',
'images' => MediaPresenter::collection($variant, 'images'),
];
})->values()->all(),
];
})
->values()
->all();
}
/**
* @param array{search: string, sort: string, direction: 'asc'|'desc'} $tableQuery
*/

View File

@ -18,6 +18,64 @@ 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 RawMaterialPrice::query()
->with(['rawMaterial', 'media'])
->where('stock', '<=', 0)
->get()
->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
*/

8
package-lock.json generated
View File

@ -12,7 +12,7 @@
"@fullcalendar/vue3": "^6.1.20",
"@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0",
"@lucide/vue": "^1.17.0",
"@lucide/vue": "^1.18.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
"@point-of-sale/webserial-receipt-printer": "^2.0.0",
@ -2618,9 +2618,9 @@
"license": "MIT"
},
"node_modules/@lucide/vue": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.17.0.tgz",
"integrity": "sha512-6Q1ZHgr5FbmJzKWe5BxlNdjLj2lbmuH1zwDtVzUJofX0w9UREwKgq4F4jwKqFYyyIS4Rj3FiJvDi2k6djukmmw==",
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/@lucide/vue/-/vue-1.18.0.tgz",
"integrity": "sha512-DmnUpDB85PlMZ+ofjZLcKq3JoJnaD1bk7SIj9xwUvqerfNqA6hCLa0/m3gIybH6rdrErABbqvTD8yYJdNqiZ3Q==",
"license": "ISC",
"peerDependencies": {
"vue": ">=3.0.1"

View File

@ -40,7 +40,7 @@
"@fullcalendar/vue3": "^6.1.20",
"@inertiajs/vite": "^3.0.0",
"@inertiajs/vue3": "^3.0.0",
"@lucide/vue": "^1.17.0",
"@lucide/vue": "^1.18.0",
"@point-of-sale/receipt-printer-encoder": "^3.0.3",
"@point-of-sale/webbluetooth-receipt-printer": "^2.0.0",
"@point-of-sale/webserial-receipt-printer": "^2.0.0",

View File

@ -2,15 +2,18 @@
import { Package } from '@lucide/vue';
import type { MediaItem } from '@/types/media';
defineProps<{
withDefaults(defineProps<{
title: string;
coverImage?: MediaItem;
}>();
showCover?: boolean;
}>(), {
showCover: true,
});
</script>
<template>
<article class="mb-4 break-inside-avoid overflow-hidden rounded-lg border bg-card shadow-sm">
<div class="relative aspect-4/3 overflow-hidden bg-muted/30">
<div v-if="showCover" class="relative aspect-4/3 overflow-hidden bg-muted/30">
<img
v-if="coverImage"
:src="coverImage.thumb_url"

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { ChevronDown, PackageX } from '@lucide/vue';
import { computed, ref } from 'vue';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
const props = defineProps<{
title: string;
description?: string;
groups: MasterOutOfStockGroup[];
}>();
const open = ref(true);
const variantCount = computed(() => props.groups.reduce(
(total, group) => total + group.variants.length,
0,
));
function visitEdit(url: string): void {
router.visit(url);
}
</script>
<template>
<Collapsible v-model:open="open">
<Card>
<CollapsibleTrigger
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" />
<h3 class="font-semibold leading-tight">
{{ title }}
</h3>
<Badge v-if="variantCount > 0" variant="destructive">
{{ variantCount }}
</Badge>
</div>
<p v-if="description" class="text-xs text-muted-foreground">
{{ description }}
</p>
</div>
<ChevronDown class="size-4 shrink-0 text-muted-foreground transition-transform duration-200"
:class="open ? 'rotate-180' : ''" />
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent class="pt-0">
<div v-if="groups.length" class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard v-for="group in groups" :key="group.id" :title="group.name" :show-cover="false"
class="cursor-pointer transition-shadow hover:shadow-md" @click="visitEdit(group.edit_url)">
<template v-if="group.unit_label" #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ group.unit_label }}
</Badge>
</template>
<div v-for="variant in group.variants" :key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-muted/30"
@click.stop="visitEdit(group.edit_url)">
<PosCatalogVariantThumb :items="variant.images" />
<div class="min-w-0 flex-1">
<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">
{{ variant.stock_formatted }}
</p>
</div>
</div>
</PosCatalogCard>
</div>
<Empty v-else class="py-8">
<EmptyHeader>
<EmptyTitle>Tidak ada stok habis</EmptyTitle>
<EmptyDescription>
Semua varian masih memiliki stok tersedia.
</EmptyDescription>
</EmptyHeader>
</Empty>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
</template>

View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import type { CollapsibleRootEmits, CollapsibleRootProps } from "reka-ui"
import { CollapsibleRoot, useForwardPropsEmits } from "reka-ui"
const props = defineProps<CollapsibleRootProps>()
const emits = defineEmits<CollapsibleRootEmits>()
const forwarded = useForwardPropsEmits(props, emits)
</script>
<template>
<CollapsibleRoot
v-slot="slotProps"
data-slot="collapsible"
v-bind="forwarded"
>
<slot v-bind="slotProps" />
</CollapsibleRoot>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleContentProps } from "reka-ui"
import { CollapsibleContent } from "reka-ui"
const props = defineProps<CollapsibleContentProps>()
</script>
<template>
<CollapsibleContent
data-slot="collapsible-content"
v-bind="props"
>
<slot />
</CollapsibleContent>
</template>

View File

@ -0,0 +1,15 @@
<script setup lang="ts">
import type { CollapsibleTriggerProps } from "reka-ui"
import { CollapsibleTrigger } from "reka-ui"
const props = defineProps<CollapsibleTriggerProps>()
</script>
<template>
<CollapsibleTrigger
data-slot="collapsible-trigger"
v-bind="props"
>
<slot />
</CollapsibleTrigger>
</template>

View File

@ -0,0 +1,3 @@
export { default as Collapsible } from "./Collapsible.vue"
export { default as CollapsibleContent } from "./CollapsibleContent.vue"
export { default as CollapsibleTrigger } from "./CollapsibleTrigger.vue"

14
resources/js/lib/stock.ts Normal file
View File

@ -0,0 +1,14 @@
export function formatStockAmount(value: number | string, unit?: string): string {
const numericValue = typeof value === 'string' ? Number.parseFloat(value) : value;
if (!Number.isFinite(numericValue)) {
return unit ? `0 ${unit}` : '0';
}
const formatted = numericValue.toLocaleString('id-ID', {
minimumFractionDigits: 0,
maximumFractionDigits: 4,
});
return unit ? `${formatted} ${unit}` : formatted;
}

View File

@ -2,6 +2,7 @@
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 ProductGroupedTable from '@/components/admin/master/products/ProductGroupedTable.vue';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
@ -9,10 +10,11 @@ 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 } from '@/types/product';
import type { PaginatedProducts, ProductOutOfStockGroup } from '@/types/product';
const props = defineProps<{
products: PaginatedProducts;
outOfStockGroups: ProductOutOfStockGroup[];
filters: {
search: string;
sort?: string;
@ -93,6 +95,12 @@ watch(
</Button>
</div>
<MasterOutOfStockCatalogSection
title="Produk Stok Habis"
description="Hanya produk aktif · klik kartu atau varian untuk edit"
:groups="outOfStockGroups"
/>
<Card class="min-w-0">
<CardContent class="min-w-0">
<ProductGroupedTable v-model:search="search" :products="products.data" :first-item="firstItem"

View File

@ -2,6 +2,7 @@
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';
@ -9,10 +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 { MasterOutOfStockGroup } from '@/types/master-inventory';
import type { PaginatedRawMaterials } from '@/types/raw-material';
const props = defineProps<{
rawMaterials: PaginatedRawMaterials;
outOfStockGroups: MasterOutOfStockGroup[];
filters: {
search: string;
sort?: string;
@ -93,6 +96,12 @@ watch(
</Button>
</div>
<MasterOutOfStockCatalogSection
title="Bahan Baku Stok Habis"
description="Klik kartu atau varian untuk membuka halaman edit"
:groups="outOfStockGroups"
/>
<Card class="min-w-0">
<CardContent class="min-w-0">
<RawMaterialGroupedTable v-model:search="search" :materials="rawMaterials.data" :first-item="firstItem"

View File

@ -0,0 +1,16 @@
import type { MediaItem } from '@/types/media';
export type MasterOutOfStockVariant = {
id: number;
name: string;
stock_formatted: string;
images?: MediaItem[];
};
export type MasterOutOfStockGroup = {
id: number;
name: string;
edit_url: string;
unit_label?: string;
variants: MasterOutOfStockVariant[];
};

View File

@ -1,5 +1,8 @@
import type { MasterOutOfStockGroup } from '@/types/master-inventory';
import type { MediaItem, MediaUploadState } from '@/types/media';
export type { MasterOutOfStockGroup as ProductOutOfStockGroup };
export type EnumOption = {
value: string;
label: string;