584 lines
25 KiB
Vue
584 lines
25 KiB
Vue
<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 './form/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 { MarketplaceFeeScope } from '@/constants/marketplace-fee-scope';
|
|
import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type';
|
|
import { OrderStatus } from '@/constants/order-status';
|
|
import { StockQuality } from '@/constants/stock-quality';
|
|
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 === OrderStatus.COMPLETED) return 'default';
|
|
if (status === OrderStatus.CANCELLED) return 'destructive';
|
|
if (status === OrderStatus.PROCESSING) return 'secondary';
|
|
return 'outline';
|
|
}
|
|
|
|
function actionIcon(status: string) {
|
|
if (status === OrderStatus.PROCESSING) return Send;
|
|
if (status === OrderStatus.COMPLETED) return Check;
|
|
return X;
|
|
}
|
|
|
|
function canPerformAction(action: OrderStatusAction): boolean {
|
|
return can(action.permission);
|
|
}
|
|
|
|
function statusConfirmDescription(action: OrderStatusAction): string {
|
|
if (action.status === OrderStatus.PROCESSING) {
|
|
return `Pesanan ${props.order.order_number} akan dikirim dan diproses.`;
|
|
}
|
|
if (action.status === OrderStatus.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 === MarketplaceFeeScope.PRODUCT ? '/produk' : '/transaksi';
|
|
|
|
if (fee.value_type === MarketplaceFeeValueType.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 === StockQuality.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>
|