feat: add created_at formatting and update order calculations in Order model and observer
This commit is contained in:
parent
2763f1fbcc
commit
7d4368f774
@ -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);
|
||||||
|
|||||||
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user