feat: add created_at formatting and update order calculations in Order model and observer

This commit is contained in:
Yoga Pangestu 2026-04-30 15:48:05 +07:00
parent 2763f1fbcc
commit 7d4368f774
5 changed files with 131 additions and 47 deletions

View File

@ -20,7 +20,7 @@
use Spatie\Activitylog\Traits\LogsActivity;
#[Guarded(['id'])]
#[Appends(['cogs_formatted', 'subtotal_formatted', 'discount_formatted', 'payment_formatted', 'total_formatted'])]
#[Appends(['cogs_formatted', 'subtotal_formatted', 'discount_formatted', 'payment_formatted', 'total_formatted', 'created_at_formatted'])]
#[ObservedBy([OrderObserver::class])]
class Order extends Model
{
@ -43,35 +43,42 @@ protected function casts(): array
protected function cogsFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->cogs, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->cogs, 0, ',', '.'),
);
}
protected function subtotalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->subtotal, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->subtotal, 0, ',', '.'),
);
}
protected function discountFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->discount, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->discount, 0, ',', '.'),
);
}
protected function paymentFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->payment, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->payment, 0, ',', '.'),
);
}
protected function totalFormatted(): Attribute
{
return Attribute::make(
get: fn () => 'Rp '.number_format($this->total, 0, ',', '.'),
get: fn() => 'Rp ' . number_format($this->total, 0, ',', '.'),
);
}
protected function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn() => $this->created_at->translatedFormat('d F Y H:i'),
);
}

View File

@ -27,6 +27,14 @@ public function creating(Order $order): void
$order->total = $order->subtotal - $order->discount;
}
/**
* Handle the Order "updating" event.
*/
public function updating(Order $order): void
{
$order->total = $order->subtotal - $order->discount;
}
/**
* Handle the Order "deleting" event.
*/

View File

