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.name }}
</h3>
<Badge variant="secondary">
{{ rawMaterial.unit_label }} {{ rawMaterial.unit_label }}
</Badge> </Badge>
</div> </template>
</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 }}
</TableCell> </p>
<TableCell class="tabular-nums"> <p class="text-xs tabular-nums text-muted-foreground">
{{ price.stock_formatted }} Stok: {{ price.stock_formatted }}
</TableCell> </p>
<TableCell> </div>
<MediaThumbnailCell :items="price.images ?? []" /> <div v-if="getMaterialCartItem(price.id)" class="flex items-center gap-1.5 shrink-0">
</TableCell> <Button type="button" variant="outline" size="icon-sm"
<TableCell class="text-right"> @click.stop="decreaseMaterialQty(price.id)">
<Button type="button" variant="outline" size="sm" <Minus class="size-3.5" />
</Button>
<span class="text-xs font-semibold min-w-[1.25rem] text-center tabular-nums">
{{ getMaterialCartItem(price.id)!.material_usage }}
</span>
<Button type="button" variant="outline" size="icon-sm"
@click.stop="addMaterial(rawMaterial, price)"> @click.stop="addMaterial(rawMaterial, price)">
<Plus class="size-3.5" /> <Plus class="size-3.5" />
Tambah
</Button> </Button>
</TableCell>
</TableRow>
</TableBody>
</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"
</div> class="flex items-center gap-2.5 px-3 py-2.5 transition-all duration-200"
:class="[
<Table> 'cursor-pointer hover:bg-muted/30',
<TableHeader> getResultCartItem(variant.id) ? 'border-2 border-primary bg-primary/5 rounded-md mx-1 my-0.5' : ''
<TableRow> ]"
<TableHead>Varian</TableHead> @click="!getResultCartItem(variant.id) && addResult(product, variant)">
<TableHead>Stok</TableHead> <PosCatalogVariantThumb :items="variant.images" />
<TableHead>Foto</TableHead> <div class="min-w-0 flex-1">
<TableHead class="w-24 text-right" /> <p class="truncate text-sm font-medium">
</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 }} {{ variant.name }}
</TableCell> </p>
<TableCell class="tabular-nums"> <p class="text-xs text-muted-foreground">
{{ variant.stock }} pcs <span class="tabular-nums">Stok: {{ variant.stock }} pcs</span>
</TableCell> </p>
<TableCell> </div>
<MediaThumbnailCell :items="variant.images ?? []" /> <div v-if="getResultCartItem(variant.id)" class="flex items-center gap-1.5 shrink-0">
</TableCell> <Button type="button" variant="outline" size="icon-sm"
<TableCell class="text-right"> @click.stop="decreaseResultQty(variant.id)">
<Button type="button" variant="outline" size="sm" <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)"> @click.stop="addResult(product, variant)">
<Plus class="size-3.5" /> <Plus class="size-3.5" />
Tambah
</Button> </Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div> </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>
</PosCatalogCard>
</div> </div>
</div> </div>
</CardContent> </CardContent>