store/resources/js/components/admin/manage/purchases/PurchasePosForm.vue

395 lines
16 KiB
Vue

<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Minus, Plus, Save, Search, ShoppingCart, Trash2 } from '@lucide/vue';
import { computed, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import PosCatalogCard from '@/components/admin/manage/PosCatalogCard.vue';
import PosCatalogVariantThumb from '@/components/admin/manage/PosCatalogVariantThumb.vue';
import MediaImageUpload from '@/components/media/MediaImageUpload.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyTitle,
} from '@/components/ui/empty';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { RupiahInput } from '@/components/ui/rupiah-input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import { getFirstCoverImage } from '@/lib/catalog-cover';
import { formErrors } from '@/lib/form';
import { formatRupiah, parseRupiah } from '@/lib/rupiah';
import type { MediaItem } from '@/types/media';
import { appendRootPhotosToFormData, createMediaUploadState } from '@/types/media';
import type { PurchaseCartItem, PurchaseCatalogItem, SelectOption } from '@/types/purchase';
const props = defineProps<{
suppliers: SelectOption[];
catalog: PurchaseCatalogItem[];
initialData?: {
supplier_id: string;
discount: string;
notes: string;
items: PurchaseCartItem[];
photos?: MediaItem | null;
};
submitUrl: string;
method: 'post' | 'put';
submitLabel: string;
}>();
const search = ref('');
const cart = ref<PurchaseCartItem[]>([]);
const form = useForm({
supplier_id: '',
discount: '',
notes: '',
media: createMediaUploadState(),
});
function populateForm() {
if (!props.initialData) {
return;
}
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.notes = props.initialData.notes;
form.media = createMediaUploadState(props.initialData.photos ? [props.initialData.photos] : []);
cart.value = props.initialData.items.map((item) => ({ ...item }));
}
watch(
() => props.initialData,
() => {
populateForm();
},
{ immediate: true },
);
type CatalogPrice = {
id: number;
variant: string;
price_input: string;
price_formatted: string;
images?: PurchaseCatalogItem['prices'][number]['images'];
};
const filteredCatalog = computed(() => {
const keyword = search.value.trim().toLowerCase();
if (!keyword) {
return props.catalog;
}
return props.catalog.filter((rawMaterial) =>
rawMaterial.name.toLowerCase().includes(keyword)
|| rawMaterial.prices.some((price) => price.variant.toLowerCase().includes(keyword)),
);
});
const subtotal = computed(() =>
cart.value.reduce((sum, item) => sum + lineSubtotal(item), 0),
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value, 0));
function lineSubtotal(item: PurchaseCartItem): number {
const quantity = Number(item.quantity) || 0;
return Math.round(quantity * item.unit_price);
}
function addToCart(rawMaterial: PurchaseCatalogItem, price: CatalogPrice) {
const existing = cart.value.find(
(item) => item.raw_material_price_id === price.id,
);
if (existing) {
const currentQty = Number(existing.quantity) || 0;
existing.quantity = String(currentQty + 1);
return;
}
cart.value.push({
raw_material_price_id: price.id,
raw_material_name: rawMaterial.name,
variant: price.variant,
unit_abbreviation: rawMaterial.unit_abbreviation,
quantity: '1',
unit_price: Number(price.price_input),
images: price.images ?? [],
});
}
function removeFromCart(index: number) {
cart.value.splice(index, 1);
}
function adjustQuantity(index: number, delta: number) {
const item = cart.value[index];
const nextQty = (Number(item.quantity) || 0) + delta;
if (nextQty <= 0) {
removeFromCart(index);
return;
}
item.quantity = String(nextQty);
}
function buildFormData(): FormData {
const formData = new FormData();
if (props.method === 'put') {
formData.append('_method', 'PUT');
}
formData.append('supplier_id', form.supplier_id);
formData.append('discount', parseRupiah(form.discount));
formData.append('notes', form.notes);
cart.value.forEach((item, index) => {
formData.append(`items[${index}][raw_material_price_id]`, String(item.raw_material_price_id));
formData.append(`items[${index}][quantity]`, item.quantity);
});
appendRootPhotosToFormData(formData, form.media);
return formData;
}
function submit() {
if (cart.value.length === 0) {
toast.error('Tambahkan minimal satu bahan baku ke keranjang.');
return;
}
if (!form.supplier_id) {
toast.error('Pilih supplier terlebih dahulu.');
return;
}
const payload = buildFormData();
form.transform(() => payload).post(props.submitUrl, {
forceFormData: true,
onError: () => {
toast.error('Gagal menyimpan data. Periksa kembali formulir.');
},
});
}
</script>
<template>
<div class="grid gap-4 xl:grid-cols-[1fr_380px]">
<Card class="min-w-0">
<CardHeader class="pb-3">
<CardTitle class="text-base">Pilih Bahan Baku</CardTitle>
</CardHeader>
<CardContent class="space-y-4">
<div class="relative">
<Search class="absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" />
<Input v-model="search" placeholder="Cari bahan baku..." class="pl-9" />
</div>
<div v-if="filteredCatalog.length === 0" class="py-12 text-center text-sm text-muted-foreground">
<Empty>
<EmptyHeader>
<EmptyTitle>Tidak ada bahan baku ditemukan</EmptyTitle>
<EmptyDescription>
Silakan lakukan pencarian untuk menemukan bahan baku yang Anda cari.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="columns-1 gap-4 sm:columns-2 xl:columns-3">
<PosCatalogCard v-for="rawMaterial in filteredCatalog" :key="rawMaterial.id"
:title="rawMaterial.name" :cover-image="getFirstCoverImage(rawMaterial.prices)">
<template #header-extra>
<Badge variant="secondary" class="mt-1.5">
{{ rawMaterial.unit_label }}
</Badge>
</template>
<p v-if="!rawMaterial.prices.length" class="px-3 py-4 text-sm text-muted-foreground">
Belum ada varian
</p>
<div v-for="price in rawMaterial.prices" :key="price.id"
class="flex cursor-pointer items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-muted/30"
@click="addToCart(rawMaterial, price)">
<PosCatalogVariantThumb :items="price.images" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{{ price.variant }}
</p>
<p class="text-xs tabular-nums text-muted-foreground">
{{ price.price_formatted }}
</p>
</div>
<Button type="button" variant="outline" size="icon-sm" class="shrink-0"
@click.stop="addToCart(rawMaterial, price)">
<Plus class="size-3.5" />
</Button>
</div>
</PosCatalogCard>
</div>
</CardContent>
</Card>
<Card class="h-fit xl:sticky xl:top-4">
<CardHeader class="pb-3">
<CardTitle class="flex items-center gap-2 text-base">
<ShoppingCart class="size-4" />
Ringkasan Belanja
</CardTitle>
</CardHeader>
<CardContent>
<form @submit.prevent="submit">
<FieldGroup>
<FieldSet class="grid gap-4">
<Field>
<FieldLabel for="supplier" required>Supplier</FieldLabel>
<Select v-model="form.supplier_id">
<SelectTrigger id="supplier" class="w-full">
<SelectValue placeholder="Pilih supplier" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem v-for="supplier in suppliers" :key="supplier.value"
:value="String(supplier.value)">
{{ supplier.label }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<FieldError :errors="formErrors(form, 'supplier_id')" />
</Field>
<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 bahan baku di sebelah kiri untuk menambahkan ke keranjang.
</EmptyDescription>
</EmptyHeader>
</Empty>
</div>
<div v-else class="space-y-3">
<div v-for="(item, index) in cart" :key="item.raw_material_price_id"
class="rounded-lg border p-3">
<div class="flex gap-3">
<div class="min-w-0 flex-1 space-y-2">
<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 }}
</p>
</div>
<Button type="button" variant="ghost" size="icon"
class="text-destructive hover:text-destructive size-7 shrink-0"
@click="removeFromCart(index)">
<Trash2 class="size-3.5" />
</Button>
</div>
<Field>
<FieldLabel class="text-xs">Jumlah ({{ item.unit_abbreviation }})
</FieldLabel>
<div class="flex items-center gap-1">
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, -1)">
<Minus class="size-3.5" />
</Button>
<Input v-model="item.quantity" type="number" min="0.0001" step="any"
class="h-8 text-center" />
<Button type="button" variant="outline" size="icon"
class="size-8 shrink-0" @click="adjustQuantity(index, 1)">
<Plus class="size-3.5" />
</Button>
</div>
</Field>
<p class="text-xs text-muted-foreground">
Harga satuan Rp {{ formatRupiah(item.unit_price) }}
</p>
<p class="text-right text-sm font-medium">
Rp {{ formatRupiah(lineSubtotal(item)) }}
</p>
</div>
</div>
</div>
</div>
<Separator />
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-muted-foreground">Subtotal</span>
<span class="font-medium">Rp {{ formatRupiah(subtotal) }}</span>
</div>
<Field>
<FieldLabel for="discount">Diskon</FieldLabel>
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
</div>
</div>
<Field>
<FieldLabel for="notes">Keterangan</FieldLabel>
<Textarea id="notes" v-model="form.notes" placeholder="Contoh: Belanja batch 2"
rows="2" />
<FieldError :errors="formErrors(form, 'notes')" />
</Field>
<MediaImageUpload id="purchase-photos" v-model="form.media" label="Bukti Transaksi"
:errors="formErrors(form, 'photos')" />
<Button type="submit" class="w-full" :disabled="form.processing || cart.length === 0">
<Save class="size-4" />
{{ form.processing ? 'Menyimpan...' : submitLabel }}
</Button>
</FieldSet>
</FieldGroup>
</form>
</CardContent>
</Card>
</div>
</template>