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; use Spatie\Activitylog\Traits\LogsActivity;
#[Guarded(['id'])] #[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])] #[ObservedBy([OrderObserver::class])]
class Order extends Model class Order extends Model
{ {
@ -75,6 +75,13 @@ protected function totalFormatted(): Attribute
); );
} }
protected function createdAtFormatted(): Attribute
{
return Attribute::make(
get: fn() => $this->created_at->translatedFormat('d F Y H:i'),
);
}
public function user(): BelongsTo public function user(): BelongsTo
{ {
return $this->belongsTo(User::class); return $this->belongsTo(User::class);

View File

@ -27,6 +27,14 @@ public function creating(Order $order): void
$order->total = $order->subtotal - $order->discount; $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. * Handle the Order "deleting" event.
*/ */

View File

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

View File

@ -1,6 +1,32 @@
import type { Product } from "./product"; import type { Product } from "./product";
import type { User } from "./auth"; 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 { export interface Order {
id: number; id: number;
order_number: string; order_number: string;
@ -15,13 +41,14 @@ export interface Order {
payment_formatted: string; payment_formatted: string;
total: number; total: number;
total_formatted: string; total_formatted: string;
payment_method: 'cash' | 'transfer' | 'e_wallet' | 'qris'; payment_method: keyof typeof PAYMENT_METHOD;
order_status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; order_status: keyof typeof ORDER_STATUS;
order_channel: 'tiktok' | 'shopee' | 'tokopedia' | 'lazada' | 'facebook' | 'website' | 'store' | 'other'; order_channel: keyof typeof ORDER_CHANNEL;
items?: OrderItem[]; items?: OrderItem[];
user?: User; user?: User;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
created_at_formatted: string;
} }
export interface OrderItem { export interface OrderItem {

View File

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