@ -9,10 +9,9 @@ import {
import { usePermission } from '@/hooks/use-permission';
import * as orderRoutes from '@/routes/order';
import type { Order } from '@/types/order';
import { ORDER_CHANNEL, ORDER_STATUS, PAYMENT_METHOD } from '@/types/order';
import { Link } from '@inertiajs/react';
import type { ColumnDef } from '@tanstack/react-table';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { Pencil, Printer, Trash2 } from 'lucide-react';
interface ColumnProps {
@ -43,11 +42,7 @@ export const getColumns = ({
<div className="flex flex-col">
<span className="font-medium">{row.original.order_number}</span>
<span className="text-xs text-muted-foreground">
{format(
new Date(row.original.created_at),
'dd MMM yyyy HH:mm',
{ locale: id },
)}
{row.original.created_at_formatted}
</span>
</div>
),
@ -65,12 +60,25 @@ export const getColumns = ({
variant="outline"
className="h-4 w-fit px-1 py-0 text-[10px]"
>
{row.original.order_channel}
{ORDER_CHANNEL[row.original.order_channel] ||
row.original.order_channel}
</Badge>
</div>
),
meta: { title: 'Pelanggan' },
},
{
accessorKey: 'cogs',
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Modal" />
),
cell: ({ row }) => (
<span className="text-sm text-muted-foreground">
{row.original.cogs_formatted}
</span>
),
meta: { title: 'Modal' },
},
{
accessorKey: 'items',
header: ({ column }) => (
@ -114,33 +122,55 @@ export const getColumns = ({
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Total" />
),
cell: ({ row }) => (
<div className="flex flex-col gap-1">
<span className="font-medium text-primary">
{row.original.subtotal_formatted}
</span>
<span className="text-xs text-muted-foreground">
- {row.original.discount_formatted}
</span>
<span className="font-bold text-primary">
{row.original.total_formatted}
</span>
<Badge
className="h-4 py-0 text-[10px]"
variant={
row.original.order_status === 'delivered'
? 'default'
: row.original.order_status === 'pending'
? 'secondary'
: row.original.order_status === 'cancelled'
? 'destructive'
: 'outline'
}
>
{row.original.order_status}
</Badge>
</div>
),
cell: ({ row }) => {
const order = row.original;
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">
Subtotal:
</span>
<span className="text-sm font-medium">
{order.subtotal_formatted}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">
Potongan:
</span>
<span className="text-sm text-red-500">
- {order.discount_formatted}
</span>
</div>
<div className="mt-1 flex flex-col border-t pt-1">
<span className="text-[10px] text-muted-foreground">
Total Tagihan:
</span>
<span className="text-base font-bold text-primary">
{order.total_formatted}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] text-muted-foreground">
Dibayar ({PAYMENT_METHOD[order.payment_method]}):
</span>
<span className="text-sm font-medium text-green-600">
{order.payment_formatted}
</span>
</div>
<Badge
className="mt-1 h-4 w-fit py-0 text-[10px]"
variant={
ORDER_STATUS[order.order_status]?.color ?? 'outline'
}
>
{ORDER_STATUS[order.order_status]?.label ??
order.order_status}
</Badge>
</div>
);
},
meta: { title: 'Total' },
},
{

View File

@ -1,6 +1,32 @@
import type { Product } from "./product";
import type { User } from "./auth";
export const ORDER_STATUS = {
pending: { label: 'Menunggu', color: 'secondary' },
processing: { label: 'Proses', color: 'outline' },
shipped: { label: 'Dikirim', color: 'outline' },
delivered: { label: 'Selesai', color: 'default' },
cancelled: { label: 'Gagal', color: 'destructive' },
} as const;
export const PAYMENT_METHOD = {
cash: 'Tunai',
transfer: 'Transfer',
e_wallet: 'E-Wallet',
qris: 'QRIS',
} as const;
export const ORDER_CHANNEL = {
tiktok: 'Tiktok',
shopee: 'Shopee',
tokopedia: 'Tokopedia',
lazada: 'Lazada',
facebook: 'Facebook',
website: 'Website',
store: 'Toko',
other: 'Lainnya',
} as const;
export interface Order {
id: number;
order_number: string;
@ -15,13 +41,14 @@ export interface Order {
payment_formatted: string;
total: number;
total_formatted: string;
payment_method: 'cash' | 'transfer' | 'e_wallet' | 'qris';
order_status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
order_channel: 'tiktok' | 'shopee' | 'tokopedia' | 'lazada' | 'facebook' | 'website' | 'store' | 'other';
payment_method: keyof typeof PAYMENT_METHOD;
order_status: keyof typeof ORDER_STATUS;
order_channel: keyof typeof ORDER_CHANNEL;
items?: OrderItem[];
user?: User;
created_at: string;
updated_at: string;
created_at_formatted: string;
}
export interface OrderItem {

View File

@ -56,6 +56,9 @@
->component('admin/manage/order/index')
->has('orders', fn ($page) => $page
->has('0.user')
->has('0.cogs_formatted')
->has('0.payment_formatted')
->has('0.created_at_formatted')
->etc()
)
);
@ -109,9 +112,8 @@
it('can store a new order', function () {
$product = Product::factory()->create(['stock' => 10]);
// Add a price for PURCHASE to avoid errors in COGS calculation if applicable
$product->prices()->create([
'price_type' => PriceType::PURCHASE->value,
// Update existing PURCHASE price from factory to ensure COGS calculation is predictable
$product->prices()->where('price_type', PriceType::PURCHASE->value)->update([
'price' => 8000,
]);
@ -140,7 +142,14 @@
assertDatabaseHas('orders', [
'customer_name' => 'John Doe',
'subtotal' => 20000,
'discount' => 2000,
'total' => 18000,
'payment' => 18000,
'payment_method' => PaymentMethod::CASH->value,
'order_status' => OrderStatus::PENDING->value,
'order_channel' => OrderChannel::STORE->value,
'cogs' => 16000, // 8000 * 2
]);
// Check stock decrement
@ -215,6 +224,9 @@
assertDatabaseHas('orders', [
'id' => $order->id,
'customer_name' => 'Updated Customer',
'order_status' => OrderStatus::PROCESSING->value,
'payment' => 15000,
'total' => 15000,
]);
// Check stock: product1 should be restored (10), product2 should be decremented (7)