feat: implement cart item quantity adjustment and empty cart handling in PurchasePosCartDetailDialog for improved user interaction

This commit is contained in:
Yoga Pangestu 2026-07-08 00:09:57 +07:00
parent e4318fd244
commit 55ca1521f9
3 changed files with 114 additions and 8 deletions

View File

@ -1,31 +1,62 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import { formatRupiah } from '@/lib/rupiah'; import { formatRupiah } from '@/lib/rupiah';
import type { PurchaseCartItem } from '@/types/purchase'; import type { PurchaseCartItem } from '@/types/purchase';
defineProps<{ const props = defineProps<{
cart: PurchaseCartItem[]; cart: PurchaseCartItem[];
lineSubtotal: (item: PurchaseCartItem) => number; lineSubtotal: (item: PurchaseCartItem) => number;
total: number; total: number;
}>(); }>();
const emit = defineEmits<{
remove: [index: number];
'adjust-quantity': [index: number, delta: number];
'set-quantity': [index: number, value: string];
}>();
const open = defineModel<boolean>('open', { required: true }); const open = defineModel<boolean>('open', { required: true });
function onQtyInput(index: number, event: Event) {
emit('set-quantity', index, (event.target as HTMLInputElement).value);
}
const hasItems = computed(() => props.cart.length > 0);
</script> </script>
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg min-w-0"> <DialogContent class="sm:max-w-lg min-w-0 max-h-[90dvh] flex flex-col overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Detail Keranjang</DialogTitle> <DialogTitle>Detail Keranjang</DialogTitle>
</DialogHeader> </DialogHeader>
<div class="scrollbar-thin max-h-96 space-y-3 overflow-y-auto overscroll-y-contain">
<div v-if="!hasItems" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
<EmptyDescription>
Pilih varian bahan baku untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="scrollbar-thin max-h-96 space-y-3 overflow-y-auto overscroll-y-contain">
<div <div
v-for="item in cart" v-for="(item, index) in cart"
:key="`detail-${item.raw_material_price_id}`" :key="`detail-${item.raw_material_price_id}`"
class="rounded-lg border p-3" class="rounded-lg border p-3"
> >
@ -34,17 +65,50 @@ const open = defineModel<boolean>('open', { required: true });
<p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p> <p class="truncate text-sm font-medium">{{ item.raw_material_name }}</p>
<p class="truncate text-xs text-muted-foreground">{{ item.variant }}</p> <p class="truncate text-xs text-muted-foreground">{{ item.variant }}</p>
</div> </div>
<span class="shrink-0 text-xs font-medium tabular-nums"> <button
{{ item.quantity }} {{ item.unit_abbreviation }} type="button"
</span> class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
@click="emit('remove', index)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>
</button>
</div> </div>
<label class="mt-2 block text-xs text-muted-foreground">
Jumlah ({{ item.unit_abbreviation }})
</label>
<div class="mt-1 flex items-center gap-1">
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded border text-sm transition-colors hover:bg-muted"
@click="emit('adjust-quantity', index, -1)"
>
&minus;
</button>
<input
type="text"
inputmode="decimal"
:value="item.quantity"
class="h-8 w-full rounded border bg-transparent px-2 text-center text-sm focus:outline-none focus:ring-1 focus:ring-ring"
@change="onQtyInput(index, $event)"
/>
<button
type="button"
class="flex size-8 shrink-0 items-center justify-center rounded border text-sm transition-colors hover:bg-muted"
@click="emit('adjust-quantity', index, 1)"
>
+
</button>
</div>
<div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground"> <div class="mt-1.5 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span>@ Rp {{ formatRupiah(item.unit_price) }}</span> <span>@ Rp {{ formatRupiah(item.unit_price) }}</span>
<span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span> <span class="font-medium text-foreground">Rp {{ formatRupiah(lineSubtotal(item)) }}</span>
</div> </div>
</div> </div>
</div> </div>
<div class="border-t pt-3">
<div v-if="hasItems" class="border-t pt-3">
<div class="flex items-center justify-between text-sm font-semibold"> <div class="flex items-center justify-between text-sm font-semibold">
<span>Total</span> <span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span> <span class="text-primary">Rp {{ formatRupiah(total) }}</span>

View File

@ -64,6 +64,7 @@ const {
removeFromCart, removeFromCart,
adjustQuantity, adjustQuantity,
syncCartItemQuantity, syncCartItemQuantity,
setCartItemQuantity,
decreasePriceQty, decreasePriceQty,
} = usePurchasePosCart({ } = usePurchasePosCart({
catalog: () => props.catalog, catalog: () => props.catalog,
@ -223,5 +224,20 @@ function submit() {
:cart="cart" :cart="cart"
:line-subtotal="lineSubtotal" :line-subtotal="lineSubtotal"
:total="total" :total="total"
@remove="removeFromCart"
@adjust-quantity="adjustQuantity"
@set-quantity="setCartItemQuantity"
/> />
<button
v-if="cart.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">
{{ cart.length > 99 ? '99+' : cart.length }}
</span>
</button>
</template> </template>

View File

@ -189,6 +189,31 @@ export function usePurchasePosCart(options: {
} }
} }
async function setCartItemQuantity(index: number, value: string) {
const item = cart.value[index];
const nextQty = Number.parseFloat(value.replace(',', '.'));
if (Number.isNaN(nextQty) || nextQty < 0) {
return;
}
if (nextQty <= 0) {
await removeFromCart(index);
return;
}
item.quantity = String(nextQty);
if (toValue(options.isCreateMode)) {
try {
await syncDraftItem(item.raw_material_price_id, nextQty);
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
}
}
}
return { return {
search, search,
cart, cart,
@ -202,6 +227,7 @@ export function usePurchasePosCart(options: {
removeFromCart, removeFromCart,
adjustQuantity, adjustQuantity,
syncCartItemQuantity, syncCartItemQuantity,
setCartItemQuantity,
decreasePriceQty, decreasePriceQty,
upsertCartItem, upsertCartItem,
}; };