feat: add order detail view and service method for enhanced order management and user experience

This commit is contained in:
Yoga Pangestu 2026-06-20 19:07:40 +07:00
parent 44312e76c9
commit ee8949947f
6 changed files with 727 additions and 1 deletions

View File

@ -36,6 +36,13 @@ public function index(Request $request): Response
]);
}
public function show(Order $order): Response
{
return Inertia::render('admin/manage/orders/Show', [
'order' => $this->orderService->findForShow($order),
]);
}
public function create(Request $request): Response
{
$user = $request->user();

View File

@ -197,6 +197,40 @@ public function findForEdit(Order $order): Order
return $order;
}
public function findForShow(Order $order): Order
{
$order->load([
'customer:id,name,phone_number,address',
'createdBy.profile',
'marketing.profile',
'items.productVariant.product:id,name',
'items.productVariant.media',
'cashTransaction:id,amount,description,created_at',
]);
$order->items->each(function (OrderItem $item): void {
$variant = $item->productVariant;
if ($variant) {
$item->setAttribute('product_name', $variant->product?->name);
$item->setAttribute('variant_name', $variant->name);
$variant->setAttribute(
'images',
MediaPresenter::collection($variant, 'images'),
);
}
});
$availableActions = collect($order->status->availableActions())
->values()
->all();
$order->setAttribute('available_actions', $availableActions);
$order->setAttribute('is_editable', $order->status->isEditable());
return $order;
}
/**
* @return list<array<string, mixed>>
*/

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { Link, router } from '@inertiajs/vue3';
import { Check, Pencil, Send, Trash2, X } from '@lucide/vue';
import { Check, Eye, Pencil, Send, Trash2, X } from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import OrderPrintButton from '@/components/admin/manage/orders/OrderPrintButton.vue';
@ -137,6 +137,18 @@ function actionIcon(status: string) {
</Button>
</template>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>
<Link :href="`/admin/manage/orders/${order.id}`">
<Eye class="size-4" />
<span class="sr-only">Detail</span>
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Detail</TooltipContent>
</Tooltip>
<Tooltip v-if="canEdit">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="size-8" as-child>

View File

@ -0,0 +1,579 @@
<script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3';
import {
ArrowLeft,
Check,
CreditCard,
MapPin,
Package,
Pencil,
Phone,
Receipt,
Send,
Trash2,
Truck,
User,
X,
} from '@lucide/vue';
import { computed, ref } from 'vue';
import { toast } from 'vue-sonner';
import OrderPrintButton from '@/components/admin/manage/orders/OrderPrintButton.vue';
import ConfirmDialog from '@/components/ConfirmDialog.vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useCan } from '@/composables/useCan';
import AdminLayout from '@/layouts/AdminLayout.vue';
import type { OrderDetail, OrderStatusAction } from '@/types/order';
const props = defineProps<{
order: OrderDetail;
}>();
const { can } = useCan();
const deleteConfirmOpen = ref(false);
const deleteProcessing = ref(false);
const statusConfirmOpen = ref(false);
const statusProcessing = ref(false);
const pendingAction = ref<OrderStatusAction | null>(null);
const canEdit = computed(() => props.order.is_editable && can('orders.update'));
const canDelete = computed(() => can('orders.delete'));
const creatorName = computed(() => {
return props.order.created_by?.profile?.full_name ?? props.order.created_by?.username ?? '-';
});
const marketingName = computed(() => {
return props.order.marketing?.profile?.full_name ?? props.order.marketing?.username ?? '-';
});
function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' {
if (status === 'completed') return 'default';
if (status === 'cancelled') return 'destructive';
if (status === 'processing') return 'secondary';
return 'outline';
}
function actionIcon(status: string) {
if (status === 'processing') return Send;
if (status === 'completed') return Check;
return X;
}
function canPerformAction(action: OrderStatusAction): boolean {
return can(action.permission);
}
function statusConfirmDescription(action: OrderStatusAction): string {
if (action.status === 'processing') {
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
}
if (action.status === 'completed') {
return `Pesanan ${props.order.order_number} akan ditandai selesai.`;
}
return `Pesanan ${props.order.order_number} akan dibatalkan. Stok produk akan dikembalikan.`;
}
function openStatusConfirm(action: OrderStatusAction) {
pendingAction.value = action;
statusConfirmOpen.value = true;
}
function transitionStatus() {
if (!pendingAction.value) return;
statusProcessing.value = true;
router.post(`/admin/manage/orders/${props.order.id}/status`, {
status: pendingAction.value.status,
}, {
onSuccess: () => {
statusConfirmOpen.value = false;
pendingAction.value = null;
},
onError: (errors) => {
const message = Object.values(errors)[0];
toast.error(typeof message === 'string' ? message : 'Gagal memperbarui status pesanan.');
},
onFinish: () => {
statusProcessing.value = false;
},
});
}
function destroyOrder() {
deleteProcessing.value = true;
router.delete(`/admin/manage/orders/${props.order.id}`, {
onSuccess: () => {
deleteConfirmOpen.value = false;
},
onError: () => {
toast.error('Gagal menghapus pesanan.');
},
onFinish: () => {
deleteProcessing.value = false;
},
});
}
const items = computed(() => props.order.items ?? []);
const summaryRows = computed(() => {
const rows = [
{ label: 'Subtotal', value: props.order.subtotal_formatted },
];
if (props.order.discount > 0) {
rows.push({ label: 'Diskon', value: `-${props.order.discount_formatted}` });
}
if (props.order.shipping_cost > 0) {
rows.push({ label: 'Ongkos Kirim', value: props.order.shipping_cost_formatted });
}
return rows;
});
const snapshot = computed(() => props.order.marketplace_settings_snapshot);
const TIKTOK_FEE_LABELS: Record<string, string> = {
platform_commission: 'Komisi Platform',
logistics_service_fee: 'Biaya Layanan Logistik',
dynamic_commission: 'Komisi Dinamis',
order_processing_fee: 'Biaya Pemrosesan Pesanan',
affiliate: 'Komisi Afiliasi',
pre_order_service_fee: 'Biaya Layanan Pre-Order',
};
const SHOPEE_FEE_LABELS: Record<string, string> = {
admin_fee: 'Biaya Admin',
program_fee: 'Biaya Program',
shipping_savings: 'Pengiriman Hemat',
premium: 'Premium',
service_fee: 'Biaya Layanan',
order_processing_fee: 'Biaya Pemrosesan Pesanan',
ams_commission_fee: 'Komisi AMS',
pre_order: 'Pre-Order',
live_extra: 'Live Extra',
};
function feeLabel(key: string): string {
if (snapshot.value?.platform === 'tiktok') {
return TIKTOK_FEE_LABELS[key] ?? key;
}
return SHOPEE_FEE_LABELS[key] ?? key;
}
function formatRp(amount: number): string {
return `Rp ${new Intl.NumberFormat('id-ID').format(amount)}`;
}
function formatFeeRule(fee: { scope: string; value_type: string; value: number }): string {
const scope = fee.scope === 'product' ? '/produk' : '/transaksi';
if (fee.value_type === 'percent') {
return `${fee.value}%${scope}`;
}
return `${formatRp(fee.value)}${scope}`;
}
const feeEntries = computed(() => {
if (!snapshot.value) return [];
return Object.entries(snapshot.value.fees)
.filter(([, rule]) => rule.value > 0)
.map(([key, rule]) => ({
key,
label: feeLabel(key),
rule: formatFeeRule(rule),
amount: snapshot.value!.results[key]?.amount ?? 0,
balanceAfter: snapshot.value!.results[key]?.balance_after ?? 0,
}));
});
</script>
<template>
<Head :title="`Detail Pesanan ${order.order_number}`" />
<AdminLayout>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1">
<h2 class="text-2xl font-bold tracking-tight">
Detail Pesanan
</h2>
<p class="text-muted-foreground text-sm">
{{ order.order_number }}
</p>
</div>
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
<OrderPrintButton :order="order as any" />
<template v-for="action in order.available_actions" :key="action.status">
<Button
v-if="canPerformAction(action)"
size="sm"
:variant="action.destructive ? 'outline' : 'default'"
:class="action.destructive ? 'text-destructive hover:text-destructive' : ''"
@click="openStatusConfirm(action)"
>
<component :is="actionIcon(action.status)" class="size-3.5" />
{{ action.label }}
</Button>
</template>
<Button v-if="canEdit" variant="outline" size="sm" as-child>
<Link :href="`/admin/manage/orders/${order.id}/edit`">
<Pencil class="size-3.5" />
Ubah
</Link>
</Button>
<Button
v-if="canDelete"
variant="outline"
size="sm"
class="text-destructive hover:text-destructive"
@click="deleteConfirmOpen = true"
>
<Trash2 class="size-3.5" />
Hapus
</Button>
<Button variant="outline" size="sm" as-child>
<Link href="/admin/manage/orders">
<ArrowLeft class="size-3.5" />
Kembali
</Link>
</Button>
</div>
</div>
<div class="grid gap-6 lg:grid-cols-3">
<!-- Main content -->
<div class="space-y-6 lg:col-span-2">
<!-- Order Info -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Package class="size-5" />
Informasi Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-3">
<div>
<p class="text-muted-foreground text-sm">Nomor Pesanan</p>
<p class="font-medium">{{ order.order_number }}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Status</p>
<Badge :variant="statusVariant(order.status)" class="mt-1">
{{ order.status_label }}
</Badge>
</div>
<div>
<p class="text-muted-foreground text-sm">Channel</p>
<div class="mt-1 flex flex-wrap items-center gap-1.5">
<Badge variant="secondary">{{ order.channel_label }}</Badge>
<Badge v-if="order.tiktok_order_id" variant="outline">
TikTok: {{ order.tiktok_order_id }}
</Badge>
<Badge v-if="order.shopee_order_id" variant="outline">
Shopee: {{ order.shopee_order_id }}
</Badge>
<Badge v-if="order.is_affiliate" variant="outline">
Afiliasi
</Badge>
</div>
</div>
<div>
<p class="text-muted-foreground text-sm">Tanggal Pesanan</p>
<p class="font-medium">{{ order.created_at_formatted }}</p>
</div>
</div>
<div class="space-y-3">
<div>
<p class="text-muted-foreground text-sm">Tipe Harga</p>
<p class="font-medium">{{ order.price_type_label }}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Tipe Pembayaran</p>
<p class="font-medium">{{ order.payment_type_label }}</p>
</div>
<div>
<p class="text-muted-foreground text-sm">Dibuat Oleh</p>
<p class="font-medium">{{ creatorName }}</p>
</div>
<div v-if="order.marketing">
<p class="text-muted-foreground text-sm">Marketing</p>
<p class="font-medium">{{ marketingName }}</p>
</div>
</div>
</div>
<div v-if="order.notes" class="mt-4 rounded-md border border-dashed p-3">
<p class="text-muted-foreground mb-1 text-sm">Catatan</p>
<p class="text-sm">{{ order.notes }}</p>
</div>
</CardContent>
</Card>
<!-- Order Items -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Package class="size-5" />
Produk Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead class="w-12">#</TableHead>
<TableHead>Produk</TableHead>
<TableHead>Varian</TableHead>
<TableHead>Kualitas</TableHead>
<TableHead class="text-right">Jumlah</TableHead>
<TableHead class="text-right">Harga Satuan</TableHead>
<TableHead class="text-right">Subtotal</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="!items.length">
<TableCell colspan="7" class="text-muted-foreground text-center">
Tidak ada item
</TableCell>
</TableRow>
<TableRow v-for="(item, index) in items" :key="item.id">
<TableCell class="text-muted-foreground tabular-nums">
{{ index + 1 }}
</TableCell>
<TableCell class="font-medium">
{{ item.product_name || item.product_variant?.product?.name }}
</TableCell>
<TableCell>
{{ item.variant_name || item.product_variant?.name }}
</TableCell>
<TableCell>
<Badge variant="outline" class="text-xs">
{{ item.stock_quality === 'reject' ? 'Reject' : 'Bagus' }}
</Badge>
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.quantity_formatted }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ item.unit_price_formatted }}
</TableCell>
<TableCell class="text-right tabular-nums font-medium">
{{ item.subtotal_formatted }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
<!-- Marketplace Fee Breakdown -->
<Card v-if="snapshot">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Receipt class="size-5" />
Rincian Biaya {{ snapshot.platform === 'tiktok' ? 'TikTok Shop' : 'Shopee' }}
<Badge v-if="snapshot.is_affiliate" variant="outline" class="ml-1">Afiliasi</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div class="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Jenis Biaya</TableHead>
<TableHead>Aturan</TableHead>
<TableHead class="text-right">Potongan</TableHead>
<TableHead class="text-right">Sisa Saldo</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="entry in feeEntries" :key="entry.key">
<TableCell class="font-medium">
{{ entry.label }}
</TableCell>
<TableCell class="text-muted-foreground text-sm">
{{ entry.rule }}
</TableCell>
<TableCell class="text-right tabular-nums text-destructive">
-{{ formatRp(entry.amount) }}
</TableCell>
<TableCell class="text-right tabular-nums">
{{ formatRp(entry.balanceAfter) }}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Separator class="my-4" />
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Dasar Perhitungan</span>
<span class="tabular-nums">{{ formatRp(snapshot.base_amount) }}</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Total Potongan</span>
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount) }}</span>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="font-semibold">Pendapatan Bersih</span>
<span class="text-lg font-bold tabular-nums text-green-600">
{{ formatRp(snapshot.net_amount) }}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Customer Info -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<User class="size-5" />
Pelanggan
</CardTitle>
</CardHeader>
<CardContent>
<div v-if="order.customer" class="space-y-3">
<div>
<p class="text-muted-foreground text-sm">Nama</p>
<p class="font-medium">{{ order.customer.name }}</p>
</div>
<div v-if="order.customer.phone_number" class="flex items-start gap-2">
<Phone class="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<p class="text-muted-foreground text-sm">Telepon</p>
<p class="font-medium">{{ order.customer.phone_number }}</p>
</div>
</div>
<div v-if="order.customer.address" class="flex items-start gap-2">
<MapPin class="text-muted-foreground mt-0.5 size-4 shrink-0" />
<div>
<p class="text-muted-foreground text-sm">Alamat</p>
<p class="font-medium whitespace-pre-line">{{ order.customer.address }}</p>
</div>
</div>
</div>
<p v-else class="text-muted-foreground text-sm">
Tidak ada pelanggan terkait
</p>
</CardContent>
</Card>
<!-- Financial Summary -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<CreditCard class="size-5" />
Ringkasan Pembayaran
</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div
v-for="row in summaryRows"
:key="row.label"
class="flex items-center justify-between text-sm"
>
<span class="text-muted-foreground">{{ row.label }}</span>
<span class="tabular-nums">{{ row.value }}</span>
</div>
<Separator />
<div class="flex items-center justify-between">
<span class="text-lg font-semibold">Total</span>
<span class="text-lg font-bold tabular-nums">
{{ order.total_amount_formatted }}
</span>
</div>
</div>
<div v-if="order.cash_transaction" class="mt-4 rounded-md bg-muted/50 p-3">
<p class="text-muted-foreground mb-1 text-xs">Transaksi Kas</p>
<p class="text-sm font-medium">{{ order.cash_transaction.description }}</p>
<p class="text-muted-foreground text-xs">
{{ order.cash_transaction.created_at }}
</p>
</div>
</CardContent>
</Card>
<!-- Shipping Info -->
<Card v-if="order.shipping_cost > 0">
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Truck class="size-5" />
Pengiriman
</CardTitle>
</CardHeader>
<CardContent>
<div class="space-y-2">
<div class="flex items-center justify-between text-sm">
<span class="text-muted-foreground">Ongkos Kirim</span>
<span class="font-medium tabular-nums">
{{ order.shipping_cost_formatted }}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</AdminLayout>
<ConfirmDialog
v-model:open="statusConfirmOpen"
:title="pendingAction ? `${pendingAction.label} pesanan?` : 'Ubah status pesanan?'"
:description="pendingAction ? statusConfirmDescription(pendingAction) : ''"
:confirm-label="pendingAction?.label ?? 'Konfirmasi'"
cancel-label="Batal"
:destructive="pendingAction?.destructive ?? false"
:loading="statusProcessing"
@confirm="transitionStatus"
/>
<ConfirmDialog
v-if="canDelete"
v-model:open="deleteConfirmOpen"
title="Hapus pesanan?"
:description="`Pesanan ${order.order_number} akan dihapus.${order.is_editable ? ' Stok produk akan dikembalikan.' : ''}`"
confirm-label="Hapus"
cancel-label="Batal"
destructive
:loading="deleteProcessing"
@confirm="destroyOrder"
/>
</template>

