feat: implement quantity increment/decrement controls and update UI for cutting material and product selection

This commit is contained in:
Yoga Pangestu 2026-06-13 12:28:37 +07:00
parent bd38bbb74e
commit 9329312fd3

View File

@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { useForm } from '@inertiajs/vue3'; import { useForm } from '@inertiajs/vue3';
import { Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue'; import { Minus, Plus, Save, Scissors, Search, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue'; import MediaThumbnailCell from '@/components/media/MediaThumbnailCell.vue';
@ -31,6 +31,9 @@ import {
TableRow, TableRow,
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { FIELD_LIMITS } from '@/lib/field-limits'; import { FIELD_LIMITS } from '@/lib/field-limits';
import { formErrors } from '@/lib/form'; import { formErrors } from '@/lib/form';
import type { import type {
@ -108,14 +111,32 @@ const filteredProducts = computed(() => {
); );
}); });
function getMaterialCartItem(priceId: number): CuttingMaterialCartItem | undefined {
return materialCart.value.find((item) => item.raw_material_price_id === priceId);
}
function decreaseMaterialQty(priceId: number) {
const index = materialCart.value.findIndex(
(item) => item.raw_material_price_id === priceId,
);
if (index !== -1) {
const item = materialCart.value[index];
const nextQty = (Number(item.material_usage) || 0) - 1;
if (nextQty <= 0) {
removeMaterial(index);
} else {
item.material_usage = String(nextQty);
}
}
}
function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) { function addMaterial(rawMaterial: CuttingRawMaterialCatalogItem, price: CatalogPrice) {
const existing = materialCart.value.find( const existing = materialCart.value.find(
(item) => item.raw_material_price_id === price.id, (item) => item.raw_material_price_id === price.id,
); );
if (existing) { if (existing) {
toast.error('Bahan baku ini sudah ditambahkan.'); existing.material_usage = String((Number(existing.material_usage) || 0) + 1);
return; return;
} }
@ -135,14 +156,37 @@ function removeMaterial(index: number) {
materialCart.value.splice(index, 1); materialCart.value.splice(index, 1);
} }
function getResultCartItem(variantId: number): CuttingResultCartItem | undefined {
return resultCart.value.find((item) => item.product_variant_id === variantId);
}
function decreaseResultQty(variantId: number) {
const index = resultCart.value.findIndex(
(item) => item.product_variant_id === variantId,
);
if (index !== -1) {
const item = resultCart.value[index];
const nextQty = (Number(item.cutting_result) || 0) - 1;
if (nextQty <= 0) {
removeResult(index);
} else {
item.cutting_result = String(nextQty);
// also adjust warehouse stock or run sync totals
item.warehouse_stock = String(Math.max(0, (Number(item.warehouse_stock) || 0) - 1));
syncResultTotals(item);
}
}
}
function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) { function addResult(product: CuttingProductCatalogItem, variant: CuttingProductCatalogItem['variants'][number]) {
const existing = resultCart.value.find( const existing = resultCart.value.find(
(item) => item.product_variant_id === variant.id, (item) => item.product_variant_id === variant.id,
); );
if (existing) { if (existing) {
toast.error('Varian produk ini sudah ditambahkan.'); existing.cutting_result = String((Number(existing.cutting_result) || 0) + 1);
existing.warehouse_stock = String((Number(existing.warehouse_stock) || 0) + 1);
syncResultTotals(existing);
return; return;
} }
@ -253,53 +297,53 @@ function submit() {
</Empty> </Empty>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="columns-1 gap-4 sm:columns-2">
<div v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id" <PosCatalogCard v-for="rawMaterial in filteredRawMaterials" :key="rawMaterial.id"
class="overflow-hidden rounded-md border"> :title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<div class="border-b bg-muted/30 px-4 py-3"> <template #header-extra>
<div class="flex flex-wrap items-center gap-2"> <Badge variant="secondary" class="mt-1.5">
<h3 class="font-medium leading-tight"> {{ rawMaterial.unit_label }}
{{ rawMaterial.name }} </Badge>
</h3> </template>
<Badge variant="secondary">
{{ rawMaterial.unit_label }}
</Badge>
</div>
</div>
<Table> <p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
<TableHeader> Belum ada varian
<TableRow> </p>
<TableHead>Varian</TableHead> <div v-for="price in rawMaterial.prices" :key="price.id"
<TableHead>Stok</TableHead> class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
<TableHead>Foto</TableHead> :class="[
<TableHead class="w-24 text-right" /> 'cursor-pointer hover:bg-muted/30',
</TableRow> getMaterialCartItem(price.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
</TableHeader> ]"
<TableBody> @click="!getMaterialCartItem(price.id) && addMaterial(rawMaterial, price)">
<TableRow v-for="price in rawMaterial.prices" :key="price.id" <PosCatalogVariantThumb :items="price.images" />
class="cursor-pointer hover:bg-muted/30" <div class="min-w-0 flex-1">
@click="addMaterial(rawMaterial, price)"> <p class="truncate text-sm font-medium">
<TableCell class="font-medium"> {{ price.variant }}
{{ price.variant }} </p>
</TableCell> <p class="text-xs tabular-nums text-muted-foreground">
<TableCell class="tabular-nums"> Stok: {{ price.stock_formatted }}
{{ price.stock_formatted }} </p>
</TableCell> </div>
<TableCell> <div v-if="getMaterialCartItem(price.id)" class="flex items-center gap-1.5 shrink-0">
<MediaThumbnailCell :items="price.images ?? []" /> <Button type="button" variant="outline" size="icon-sm"
</TableCell> @click.stop="decreaseMaterialQty(price.id)">
<TableCell class="text-right"> <Minus class="size-3.5" />
<Button type="button" variant="outline" size="sm" </Button>
@click.stop="addMaterial(rawMaterial, price)"> <span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
<Plus class="size-3.5" /> {{ getMaterialCartItem(price.id)!.material_usage }}
Tambah </span>
</Button> <Button type="button" variant="outline" size="icon-sm"
</TableCell> @click.stop="addMaterial(rawMaterial, price)">
</TableRow> <Plus class="size-3.5" />
</TableBody> </Button>
</Table> </div>
</div> <Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="addMaterial(rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -327,48 +371,47 @@ function submit() {
</Empty> </Empty>
</div> </div>
<div v-else class="space-y-4"> <div v-else class="columns-1 gap-4 sm:columns-2">
<div v-for="product in filteredProducts" :key="product.id" <PosCatalogCard v-for="product in filteredProducts" :key="product.id" :title="product.name"
class="overflow-hidden rounded-md border"> :cover-image="getFirstCoverImage(product.variants)">
<div class="border-b bg-muted/30 px-4 py-3"> <p v-if="!product.variants.length" class="px-3 py-4 text-sm text-muted-foreground">
<h3 class="font-medium leading-tight"> Belum ada varian
{{ product.name }} </p>
</h3> <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="[
'cursor-pointer hover:bg-muted/30',
getResultCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
]"
@click="!getResultCartItem(variant.id) && addResult(product, variant)">
<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 class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
</p>
</div>
<div v-if="getResultCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0">
<Button type="button" variant="outline" size="icon-sm"
@click.stop="decreaseResultQty(variant.id)">
<Minus class="size-3.5" />
</Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
{{ getResultCartItem(variant.id)!.cutting_result }}
</span>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="addResult(product, variant)">
<Plus class="size-3.5" />
</Button>
</div>
<Button v-else type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="addResult(product, variant)">
<Plus class="size-3.5" />
</Button>
</div> </div>
</PosCatalogCard>
<Table>
<TableHeader>
<TableRow>
<TableHead>Varian</TableHead>
<TableHead>Stok</TableHead>
<TableHead>Foto</TableHead>
<TableHead class="w-24 text-right" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="variant in product.variants" :key="variant.id"
class="cursor-pointer hover:bg-muted/30"
@click="addResult(product, variant)">
<TableCell class="font-medium">
{{ variant.name }}
</TableCell>
<TableCell class="tabular-nums">
{{ variant.stock }} pcs
</TableCell>
<TableCell>
<MediaThumbnailCell :items="variant.images ?? []" />
</TableCell>
<TableCell class="text-right">
<Button type="button" variant="outline" size="sm"
@click.stop="addResult(product, variant)">
<Plus class="size-3.5" />
Tambah
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div> </div>
</div> </div>
</CardContent> </CardContent>