refactor: improve raw material filtering logic in CuttingPosCombinationDialog to return only matching prices, enhancing search efficiency

This commit is contained in:
Yoga Pangestu 2026-07-09 15:09:02 +07:00
parent a3954faab6
commit b8e9aaa128
4 changed files with 142 additions and 63 deletions

View File

@ -5,78 +5,63 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { formatRupiah } from '@/lib/rupiah';
import type { FormWithErrors } from '@/lib/form';
import type { CuttingMaterialCartItem, CuttingResultCartItem } from '@/types/cutting';
import CuttingPosMaterialSummaryItems from './CuttingPosMaterialSummaryItems.vue';
import CuttingPosResultSummaryItems from './CuttingPosResultSummaryItems.vue';
defineProps<{
form: FormWithErrors & { description: string; processing?: boolean };
materialCart: CuttingMaterialCartItem[];
resultCart: CuttingResultCartItem[];
materialLineCost: (item: CuttingMaterialCartItem) => number;
totalMaterialCost: number;
totalResultPieces: number;
isCreateMode: boolean;
}>();
const emit = defineEmits<{
'remove-material': [index: number];
'sync-material-field': [index: number];
'remove-combination': [combinationId: number];
'sync-combination-result': [combinationId: number, result: number | null];
'remove-result': [index: number];
'sync-result-totals': [item: CuttingResultCartItem];
'sync-result-field': [index: number];
}>();
const open = defineModel<boolean>('open', { required: true });
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogContent class="sm:max-w-lg min-w-0 max-h-[90dvh] flex flex-col">
<DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader>
<div class="scrollbar-thin max-h-96 space-y-4 overflow-y-auto overscroll-y-contain">
<div class="scrollbar-thin flex-1 overflow-y-auto overscroll-y-contain pr-1 space-y-4">
<div v-if="materialCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Bahan Baku</p>
<div class="space-y-2">
<div v-for="item in materialCart" :key="`detail-mat-${item.raw_material_price_id}`"
class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant }} · Stok {{ item.stock_input }} {{ item.unit_abbreviation }}
</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums">
{{ item.material_usage }} {{ item.unit_abbreviation }}
</span>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ formatRupiah(materialLineCost(item))
}}</span>
</div>
</div>
</div>
<CuttingPosMaterialSummaryItems :form="form" :material-cart="materialCart"
@remove="emit('remove-material', $event)" @sync-field="emit('sync-material-field', $event)"
@remove-combination="emit('remove-combination', $event)"
@sync-combination-result="(combinationId, result) => emit('sync-combination-result', combinationId, result)" />
</div>
<Separator v-if="materialCart.length > 0 && resultCart.length > 0" />
<div v-if="resultCart.length > 0">
<p class="mb-2 text-xs font-medium text-muted-foreground">Hasil Produk</p>
<div class="space-y-2">
<div v-for="item in resultCart" :key="`detail-res-${item.product_variant_id}`"
class="rounded-lg border p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{{ item.product_name }}</p>
<p class="truncate text-xs text-muted-foreground">
{{ item.variant_name }} · Stok {{ item.stock }} pcs
</p>
</div>
</div>
<div v-if="!isCreateMode" class="mt-1.5 flex items-center gap-3 text-xs tabular-nums">
<span>Hasil: <strong>{{ item.cutting_result }}</strong></span>
<span class="text-muted-foreground">·</span>
<span>Sample: <strong>{{ item.sample }}</strong></span>
<span class="text-muted-foreground">·</span>
<span>Diluar Sample: <strong>{{ item.original_outside_sample }}</strong></span>
</div>
</div>
</div>
<CuttingPosResultSummaryItems :form="form" :result-cart="resultCart"
:total-result-pieces="totalResultPieces" :is-create-mode="isCreateMode"
@remove="emit('remove-result', $event)" @sync-totals="emit('sync-result-totals', $event)"
@sync-field="emit('sync-result-field', $event)" />
</div>
</div>
<div class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold">
<span>Total Biaya Bahan</span>
<span class="text-primary">{{ formatRupiah(totalMaterialCost) }}</span>
<span class="text-primary">Rp {{ formatRupiah(totalMaterialCost) }}</span>
</div>
</div>
</DialogContent>

