Compare commits

..

No commits in common. "d2ddc5e6b79aba56a31cd0668f476bb0a4c6e74b" and "bd51f9abe027b27a15d6ae04e81847e5eafdff43" have entirely different histories.

9 changed files with 79 additions and 193 deletions

View File

@ -138,8 +138,7 @@ public function rawMaterialCatalog(?Cutting $cutting = null, ?User $user = null)
->with([ ->with([
'prices' => fn ($query) => $query 'prices' => fn ($query) => $query
->orderBy('created_at') ->orderBy('created_at')
->with('media') ->with('media'),
->where(fn ($q) => $q->where('stock', '>', 0)->orWhereIn('id', $selectedPriceIds)),
]) ])
->where(function (Builder $query) use ($selectedPriceIds): void { ->where(function (Builder $query) use ($selectedPriceIds): void {
$query->active(); $query->active();

View File

@ -158,13 +158,7 @@ public function catalogItems(?Order $order = null, ?User $user = null): Collecti
->with([ ->with([
'variants' => fn ($query) => $query 'variants' => fn ($query) => $query
->with('media') ->with('media')
->orderBy('created_at') ->orderBy('created_at'),
->where(fn ($q) => $q
->where('stock', '>', 0)
->orWhere('reject_stock', '>', 0)
->orWhere('retail_stock', '>', 0)
->orWhereIn('id', $orderVariantIds)
),
]) ])
->where(function (Builder $query) use ($orderVariantIds): void { ->where(function (Builder $query) use ($orderVariantIds): void {
$query->active(); $query->active();
@ -863,13 +857,13 @@ private function presentVariantPricesFromCollection(int $variantId, Collection $
return $allPricesByVariant return $allPricesByVariant
->get($variantId, collect()) ->get($variantId, collect())
->map(fn ($price) => [ ->map(fn ($price) => [
'type' => is_array($price) ? $price['price_type'] : $price->price_type->value, 'type' => $price->price_type->value,
'type_label' => PriceType::from(is_array($price) ? $price['price_type'] : $price->price_type->value)->label(), 'type_label' => $price->price_type->label(),
'price' => (int) (is_array($price) ? $price['price'] : $price->price), 'price' => (int) $price->price,
'price_formatted' => is_array($price) ? $price['price_formatted'] : $price->price_formatted, 'price_formatted' => $price->price_formatted,
'price_input' => (string) (is_array($price) ? $price['price'] : $price->price), 'price_input' => (string) $price->price,
'cost_per_unit' => (int) (is_array($price) ? ($price['cost_per_unit'] ?? $price['price']) : $price->cost_per_unit), 'cost_per_unit' => (int) $price->cost_per_unit,
'cost_per_unit_formatted' => is_array($price) ? ($price['cost_per_unit_formatted'] ?? $price['price_formatted']) : $price->cost_per_unit_formatted, 'cost_per_unit_formatted' => $price->cost_per_unit_formatted,
]) ])
->values() ->values()
->all(); ->all();

View File

@ -11,15 +11,14 @@ self.addEventListener('install', () => {
// ── Activate ───────────────────────────────────────────────────────────────── // ── Activate ─────────────────────────────────────────────────────────────────
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event) => {
event.waitUntil( event.waitUntil(
(async () => { caches
const keys = await caches.keys(); .keys()
await Promise.all(keys.map((k) => caches.delete(k))); .then((keys) =>
Promise.all(
await self.registration.unregister(); keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)),
),
const clientList = await self.clients.matchAll({ type: 'window' }); )
clientList.forEach((client) => client.navigate(client.url)); .then(() => self.clients.claim()),
})(),
); );
}); });

View File

@ -77,10 +77,9 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</Empty> </Empty>
</div> </div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3"> <div v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<template v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"> <PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
<PosCatalogCard v-if="rawMaterial.prices.length > 0" :title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra> <template #header-extra>
<Badge variant="secondary" class="mt-1.5"> <Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }} {{ rawMaterial.unit_label }}
@ -149,7 +148,6 @@ function openCombination(rawMaterial: CuttingRawMaterialCatalogItem, price: Cutt
</div> </div>
</div> </div>
</PosCatalogCard> </PosCatalogCard>
</template>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@ -85,66 +85,68 @@ const emit = defineEmits<{
</div> </div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3"> <div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<template v-for="product in filteredCatalog" :key="product.id"> <PosCatalogCard
<PosCatalogCard v-for="product in filteredCatalog"
v-if="product.variants.length > 0" :key="product.id"
:title="product.name" :title="product.name"
:cover-image="getFirstCoverImage(product.variants)" :cover-image="getFirstCoverImage(product.variants)"
>
<p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div
v-for="variant in product.variants"
:key="variant.id"
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
getVariantPrice(variant) ? 'cursor-pointer hover:bg-muted/30' : 'opacity-60',
getCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : '',
]"
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)"
> >
<div <PosCatalogVariantThumb :items="variant.images" />
v-for="variant in product.variants" <div class="min-w-0 flex-1">
:key="variant.id" <p class="truncate text-sm font-medium">
class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200" {{ variant.name }}
:class="[ </p>
getVariantPrice(variant) ? 'cursor-pointer hover:bg-muted/30' : 'opacity-60', <p class="text-xs text-muted-foreground">
getCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : '', <span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.retail_stock ?? 0 }}</span>
]" <template v-else>
@click="getVariantPrice(variant) && !getCartItem(variant.id) && emit('add-to-cart', product, variant)" <span class="tabular-nums">Bagus: {{ variant.stock }}</span>
>
<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 text-muted-foreground">
<span v-if="isCashierUser" class="tabular-nums">Ecer: {{ variant.retail_stock ?? 0 }}</span>
<template v-else>
<span class="tabular-nums">Bagus: {{ variant.stock }}</span>
<span class="mx-1">·</span>
<span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
</template>
<span class="mx-1">·</span> <span class="mx-1">·</span>
<span v-if="getVariantPrice(variant)" class="tabular-nums"> <span class="tabular-nums">Reject: {{ variant.reject_stock ?? 0 }}</span>
{{ getVariantPrice(variant)!.price_formatted }} </template>
</span> <span class="mx-1">·</span>
<span v-else>-</span> <span v-if="getVariantPrice(variant)" class="tabular-nums">
</p> {{ getVariantPrice(variant)!.price_formatted }}
</div>
<div v-if="getCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button type="button" variant="outline" size="icon-sm" @click.stop="emit('decrease-qty', variant.id)">
<Minus class="size-3.5" />
</Button>
<span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
{{ getCartItem(variant.id)!.quantity }}
</span> </span>
<Button type="button" variant="outline" size="icon-sm" @click.stop="emit('add-to-cart', product, variant)"> <span v-else>-</span>
<Plus class="size-3.5" /> </p>
</Button> </div>
</div> <div v-if="getCartItem(variant.id)" class="flex shrink-0 items-center gap-1.5">
<Button <Button type="button" variant="outline" size="icon-sm" @click.stop="emit('decrease-qty', variant.id)">
v-else <Minus class="size-3.5" />
type="button" </Button>
variant="outline" <span class="min-w-[1.25rem] text-center text-xs font-semibold tabular-nums">
size="icon-sm" {{ getCartItem(variant.id)!.quantity }}
class="shrink-0" </span>
:disabled="!getVariantPrice(variant)" <Button type="button" variant="outline" size="icon-sm" @click.stop="emit('add-to-cart', product, variant)">
@click.stop="emit('add-to-cart', product, variant)"
>
<Plus class="size-3.5" /> <Plus class="size-3.5" />
</Button> </Button>
</div> </div>
</PosCatalogCard> <Button
</template> v-else
type="button"
variant="outline"
size="icon-sm"
class="shrink-0"
:disabled="!getVariantPrice(variant)"
@click.stop="emit('add-to-cart', product, variant)"
>
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -125,7 +125,6 @@ function openVerificationDetail(requestId: number | undefined) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-10">#</TableHead>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead>Foto</TableHead> <TableHead>Foto</TableHead>
<TableHead>Stok Bagus</TableHead> <TableHead>Stok Bagus</TableHead>
@ -140,10 +139,7 @@ function openVerificationDetail(requestId: number | undefined) {
Belum ada varian Belum ada varian
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="(variant, variantIndex) in product.variants" :key="variant.id"> <TableRow v-for="variant in product.variants" :key="variant.id">
<TableCell class="text-muted-foreground tabular-nums text-center">
{{ variantIndex + 1 }}
</TableCell>
<TableCell class="font-medium"> <TableCell class="font-medium">
{{ variant.name }} {{ variant.name }}
</TableCell> </TableCell>

View File

@ -109,7 +109,6 @@ function openVerificationDetail(requestId: number | undefined) {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-10">#</TableHead>
<TableHead>Varian</TableHead> <TableHead>Varian</TableHead>
<TableHead>Foto</TableHead> <TableHead>Foto</TableHead>
<TableHead>Stok</TableHead> <TableHead>Stok</TableHead>
@ -118,14 +117,11 @@ function openVerificationDetail(requestId: number | undefined) {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
<TableRow v-if="!material.prices.length" :key="`${material.id}-empty`"> <TableRow v-if="!material.prices.length" :key="`${material.id}-empty`">
<TableCell colspan="5" class="text-muted-foreground"> <TableCell colspan="4" class="text-muted-foreground">
Belum ada varian Belum ada varian
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow v-for="(price, priceIndex) in material.prices" :key="price.id"> <TableRow v-for="price in material.prices" :key="price.id">
<TableCell class="text-muted-foreground tabular-nums text-center">
{{ priceIndex + 1 }}
</TableCell>
<TableCell class="font-medium"> <TableCell class="font-medium">
{{ price.variant }} {{ price.variant }}
</TableCell> </TableCell>

View File

@ -1122,49 +1122,3 @@ function setupDraftItems(User $user): array
expect($combination->material_result)->toBe(15); expect($combination->material_result)->toBe(15);
}); });
}); });
// ─── Raw Material Catalog Stock Filter ───────────────────────
describe('Raw Material Catalog Stock Filter', function () {
test('rawMaterialCatalog excludes variants with zero stock', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_CREATE);
$rawMaterial = RawMaterial::factory()->create(['is_active' => true]);
$priceWithStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 10]);
$priceWithoutStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 0]);
$this->actingAs($user)
->get(route('admin.manage.cuttings.create'))
->assertOk();
$service = app(\App\Services\Manage\CuttingService::class);
$catalog = $service->rawMaterialCatalog(user: $user);
$material = $catalog->firstWhere('id', $rawMaterial->id);
$priceIds = $material->prices->pluck('id')->all();
expect($priceIds)->toContain($priceWithStock->id);
expect($priceIds)->not->toContain($priceWithoutStock->id);
});
test('rawMaterialCatalog includes zero-stock variants already used in cutting', function () {
$user = createCuttingUserWithPermission(PermissionEnum::CUTTINGS_VIEW, PermissionEnum::CUTTINGS_UPDATE);
$rawMaterial = RawMaterial::factory()->create(['is_active' => true]);
$priceZeroStock = RawMaterialPrice::factory()->create(['raw_material_id' => $rawMaterial->id, 'stock' => 0]);
$cutting = Cutting::factory()->create(['created_by_id' => $user->id]);
CuttingMaterial::factory()->create([
'cutting_id' => $cutting->id,
'raw_material_price_id' => $priceZeroStock->id,
]);
$service = app(\App\Services\Manage\CuttingService::class);
$catalog = $service->rawMaterialCatalog($cutting);
$material = $catalog->firstWhere('id', $rawMaterial->id);
$priceIds = $material?->prices->pluck('id')->all() ?? [];
expect($priceIds)->toContain($priceZeroStock->id);
});
});

