feat: add shipping cost field to purchase management, including validation, calculations, and UI updates for total cost

This commit is contained in:
Yoga Pangestu 2026-06-19 19:19:50 +07:00
parent db3f6887ea
commit 2a2cc0fb96
12 changed files with 63 additions and 9 deletions

View File

@ -28,6 +28,7 @@ public function rules(): array
$rules = [
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
'discount' => ['nullable', 'integer', 'min:0'],
'shipping_cost' => ['nullable', 'integer', 'min:0'],
'notes' => ['nullable', 'string', 'max:100'],
...$this->photoRules(),
];
@ -53,6 +54,7 @@ public function attributes(): array
return [
'supplier_id' => 'supplier',
'discount' => 'diskon',
'shipping_cost' => 'ongkir',
'notes' => 'keterangan',
'items' => 'bahan baku',
'items.*.raw_material_price_id' => 'bahan baku',

View File

@ -18,6 +18,7 @@
#[Appends([
'subtotal_formatted',
'discount_formatted',
'shipping_cost_formatted',
'total_formatted',
'created_at_formatted',
])]
@ -33,6 +34,7 @@ protected function casts(): array
return [
'subtotal' => 'integer',
'discount' => 'integer',
'shipping_cost' => 'integer',
'total' => 'integer',
];
}
@ -66,6 +68,13 @@ public function discountFormatted(): Attribute
);
}
public function shippingCostFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->shipping_cost, 0, ',', '.'),
);
}
public function subtotalFormatted(): Attribute
{
return Attribute::make(

View File

@ -223,13 +223,15 @@ public function create(array $validated, User $user): Purchase
$subtotal = $draftItems->sum('subtotal');
$discount = (int) ($validated['discount'] ?? 0);
$total = max($subtotal - $discount, 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
$purchase = Purchase::create([
'supplier_id' => $validated['supplier_id'],
'created_by_id' => $user->id,
'subtotal' => $subtotal,
'discount' => $discount,
'shipping_cost' => $shippingCost,
'total' => $total,
'notes' => $validated['notes'] ?? null,
]);
@ -273,11 +275,13 @@ public function update(Purchase $purchase, array $validated): void
$lineItems = $this->buildLineItems($validated['items']);
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
$discount = (int) ($validated['discount'] ?? 0);
$total = max($subtotal - $discount, 0);
$shippingCost = (int) ($validated['shipping_cost'] ?? 0);
$total = max($subtotal - $discount + $shippingCost, 0);
$purchase->supplier_id = $validated['supplier_id'];
$purchase->subtotal = $subtotal;
$purchase->discount = $discount;
$purchase->shipping_cost = $shippingCost;
$purchase->total = $total;
$purchase->notes = $validated['notes'] ?? null;
$purchase->save();

View File

@ -191,7 +191,6 @@ public function update(RawMaterial $rawMaterial, array $validated): void
{
DB::transaction(function () use ($validated, $rawMaterial): void {
$rawMaterial->name = $validated['name'];
$rawMaterial->unit = $validated['unit'];
$rawMaterial->save();
$submittedPriceIds = collect($validated['prices'])

View File

@ -16,13 +16,15 @@ public function definition(): array
{
$subtotal = fake()->numberBetween(100_000, 10_000_000);
$discount = fake()->numberBetween(0, (int) ($subtotal * 0.1));
$shippingCost = fake()->numberBetween(0, 100_000);
return [
'supplier_id' => Supplier::factory(),
'created_by_id' => User::factory(),
'subtotal' => $subtotal,
'discount' => $discount,
'total' => $subtotal - $discount,
'shipping_cost' => $shippingCost,
'total' => $subtotal - $discount + $shippingCost,
'notes' => fake()->optional()->sentence(3),
];
}

View File

@ -16,6 +16,7 @@ public function up(): void
$table->unsignedBigInteger('subtotal');
$table->unsignedBigInteger('discount')->default(0);
$table->unsignedBigInteger('shipping_cost')->default(0);
$table->unsignedBigInteger('total');
$table->string('notes', 100)->nullable();

View File

@ -53,7 +53,7 @@ function destroyAttendance() {
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-xl">
<DialogContent class="sm:max-w-xl max-h-[90vh] overflow-y-auto scrollbar-thin">
<DialogHeader>
<DialogTitle>Detail Presensi</DialogTitle>
<DialogDescription v-if="attendance">
@ -64,7 +64,7 @@ function destroyAttendance() {
</DialogDescription>
</DialogHeader>
<div v-if="attendance" class="space-y-4">
<div v-if="attendance" class="space-y-4 pb-2">
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
<div>
<dt class="text-muted-foreground">

View File

@ -117,6 +117,8 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
}}</strong></span>
<span>Diskon <strong class="text-foreground">{{ purchase.discount_formatted
}}</strong></span>
<span v-if="purchase.shipping_cost > 0">Ongkir <strong class="text-foreground">{{
purchase.shipping_cost_formatted }}</strong></span>
<span>Total <strong class="text-primary">{{ purchase.total_formatted }}</strong></span>
</div>
<p v-if="purchase.notes" class="text-muted-foreground text-sm">

View File

@ -49,6 +49,7 @@ const props = defineProps<{
initialData?: {
supplier_id: string;
discount: string;
shipping_cost: string;
notes: string;
items: PurchaseCartItem[];
photos?: MediaItem | null;
@ -69,6 +70,7 @@ const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
const form = useForm({
supplier_id: '',
discount: '',
shipping_cost: '',
notes: '',
photos: [] as File[],
remove_media_ids: [] as number[],
@ -88,6 +90,7 @@ function populateForm() {
form.supplier_id = props.initialData.supplier_id;
form.discount = props.initialData.discount;
form.shipping_cost = props.initialData.shipping_cost ?? '0';
form.notes = props.initialData.notes;
form.photos = [];
form.remove_media_ids = [];
@ -153,8 +156,9 @@ const subtotal = computed(() =>
);
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
const shippingAmount = computed(() => Number(parseRupiah(form.shipping_cost)) || 0);
const total = computed(() => Math.max(subtotal.value - discountAmount.value, 0));
const total = computed(() => Math.max(subtotal.value - discountAmount.value + shippingAmount.value, 0));
function getCartItem(priceId: number): PurchaseCartItem | undefined {
return cart.value.find((item) => item.raw_material_price_id === priceId);
@ -290,6 +294,7 @@ function buildFormData(): FormData {
formData.append('supplier_id', form.supplier_id);
formData.append('discount', parseRupiah(form.discount));
formData.append('shipping_cost', parseRupiah(form.shipping_cost));
formData.append('notes', form.notes);
if (props.method === 'put') {
@ -511,6 +516,11 @@ function submit() {
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
<FieldError :errors="formErrors(form, 'discount')" />
</Field>
<Field>
<FieldLabel for="shipping_cost">Ongkir</FieldLabel>
<RupiahInput id="shipping_cost" v-model="form.shipping_cost" placeholder="0" />
<FieldError :errors="formErrors(form, 'shipping_cost')" />
</Field>
<div class="flex justify-between text-base font-semibold">
<span>Total</span>
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import { useForm } from '@inertiajs/vue3';
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
import { ref } from 'vue';
import { ref, computed } from 'vue';
import { toast } from 'vue-sonner';
import { DecimalInput } from '@/components/form/decimal-input';
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
@ -94,6 +94,14 @@ const form = useForm({
unit: props.initialData?.unit ?? '',
});
const isEditMode = computed(() => props.method === 'put');
const unitLabel = computed(() => {
const option = props.units.find((item) => item.value === form.unit);
return option?.label ?? form.unit;
});
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
if (items.length <= 1) {
return true;
@ -237,7 +245,10 @@ function submit() {
<Field>
<FieldLabel for="unit" required>Satuan</FieldLabel>
<Select v-model="form.unit">
<Select
v-if="!isEditMode"
v-model="form.unit"
>
<SelectTrigger id="unit" class="w-full">
<SelectValue placeholder="Pilih satuan" />
</SelectTrigger>
@ -247,6 +258,16 @@ function submit() {
</SelectItem>
</SelectContent>
</Select>
<div
v-else
id="unit"
class="flex h-9 w-full items-center rounded-md border bg-muted/40 px-3 text-sm"
>
{{ unitLabel }}
</div>
<p v-if="isEditMode" class="text-xs text-muted-foreground">
Satuan tidak dapat diubah agar stok tetap konsisten.
</p>
<FieldError :errors="formErrors(form, 'unit')" />
</Field>
</FieldSet>

View File

@ -16,6 +16,7 @@ const props = defineProps<{
const initialData = computed(() => ({
supplier_id: String(props.purchase.supplier_id),
discount: String(props.purchase.discount),
shipping_cost: String(props.purchase.shipping_cost ?? 0),
notes: props.purchase.notes ?? '',
items: props.purchase.items.map((item) => ({
raw_material_price_id: item.raw_material_price_id,

View File

@ -24,6 +24,8 @@ export type PurchaseListItem = {
} | null;
subtotal_formatted: string;
discount_formatted: string;
shipping_cost: number;
shipping_cost_formatted: string;
total_formatted: string;
notes: string | null;
created_at_formatted: string;
@ -61,6 +63,7 @@ export type PurchaseEditItem = {
id: number;
supplier_id: number;
discount: number;
shipping_cost: number;
notes: string | null;
photos?: MediaItem | null;
items: Array<{