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:
parent
e77f0ebc8a
commit
c757b3c9b2
@ -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,
|
||||
]),
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
8
package-lock.json
generated
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
19
resources/js/components/ui/collapsible/Collapsible.vue
Normal file
19
resources/js/components/ui/collapsible/Collapsible.vue
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
3
resources/js/components/ui/collapsible/index.ts
Normal file
3
resources/js/components/ui/collapsible/index.ts
Normal 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
14
resources/js/lib/stock.ts
Normal 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;
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
16
resources/js/types/master-inventory.ts
Normal file
16
resources/js/types/master-inventory.ts
Normal 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[];
|
||||
};
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user