View File

@ -114,6 +114,98 @@ export type OrderEditItem = {
}>;
};
export type OrderDetail = {
id: number;
order_number: string;
channel: string;
channel_label: string;
price_type: string;
price_type_label: string;
payment_type: string;
payment_type_label: string;
is_affiliate: boolean;
status: string;
status_label: string;
is_editable: boolean;
available_actions: OrderStatusAction[];
tiktok_order_id: string | null;
shopee_order_id: string | null;
subtotal: number;
subtotal_formatted: string;
discount: number;
discount_formatted: string;
shipping_cost: number;
shipping_cost_formatted: string;
total_amount: number;
total_amount_formatted: string;
notes: string | null;
created_at_formatted: string;
customer?: {
id: number;
name: string;
phone_number: string | null;
address: string | null;
} | null;
created_by?: {
id: number;
username: string;
profile?: {
full_name: string;
} | null;
};
marketing?: {
id: number;
username: string;
profile?: {
full_name: string;
} | null;
} | null;
items: Array<{
id: number;
product_name: string;
variant_name: string;
stock_quality: string;
stock_quality_label?: string;
quantity: number;
quantity_formatted: string;
unit_price: number;
unit_price_formatted: string;
subtotal: number;
subtotal_formatted: string;
product_variant?: {
id: number;
name: string;
images?: MediaItem[];
product?: {
id: number;
name: string;
};
};
}>;
cash_transaction?: {
id: number;
amount: number;
description: string;
created_at: string;
} | null;
marketplace_settings_snapshot?: {
platform: string;
is_affiliate: boolean;
base_amount: number;
total_fee_amount: number;
net_amount: number;
fees: Record<string, {
scope: string;
value_type: string;
value: number;
}>;
results: Record<string, {
amount: number;
balance_after: number;
}>;
} | null;
};
export type PaginatedOrders = {
data: OrderListItem[];
current_page: number;

View File

@ -239,6 +239,8 @@
->middleware('permission:'.Permission::ORDERS_CREATE->value)
->name('store');
Route::get('{order}', [OrderController::class, 'show'])->name('show');
Route::get('{order}/edit', [OrderController::class, 'edit'])
->middleware('permission:'.Permission::ORDERS_UPDATE->value)
->name('edit');