View File

@ -613,55 +613,3 @@ function setupOrderDraftItems(User $user): ProductVariant
expect($item->trashed())->toBeTrue(); expect($item->trashed())->toBeTrue();
}); });
}); });
// ─── Order Catalog Stock Filter ─────────────────────────────
describe('Order Catalog Stock Filter', function () {
test('catalogItems excludes variants where all stock types are zero', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_CREATE);
$product = Product::factory()->create(['is_active' => true]);
$variantWithStock = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 10, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantAllZero = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 0]);
$variantRejectOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 5, 'retail_stock' => 0]);
$variantRetailOnly = ProductVariant::factory()->create(['product_id' => $product->id, 'stock' => 0, 'reject_stock' => 0, 'retail_stock' => 3]);
$service = app(\App\Services\Manage\OrderService::class);
$catalog = $service->catalogItems(user: $user);
$found = $catalog->firstWhere('id', $product->id);
$variantIds = $found?->variants->pluck('id')->all() ?? [];
expect($variantIds)->toContain($variantWithStock->id);
expect($variantIds)->toContain($variantRejectOnly->id);
expect($variantIds)->toContain($variantRetailOnly->id);
expect($variantIds)->not->toContain($variantAllZero->id);
});
test('catalogItems includes all-zero-stock variants already in order', function () {
$user = createOrderUserWithPermission(PermissionEnum::ORDERS_VIEW, PermissionEnum::ORDERS_UPDATE);
$product = Product::factory()->create(['is_active' => true]);
$variantAllZero = ProductVariant::factory()->create([
'product_id' => $product->id,
'stock' => 0,
'reject_stock' => 0,
'retail_stock' => 0,
]);
$order = Order::factory()->create(['created_by_id' => $user->id]);
OrderItem::factory()->create([
'order_id' => $order->id,
'product_variant_id' => $variantAllZero->id,
]);
$service = app(\App\Services\Manage\OrderService::class);
$catalog = $service->catalogItems($order);
$found = $catalog->firstWhere('id', $product->id);
$variantIds = $found?->variants->pluck('id')->all() ?? [];
expect($variantIds)->toContain($variantAllZero->id);
});
});