Compare commits
7 Commits
c2360ef1ed
...
452c1a4209
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
452c1a4209 | ||
|
|
5e57317d8f | ||
|
|
b4f26c56cf | ||
|
|
9e0188c9ef | ||
|
|
55ca1521f9 | ||
|
|
e4318fd244 | ||
|
|
dc80ad3106 |
@ -228,6 +228,13 @@ public function createVariantAndDraft(array $validated, User $user): array
|
|||||||
|
|
||||||
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
$price->setAttribute('images', MediaPresenter::collection($price, 'images'));
|
||||||
|
|
||||||
|
$this->pushNotificationService->sendToRoles(
|
||||||
|
'🆕 Varian Baru Ditambahkan',
|
||||||
|
"{$user->name} menambahkan varian \"{$price->variant}\" ke bahan baku \"{$rawMaterial->name}\".",
|
||||||
|
['owner', 'developer', 'direktur'],
|
||||||
|
route('admin.master.raw_materials.index', ['search' => $rawMaterial->name]),
|
||||||
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $price->id,
|
'id' => $price->id,
|
||||||
'variant' => $price->variant,
|
'variant' => $price->variant,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { router, usePage } from '@inertiajs/vue3';
|
import { router, usePage } from '@inertiajs/vue3';
|
||||||
import { LogOut, User } from '@lucide/vue';
|
import { Home, LogOut, User } from '@lucide/vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import NotificationBell from '@/components/NotificationBell.vue';
|
import NotificationBell from '@/components/NotificationBell.vue';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
@ -34,6 +34,9 @@ function logout() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/" class="flex size-8 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" title="Halaman Depan">
|
||||||
|
<Home class="size-4" />
|
||||||
|
</a>
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<DropdownMenu v-if="user">
|
<DropdownMenu v-if="user">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
|
|||||||
@ -21,13 +21,16 @@ const props = withDefaults(
|
|||||||
url: string | null;
|
url: string | null;
|
||||||
urls?: string[];
|
urls?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
|
titles?: string[];
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
urls: () => [],
|
urls: () => [],
|
||||||
title: 'Pratinjau Foto',
|
title: 'Pratinjau Foto',
|
||||||
|
titles: () => [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
const activeUrl = ref<string | null>(props.url);
|
const activeUrl = ref<string | null>(props.url);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -63,6 +66,14 @@ const currentIndex = computed(() => {
|
|||||||
|
|
||||||
const hasMultiple = computed(() => allUrls.value.length > 1);
|
const hasMultiple = computed(() => allUrls.value.length > 1);
|
||||||
|
|
||||||
|
const activeTitle = computed(() => {
|
||||||
|
if (props.titles && props.titles.length > 0 && currentIndex.value >= 0) {
|
||||||
|
return props.titles[currentIndex.value] ?? props.title ?? 'Pratinjau Foto';
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.title ?? 'Pratinjau Foto';
|
||||||
|
});
|
||||||
|
|
||||||
function nextImage() {
|
function nextImage() {
|
||||||
if (!hasMultiple.value) {
|
if (!hasMultiple.value) {
|
||||||
return;
|
return;
|
||||||
@ -130,7 +141,7 @@ function handleTouchEnd(e: TouchEvent) {
|
|||||||
<Dialog v-model:open="open">
|
<Dialog v-model:open="open">
|
||||||
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
|
<DialogContent class="sm:max-w-2xl p-4 overflow-hidden gap-4">
|
||||||
<DialogHeader class="pb-2 border-b">
|
<DialogHeader class="pb-2 border-b">
|
||||||
<DialogTitle class="text-base font-semibold">{{ title ?? 'Pratinjau Gambar' }}</DialogTitle>
|
<DialogTitle class="text-base font-semibold">{{ activeTitle }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div class="relative flex items-center justify-center min-h-[300px] select-none"
|
<div class="relative flex items-center justify-center min-h-[300px] select-none"
|
||||||
|
|||||||
@ -14,6 +14,7 @@ const props = defineProps<{
|
|||||||
maxVisible?: number;
|
maxVisible?: number;
|
||||||
title?: string;
|
title?: string;
|
||||||
allUrls?: string[];
|
allUrls?: string[];
|
||||||
|
allTitles?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const previewOpen = ref(false);
|
const previewOpen = ref(false);
|
||||||
@ -120,5 +121,5 @@ function openGallery() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" @close="onPreviewClose" />
|
<MediaPreviewDialog v-model:open="previewOpen" v-model:url="previewUrl" :urls="allUrls ?? items.map(item => item.url)" :title="title" :titles="allTitles" @close="onPreviewClose" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -150,7 +150,7 @@ const placeholder = computed(() =>
|
|||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="cash-amount" required>Jumlah</FieldLabel>
|
<FieldLabel for="cash-amount" required>Jumlah</FieldLabel>
|
||||||
<RupiahInput id="cash-amount" v-model="form.amount" autofocus />
|
<RupiahInput id="cash-amount" v-model="form.amount" />
|
||||||
<FieldError :errors="formErrors(form, 'amount')" />
|
<FieldError :errors="formErrors(form, 'amount')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -118,7 +118,7 @@ function submit() {
|
|||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="employee-advance-amount" required>Jumlah</FieldLabel>
|
<FieldLabel for="employee-advance-amount" required>Jumlah</FieldLabel>
|
||||||
<RupiahInput id="employee-advance-amount" v-model="form.amount" autofocus />
|
<RupiahInput id="employee-advance-amount" v-model="form.amount" />
|
||||||
<FieldError :errors="formErrors(form, 'amount')" />
|
<FieldError :errors="formErrors(form, 'amount')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -77,7 +77,7 @@ function submit() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
|
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||||
<Textarea id="reject-reason" v-model="form.reason" placeholder="Tuliskan alasan penolakan"
|
<Textarea id="reject-reason" v-model="form.reason" placeholder="Tuliskan alasan penolakan"
|
||||||
rows="3" autofocus :maxlength="FIELD_LIMITS.reason" />
|
rows="3" :maxlength="FIELD_LIMITS.reason" />
|
||||||
<FieldError :errors="formErrors(form, 'reason')" />
|
<FieldError :errors="formErrors(form, 'reason')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|||||||
@ -125,7 +125,7 @@ function submit() {
|
|||||||
<FieldSet class="grid gap-4">
|
<FieldSet class="grid gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="expense-amount" required>Jumlah</FieldLabel>
|
<FieldLabel for="expense-amount" required>Jumlah</FieldLabel>
|
||||||
<RupiahInput id="expense-amount" v-model="form.amount" autofocus />
|
<RupiahInput id="expense-amount" v-model="form.amount" />
|
||||||
<FieldError :errors="formErrors(form, 'amount')" />
|
<FieldError :errors="formErrors(form, 'amount')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -98,7 +98,7 @@ function submit() {
|
|||||||
</p>
|
</p>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="leave-start-date" required>Tanggal Mulai</FieldLabel>
|
<FieldLabel for="leave-start-date" required>Tanggal Mulai</FieldLabel>
|
||||||
<DatePicker id="leave-start-date" v-model="form.start_date" autofocus />
|
<DatePicker id="leave-start-date" v-model="form.start_date" />
|
||||||
<FieldError :errors="formErrors(form, 'start_date')" />
|
<FieldError :errors="formErrors(form, 'start_date')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -77,7 +77,7 @@ function submit() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
|
<FieldLabel for="reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||||
<Textarea id="reject-reason" v-model="form.reason"
|
<Textarea id="reject-reason" v-model="form.reason"
|
||||||
placeholder="Masukkan alasan penolakan" rows="3" autofocus
|
placeholder="Masukkan alasan penolakan" rows="3"
|
||||||
:maxlength="FIELD_LIMITS.reason" />
|
:maxlength="FIELD_LIMITS.reason" />
|
||||||
<FieldError :errors="formErrors(form, 'reason')" />
|
<FieldError :errors="formErrors(form, 'reason')" />
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@ -68,7 +68,7 @@ function submitReject() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
|
<FieldLabel for="cutting-reject-reason" required>Alasan Penolakan</FieldLabel>
|
||||||
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
|
<Textarea id="cutting-reject-reason" v-model="rejectForm.reason"
|
||||||
placeholder="Masukkan alasan penolakan" rows="3" autofocus
|
placeholder="Masukkan alasan penolakan" rows="3"
|
||||||
:maxlength="FIELD_LIMITS.reason" />
|
:maxlength="FIELD_LIMITS.reason" />
|
||||||
<FieldError :errors="rejectForm.errors.reason
|
<FieldError :errors="rejectForm.errors.reason
|
||||||
? [rejectForm.errors.reason]
|
? [rejectForm.errors.reason]
|
||||||
|
|||||||
@ -101,7 +101,7 @@ async function submit() {
|
|||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Masukkan nama pelanggan"
|
placeholder="Masukkan nama pelanggan"
|
||||||
autofocus
|
|
||||||
:maxlength="FIELD_LIMITS.name"
|
:maxlength="FIELD_LIMITS.name"
|
||||||
/>
|
/>
|
||||||
<FieldError
|
<FieldError
|
||||||
|
|||||||
@ -5,6 +5,12 @@ import {
|
|||||||
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 { OrderCartItem } from '@/types/order';
|
import type { OrderCartItem } from '@/types/order';
|
||||||
|
|
||||||
@ -15,18 +21,40 @@ defineProps<{
|
|||||||
totalAmount: number;
|
totalAmount: 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);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<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 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="cart.length === 0" class="rounded-lg border border-dashed text-center text-sm text-muted-foreground">
|
||||||
|
<Empty>
|
||||||
|
<EmptyHeader>
|
||||||
|
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
|
||||||
|
<EmptyDescription>
|
||||||
|
Pilih varian produk 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.product_variant_id}-${item.stock_quality}`"
|
:key="`detail-${item.product_variant_id}-${item.stock_quality}`"
|
||||||
class="rounded-lg border p-3"
|
class="rounded-lg border p-3"
|
||||||
>
|
>
|
||||||
@ -38,15 +66,50 @@ const open = defineModel<boolean>('open', { required: true });
|
|||||||
· {{ item.stock_quality_label ?? stockQualityLabel(item.stock_quality) }}
|
· {{ item.stock_quality_label ?? stockQualityLabel(item.stock_quality) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs font-medium tabular-nums">{{ item.quantity }} pcs</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
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 (pcs)
|
||||||
|
</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)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</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="cart.length > 0" 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(totalAmount) }}</span>
|
<span class="text-primary">Rp {{ formatRupiah(totalAmount) }}</span>
|
||||||
|
|||||||
@ -115,6 +115,7 @@ const {
|
|||||||
removeFromCart,
|
removeFromCart,
|
||||||
adjustQuantity,
|
adjustQuantity,
|
||||||
syncCartItemQuantity,
|
syncCartItemQuantity,
|
||||||
|
setCartItemQuantity,
|
||||||
decreaseVariantQty,
|
decreaseVariantQty,
|
||||||
} = useOrderPosCart({
|
} = useOrderPosCart({
|
||||||
catalog: () => props.catalog,
|
catalog: () => props.catalog,
|
||||||
@ -227,7 +228,7 @@ function buildFormData(): FormData {
|
|||||||
formData.append('discount', parseRupiah(form.discount));
|
formData.append('discount', parseRupiah(form.discount));
|
||||||
formData.append('nego_price', parseRupiah(form.nego_price));
|
formData.append('nego_price', parseRupiah(form.nego_price));
|
||||||
formData.append('notes', form.notes);
|
formData.append('notes', form.notes);
|
||||||
|
|
||||||
if (isCashierUser.value) {
|
if (isCashierUser.value) {
|
||||||
formData.append('status', OrderStatus.COMPLETED);
|
formData.append('status', OrderStatus.COMPLETED);
|
||||||
} else {
|
} else {
|
||||||
@ -362,5 +363,20 @@ function submit() {
|
|||||||
:stock-quality-label="stockQualityLabel"
|
:stock-quality-label="stockQualityLabel"
|
||||||
:line-subtotal="lineSubtotal"
|
:line-subtotal="lineSubtotal"
|
||||||
:total-amount="totalAmount"
|
:total-amount="totalAmount"
|
||||||
|
@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>
|
||||||
|
|||||||
@ -287,6 +287,31 @@ export function useOrderPosCart(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 < 1) {
|
||||||
|
await removeFromCart(index);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.quantity = String(nextQty);
|
||||||
|
|
||||||
|
if (toValue(options.isCreateMode)) {
|
||||||
|
try {
|
||||||
|
await syncDraftItem(item.product_variant_id, nextQty, item.stock_quality);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Gagal memperbarui jumlah item.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
search,
|
search,
|
||||||
selectedStockQuality,
|
selectedStockQuality,
|
||||||
@ -303,6 +328,7 @@ export function useOrderPosCart(options: {
|
|||||||
removeFromCart,
|
removeFromCart,
|
||||||
adjustQuantity,
|
adjustQuantity,
|
||||||
syncCartItemQuantity,
|
syncCartItemQuantity,
|
||||||
|
setCartItemQuantity,
|
||||||
decreaseVariantQty,
|
decreaseVariantQty,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)"
|
||||||
|
>
|
||||||
|
−
|
||||||
|
</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>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const emit = defineEmits<{
|
|||||||
<EmptyHeader>
|
<EmptyHeader>
|
||||||
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
|
<EmptyTitle>Keranjang masih kosong</EmptyTitle>
|
||||||
<EmptyDescription>
|
<EmptyDescription>
|
||||||
Pilih varian bahan baku di sebelah kiri untuk menambahkan ke keranjang.
|
Pilih varian bahan baku untuk menambahkan ke keranjang.
|
||||||
</EmptyDescription>
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -95,7 +95,7 @@ function submit() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="category-name" required>Nama</FieldLabel>
|
<FieldLabel for="category-name" required>Nama</FieldLabel>
|
||||||
<Input id="category-name" v-model="form.name" type="text" placeholder="Masukkan nama kategori"
|
<Input id="category-name" v-model="form.name" type="text" placeholder="Masukkan nama kategori"
|
||||||
autofocus :maxlength="FIELD_LIMITS.categoryName" />
|
:maxlength="FIELD_LIMITS.categoryName" />
|
||||||
<FieldError :errors="formErrors(form, 'name')" />
|
<FieldError :errors="formErrors(form, 'name')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|||||||
@ -101,7 +101,7 @@ function submit() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="customer-name" required>Nama</FieldLabel>
|
<FieldLabel for="customer-name" required>Nama</FieldLabel>
|
||||||
<Input id="customer-name" v-model="form.name" type="text" placeholder="Masukkan nama pelanggan"
|
<Input id="customer-name" v-model="form.name" type="text" placeholder="Masukkan nama pelanggan"
|
||||||
autofocus :maxlength="FIELD_LIMITS.name" />
|
:maxlength="FIELD_LIMITS.name" />
|
||||||
<FieldError :errors="formErrors(form, 'name')" />
|
<FieldError :errors="formErrors(form, 'name')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
@ -59,6 +59,12 @@ function allVariantImageUrls(product: ProductListItem): string[] {
|
|||||||
return product.variants.flatMap((variant) => (variant.images ?? []).map((img) => img.url));
|
return product.variants.flatMap((variant) => (variant.images ?? []).map((img) => img.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allVariantImageTitles(product: ProductListItem): string[] {
|
||||||
|
return product.variants.flatMap((variant) =>
|
||||||
|
(variant.images ?? []).map(() => `${product.name} - ${variant.name}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const verificationModalOpen = ref(false);
|
const verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
const selectedRequestId = ref<number | null>(null);
|
||||||
|
|
||||||
@ -152,7 +158,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ variant.name }}
|
{{ variant.name }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" />
|
<MediaThumbnailCell :items="variant.images ?? []" :max-visible="1" :title="`${product.name} - ${variant.name}`" :all-urls="allVariantImageUrls(product)" :all-titles="allVariantImageTitles(product)" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ variant.stock_formatted }}
|
{{ variant.stock_formatted }}
|
||||||
|
|||||||
@ -53,6 +53,12 @@ function allVariantImageUrls(material: RawMaterialListItem): string[] {
|
|||||||
return material.prices.flatMap((price) => (price.images ?? []).map((img) => img.url));
|
return material.prices.flatMap((price) => (price.images ?? []).map((img) => img.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allVariantImageTitles(material: RawMaterialListItem): string[] {
|
||||||
|
return material.prices.flatMap((price) =>
|
||||||
|
(price.images ?? []).map(() => `${material.name} - ${price.variant}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const verificationModalOpen = ref(false);
|
const verificationModalOpen = ref(false);
|
||||||
const selectedRequestId = ref<number | null>(null);
|
const selectedRequestId = ref<number | null>(null);
|
||||||
|
|
||||||
@ -144,7 +150,7 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
{{ price.variant }}
|
{{ price.variant }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" :all-urls="allVariantImageUrls(material)" />
|
<MediaThumbnailCell :items="price.images ?? []" :max-visible="1" :title="`${material.name} - ${price.variant}`" :all-urls="allVariantImageUrls(material)" :all-titles="allVariantImageTitles(material)" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="tabular-nums">
|
<TableCell class="tabular-nums">
|
||||||
{{ price.stock_formatted }}
|
{{ price.stock_formatted }}
|
||||||
|
|||||||
@ -101,7 +101,7 @@ function submit() {
|
|||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="supplier-name" required>Nama</FieldLabel>
|
<FieldLabel for="supplier-name" required>Nama</FieldLabel>
|
||||||
<Input id="supplier-name" v-model="form.name" type="text"
|
<Input id="supplier-name" v-model="form.name" type="text"
|
||||||
placeholder="Masukkan nama supplier" autofocus :maxlength="FIELD_LIMITS.name" />
|
placeholder="Masukkan nama supplier" :maxlength="FIELD_LIMITS.name" />
|
||||||
<FieldError :errors="formErrors(form, 'name')" />
|
<FieldError :errors="formErrors(form, 'name')" />
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user