feat: add shipping cost field to purchase management, including validation, calculations, and UI updates for total cost
This commit is contained in:
parent
db3f6887ea
commit
2a2cc0fb96
@ -28,6 +28,7 @@ public function rules(): array
|
|||||||
$rules = [
|
$rules = [
|
||||||
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
|
'supplier_id' => ['required', 'integer', Rule::exists('suppliers', 'id')->whereNull('deleted_at')],
|
||||||
'discount' => ['nullable', 'integer', 'min:0'],
|
'discount' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'shipping_cost' => ['nullable', 'integer', 'min:0'],
|
||||||
'notes' => ['nullable', 'string', 'max:100'],
|
'notes' => ['nullable', 'string', 'max:100'],
|
||||||
...$this->photoRules(),
|
...$this->photoRules(),
|
||||||
];
|
];
|
||||||
@ -53,6 +54,7 @@ public function attributes(): array
|
|||||||
return [
|
return [
|
||||||
'supplier_id' => 'supplier',
|
'supplier_id' => 'supplier',
|
||||||
'discount' => 'diskon',
|
'discount' => 'diskon',
|
||||||
|
'shipping_cost' => 'ongkir',
|
||||||
'notes' => 'keterangan',
|
'notes' => 'keterangan',
|
||||||
'items' => 'bahan baku',
|
'items' => 'bahan baku',
|
||||||
'items.*.raw_material_price_id' => 'bahan baku',
|
'items.*.raw_material_price_id' => 'bahan baku',
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
#[Appends([
|
#[Appends([
|
||||||
'subtotal_formatted',
|
'subtotal_formatted',
|
||||||
'discount_formatted',
|
'discount_formatted',
|
||||||
|
'shipping_cost_formatted',
|
||||||
'total_formatted',
|
'total_formatted',
|
||||||
'created_at_formatted',
|
'created_at_formatted',
|
||||||
])]
|
])]
|
||||||
@ -33,6 +34,7 @@ protected function casts(): array
|
|||||||
return [
|
return [
|
||||||
'subtotal' => 'integer',
|
'subtotal' => 'integer',
|
||||||
'discount' => 'integer',
|
'discount' => 'integer',
|
||||||
|
'shipping_cost' => 'integer',
|
||||||
'total' => '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
|
public function subtotalFormatted(): Attribute
|
||||||
{
|
{
|
||||||
return Attribute::make(
|
return Attribute::make(
|
||||||
|
|||||||
@ -223,13 +223,15 @@ public function create(array $validated, User $user): Purchase
|
|||||||
|
|
||||||
$subtotal = $draftItems->sum('subtotal');
|
$subtotal = $draftItems->sum('subtotal');
|
||||||
$discount = (int) ($validated['discount'] ?? 0);
|
$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([
|
$purchase = Purchase::create([
|
||||||
'supplier_id' => $validated['supplier_id'],
|
'supplier_id' => $validated['supplier_id'],
|
||||||
'created_by_id' => $user->id,
|
'created_by_id' => $user->id,
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
'discount' => $discount,
|
'discount' => $discount,
|
||||||
|
'shipping_cost' => $shippingCost,
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
'notes' => $validated['notes'] ?? null,
|
'notes' => $validated['notes'] ?? null,
|
||||||
]);
|
]);
|
||||||
@ -273,11 +275,13 @@ public function update(Purchase $purchase, array $validated): void
|
|||||||
$lineItems = $this->buildLineItems($validated['items']);
|
$lineItems = $this->buildLineItems($validated['items']);
|
||||||
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
|
$subtotal = array_sum(array_column($lineItems, 'subtotal'));
|
||||||
$discount = (int) ($validated['discount'] ?? 0);
|
$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->supplier_id = $validated['supplier_id'];
|
||||||
$purchase->subtotal = $subtotal;
|
$purchase->subtotal = $subtotal;
|
||||||
$purchase->discount = $discount;
|
$purchase->discount = $discount;
|
||||||
|
$purchase->shipping_cost = $shippingCost;
|
||||||
$purchase->total = $total;
|
$purchase->total = $total;
|
||||||
$purchase->notes = $validated['notes'] ?? null;
|
$purchase->notes = $validated['notes'] ?? null;
|
||||||
$purchase->save();
|
$purchase->save();
|
||||||
|
|||||||
@ -191,7 +191,6 @@ public function update(RawMaterial $rawMaterial, array $validated): void
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($validated, $rawMaterial): void {
|
DB::transaction(function () use ($validated, $rawMaterial): void {
|
||||||
$rawMaterial->name = $validated['name'];
|
$rawMaterial->name = $validated['name'];
|
||||||
$rawMaterial->unit = $validated['unit'];
|
|
||||||
$rawMaterial->save();
|
$rawMaterial->save();
|
||||||
|
|
||||||
$submittedPriceIds = collect($validated['prices'])
|
$submittedPriceIds = collect($validated['prices'])
|
||||||
|
|||||||
@ -16,13 +16,15 @@ public function definition(): array
|
|||||||
{
|
{
|
||||||
$subtotal = fake()->numberBetween(100_000, 10_000_000);
|
$subtotal = fake()->numberBetween(100_000, 10_000_000);
|
||||||
$discount = fake()->numberBetween(0, (int) ($subtotal * 0.1));
|
$discount = fake()->numberBetween(0, (int) ($subtotal * 0.1));
|
||||||
|
$shippingCost = fake()->numberBetween(0, 100_000);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'supplier_id' => Supplier::factory(),
|
'supplier_id' => Supplier::factory(),
|
||||||
'created_by_id' => User::factory(),
|
'created_by_id' => User::factory(),
|
||||||
'subtotal' => $subtotal,
|
'subtotal' => $subtotal,
|
||||||
'discount' => $discount,
|
'discount' => $discount,
|
||||||
'total' => $subtotal - $discount,
|
'shipping_cost' => $shippingCost,
|
||||||
|
'total' => $subtotal - $discount + $shippingCost,
|
||||||
'notes' => fake()->optional()->sentence(3),
|
'notes' => fake()->optional()->sentence(3),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ public function up(): void
|
|||||||
|
|
||||||
$table->unsignedBigInteger('subtotal');
|
$table->unsignedBigInteger('subtotal');
|
||||||
$table->unsignedBigInteger('discount')->default(0);
|
$table->unsignedBigInteger('discount')->default(0);
|
||||||
|
$table->unsignedBigInteger('shipping_cost')->default(0);
|
||||||
$table->unsignedBigInteger('total');
|
$table->unsignedBigInteger('total');
|
||||||
$table->string('notes', 100)->nullable();
|
$table->string('notes', 100)->nullable();
|
||||||
|
|
||||||
|
|||||||
@ -53,7 +53,7 @@ function destroyAttendance() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Dialog v-model:open="open">
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>Detail Presensi</DialogTitle>
|
<DialogTitle>Detail Presensi</DialogTitle>
|
||||||
<DialogDescription v-if="attendance">
|
<DialogDescription v-if="attendance">
|
||||||
@ -64,7 +64,7 @@ function destroyAttendance() {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</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">
|
<dl class="grid grid-cols-2 gap-x-4 gap-y-3 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-muted-foreground">
|
<dt class="text-muted-foreground">
|
||||||
|
|||||||
@ -117,6 +117,8 @@ function getGroupedItems(items: any[]): GroupedPurchaseItems[] {
|
|||||||
}}</strong></span>
|
}}</strong></span>
|
||||||
<span>Diskon <strong class="text-foreground">{{ purchase.discount_formatted
|
<span>Diskon <strong class="text-foreground">{{ purchase.discount_formatted
|
||||||
}}</strong></span>
|
}}</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>
|
<span>Total <strong class="text-primary">{{ purchase.total_formatted }}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="purchase.notes" class="text-muted-foreground text-sm">
|
<p v-if="purchase.notes" class="text-muted-foreground text-sm">
|
||||||
|
|||||||
@ -49,6 +49,7 @@ const props = defineProps<{
|
|||||||
initialData?: {
|
initialData?: {
|
||||||
supplier_id: string;
|
supplier_id: string;
|
||||||
discount: string;
|
discount: string;
|
||||||
|
shipping_cost: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
items: PurchaseCartItem[];
|
items: PurchaseCartItem[];
|
||||||
photos?: MediaItem | null;
|
photos?: MediaItem | null;
|
||||||
@ -69,6 +70,7 @@ const currentPhotoUrl = computed(() => props.initialData?.photos?.url ?? null);
|
|||||||
const form = useForm({
|
const form = useForm({
|
||||||
supplier_id: '',
|
supplier_id: '',
|
||||||
discount: '',
|
discount: '',
|
||||||
|
shipping_cost: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
photos: [] as File[],
|
photos: [] as File[],
|
||||||
remove_media_ids: [] as number[],
|
remove_media_ids: [] as number[],
|
||||||
@ -88,6 +90,7 @@ function populateForm() {
|
|||||||
|
|
||||||
form.supplier_id = props.initialData.supplier_id;
|
form.supplier_id = props.initialData.supplier_id;
|
||||||
form.discount = props.initialData.discount;
|
form.discount = props.initialData.discount;
|
||||||
|
form.shipping_cost = props.initialData.shipping_cost ?? '0';
|
||||||
form.notes = props.initialData.notes;
|
form.notes = props.initialData.notes;
|
||||||
form.photos = [];
|
form.photos = [];
|
||||||
form.remove_media_ids = [];
|
form.remove_media_ids = [];
|
||||||
@ -153,8 +156,9 @@ const subtotal = computed(() =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
const discountAmount = computed(() => Number(parseRupiah(form.discount)) || 0);
|
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 {
|
function getCartItem(priceId: number): PurchaseCartItem | undefined {
|
||||||
return cart.value.find((item) => item.raw_material_price_id === priceId);
|
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('supplier_id', form.supplier_id);
|
||||||
formData.append('discount', parseRupiah(form.discount));
|
formData.append('discount', parseRupiah(form.discount));
|
||||||
|
formData.append('shipping_cost', parseRupiah(form.shipping_cost));
|
||||||
formData.append('notes', form.notes);
|
formData.append('notes', form.notes);
|
||||||
|
|
||||||
if (props.method === 'put') {
|
if (props.method === 'put') {
|
||||||
@ -511,6 +516,11 @@ function submit() {
|
|||||||
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
|
<RupiahInput id="discount" v-model="form.discount" placeholder="0" />
|
||||||
<FieldError :errors="formErrors(form, 'discount')" />
|
<FieldError :errors="formErrors(form, 'discount')" />
|
||||||
</Field>
|
</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">
|
<div class="flex justify-between text-base font-semibold">
|
||||||
<span>Total</span>
|
<span>Total</span>
|
||||||
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
|
<span class="text-primary">Rp {{ formatRupiah(total) }}</span>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useForm } from '@inertiajs/vue3';
|
import { useForm } from '@inertiajs/vue3';
|
||||||
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
import { Copy, Plus, Save, Trash2 } from '@lucide/vue';
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
import { DecimalInput } from '@/components/form/decimal-input';
|
import { DecimalInput } from '@/components/form/decimal-input';
|
||||||
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
import { MultipleImageUploadField } from '@/components/form/image-upload-field';
|
||||||
@ -94,6 +94,14 @@ const form = useForm({
|
|||||||
unit: props.initialData?.unit ?? '',
|
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 {
|
function allPricesHaveSameValue(items: RawMaterialPriceFormItem[]): boolean {
|
||||||
if (items.length <= 1) {
|
if (items.length <= 1) {
|
||||||
return true;
|
return true;
|
||||||
@ -237,7 +245,10 @@ function submit() {
|
|||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel for="unit" required>Satuan</FieldLabel>
|
<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">
|
<SelectTrigger id="unit" class="w-full">
|
||||||
<SelectValue placeholder="Pilih satuan" />
|
<SelectValue placeholder="Pilih satuan" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@ -247,6 +258,16 @@ function submit() {
|
|||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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')" />
|
<FieldError :errors="formErrors(form, 'unit')" />
|
||||||
</Field>
|
</Field>
|
||||||
</FieldSet>
|
</FieldSet>
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const props = defineProps<{
|
|||||||
const initialData = computed(() => ({
|
const initialData = computed(() => ({
|
||||||
supplier_id: String(props.purchase.supplier_id),
|
supplier_id: String(props.purchase.supplier_id),
|
||||||
discount: String(props.purchase.discount),
|
discount: String(props.purchase.discount),
|
||||||
|
shipping_cost: String(props.purchase.shipping_cost ?? 0),
|
||||||
notes: props.purchase.notes ?? '',
|
notes: props.purchase.notes ?? '',
|
||||||
items: props.purchase.items.map((item) => ({
|
items: props.purchase.items.map((item) => ({
|
||||||
raw_material_price_id: item.raw_material_price_id,
|
raw_material_price_id: item.raw_material_price_id,
|
||||||
|
|||||||
@ -24,6 +24,8 @@ export type PurchaseListItem = {
|
|||||||
} | null;
|
} | null;
|
||||||
subtotal_formatted: string;
|
subtotal_formatted: string;
|
||||||
discount_formatted: string;
|
discount_formatted: string;
|
||||||
|
shipping_cost: number;
|
||||||
|
shipping_cost_formatted: string;
|
||||||
total_formatted: string;
|
total_formatted: string;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
created_at_formatted: string;
|
created_at_formatted: string;
|
||||||
@ -61,6 +63,7 @@ export type PurchaseEditItem = {
|
|||||||
id: number;
|
id: number;
|
||||||
supplier_id: number;
|
supplier_id: number;
|
||||||
discount: number;
|
discount: number;
|
||||||
|
shipping_cost: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
photos?: MediaItem | null;
|
photos?: MediaItem | null;
|
||||||
items: Array<{
|
items: Array<{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user