View File

@ -62,11 +62,18 @@ const filteredRawMaterials = computed(() => {
return catalog;
}
return catalog.filter(
(rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
return catalog
.map((rawMaterial) => {
const matchingPrices = rawMaterial.prices.filter((price) =>
price.variant.toLowerCase().includes(keyword),
);
return {
...rawMaterial,
prices: matchingPrices,
};
})
.filter((rawMaterial) => rawMaterial.prices.length > 0);
});
function isSelected(priceId: number): boolean {
@ -186,7 +193,7 @@ watch(open, (value) => {
<div class="flex items-center gap-2 flex-1 min-w-0">
<PosCatalogVariantThumb :items="item.images" :title="`${item.raw_material_name} - ${item.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
<p class="text-sm font-medium">
{{ item.raw_material_name }} - {{ item.variant }}
</p>
<Badge variant="outline" class="text-xs mt-0.5 select-none font-normal">
@ -240,7 +247,7 @@ watch(open, (value) => {
]" @click="!isSelected(price.id) && toggleVariant(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" :title="`${rawMaterial.name} - ${price.variant}`" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm">{{ price.variant }}</p>
<p class="text-sm">{{ price.variant }}</p>
<p class="text-xs tabular-nums text-muted-foreground">
Stok: {{ price.stock_formatted }}
</p>

View File

@ -1,6 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { computed, ref, watch } from 'vue';
import { ShoppingCart } from '@lucide/vue';
import { toast } from 'vue-sonner';
import { parseRupiah } from '@/lib/rupiah';
import type {
@ -16,6 +17,7 @@ import CuttingPosCartDetailDialog from './CuttingPosCartDetailDialog.vue';
import CuttingPosMaterialCatalogPanel from './CuttingPosMaterialCatalogPanel.vue';
import CuttingPosResultCatalogPanel from './CuttingPosResultCatalogPanel.vue';
import CuttingPosSummaryPanel from './CuttingPosSummaryPanel.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { useCuttingPosCart } from './useCuttingPosCart';
const props = defineProps<{
@ -102,6 +104,65 @@ const {
isCreateMode: () => isCreateMode.value,
});
// Confirmation Dialog states
const showDeleteConfirm = ref(false);
const deleteType = ref<'material' | 'result' | 'combination' | null>(null);
const deleteIndex = ref<number | null>(null);
const deleteCombinationId = ref<number | null>(null);
const deleteConfirmTitle = computed(() => {
if (deleteType.value === 'combination') {
return 'Hapus Kombinasi?';
}
if (deleteType.value === 'material') {
return 'Hapus Bahan Baku?';
}
return 'Hapus Hasil Produk?';
});
const deleteConfirmDescription = computed(() => {
if (deleteType.value === 'combination') {
return 'Apakah Anda yakin ingin menghapus kombinasi bahan baku ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
}
if (deleteType.value === 'material') {
return 'Apakah Anda yakin ingin menghapus bahan baku ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
}
return 'Apakah Anda yakin ingin menghapus hasil produk ini dari keranjang? Tindakan ini tidak dapat dibatalkan.';
});
function confirmRemoveMaterial(index: number) {
deleteType.value = 'material';
deleteIndex.value = index;
showDeleteConfirm.value = true;
}
function confirmRemoveResult(index: number) {
deleteType.value = 'result';
deleteIndex.value = index;
showDeleteConfirm.value = true;
}
function confirmRemoveCombination(combinationId: number) {
deleteType.value = 'combination';
deleteCombinationId.value = combinationId;
showDeleteConfirm.value = true;
}
async function handleConfirmDelete() {
if (deleteType.value === 'material' && deleteIndex.value !== null) {
await removeMaterial(deleteIndex.value);
} else if (deleteType.value === 'result' && deleteIndex.value !== null) {
await removeResult(deleteIndex.value);
} else if (deleteType.value === 'combination' && deleteCombinationId.value !== null) {
await removeCombination(deleteCombinationId.value);
}
showDeleteConfirm.value = false;
deleteType.value = null;
deleteIndex.value = null;
deleteCombinationId.value = null;
}
function populateForm() {
if (!props.initialData) {
return;
@ -266,7 +327,7 @@ function onProductCreated(product: CuttingProductCatalogItem) {
<template>
<div class="grid min-w-0 gap-4 xl:grid-cols-[1fr_400px]">
<div class="min-w-0 space-y-4">
<div class="min-w-0 space-y-4 order-last xl:order-first">
<CuttingPosMaterialCatalogPanel v-model:material-search="materialSearch"
v-model:selected-raw-material-id="selectedRawMaterialId"
:filtered-raw-materials="filteredRawMaterials" :raw-material-catalog="rawMaterialCatalogState"
@ -279,16 +340,47 @@ function onProductCreated(product: CuttingProductCatalogItem) {
:categories="categories" :material-cart="materialCart" @decrease-result-qty="decreaseResultQty" @product-created="onProductCreated" />
</div>
<CuttingPosSummaryPanel :form="form" :material-cart="materialCart"
<CuttingPosSummaryPanel class="order-first xl:order-last" :form="form" :material-cart="materialCart"
:result-cart="resultCart" :total-result-pieces="totalResultPieces" :submit-label="submitLabel"
@submit="submit" :is-create-mode="isCreateMode"
@open-detail="cartDetailOpen = true" @remove-material="removeMaterial"
@sync-material-field="syncMaterialField" @remove-result="removeResult"
@open-detail="cartDetailOpen = true" @remove-material="confirmRemoveMaterial"
@sync-material-field="syncMaterialField" @remove-result="confirmRemoveResult"
@sync-result-totals="syncResultTotals" @sync-result-field="syncResultField"
@remove-combination="removeCombination" @sync-combination-result="syncCombinationResult" />
@remove-combination="confirmRemoveCombination" @sync-combination-result="syncCombinationResult" />
</div>
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :material-cart="materialCart" :result-cart="resultCart"
:material-line-cost="materialLineCost" :total-material-cost="totalMaterialCost"
:is-create-mode="isCreateMode" />
<CuttingPosCartDetailDialog v-model:open="cartDetailOpen" :form="form" :material-cart="materialCart" :result-cart="resultCart"
:material-line-cost="materialLineCost" :total-material-cost="totalMaterialCost" :total-result-pieces="totalResultPieces"
:is-create-mode="isCreateMode"
@remove-material="confirmRemoveMaterial"
@sync-material-field="syncMaterialField"
@remove-combination="confirmRemoveCombination"
@sync-combination-result="syncCombinationResult"
@remove-result="confirmRemoveResult"
@sync-result-totals="syncResultTotals"
@sync-result-field="syncResultField" />
<!-- Floating Cart button on mobile -->
<button
v-if="materialCart.length + resultCart.length > 0"
type="button"
class="fixed top-50 right-6 z-50 flex size-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-105 active:scale-95 xl:hidden"
@click="cartDetailOpen = true"
>
<ShoppingCart class="size-6" />
<span class="absolute -right-1 -top-1 flex size-6 items-center justify-center rounded-full bg-destructive text-[11px] font-bold text-destructive-foreground">
{{ (materialCart.length + resultCart.length) > 99 ? '99+' : (materialCart.length + resultCart.length) }}
</span>
</button>
<!-- Confirm deletion dialog -->
<ConfirmDialog
v-model:open="showDeleteConfirm"
:title="deleteConfirmTitle"
:description="deleteConfirmDescription"
confirm-label="Hapus"
cancel-label="Batal"
destructive
@confirm="handleConfirmDelete"
/>
</template>

View File

@ -53,11 +53,6 @@ const emit = defineEmits<{
Ringkasan Cutting
</span>
<span class="flex items-center gap-2">
<button v-if="materialCart.length > 0 || resultCart.length > 0" type="button"
class="text-xs font-normal text-primary underline underline-offset-2 hover:text-primary/80"
@click="emit('open-detail')">
Lihat Detail
</button>
<Badge v-if="!isCreateMode && totalResultPieces > 0" variant="secondary"
class="font-semibold tabular-nums">
Total: {{ totalResultPieces }} pcs