496 lines
23 KiB
Vue
496 lines
23 KiB
Vue
<script setup lang="ts">
|
|
import { Head, Link, router } from '@inertiajs/vue3';
|
|
import {
|
|
CreditCard,
|
|
MapPin,
|
|
Package,
|
|
Pencil,
|
|
Phone,
|
|
Receipt,
|
|
User,
|
|
} from '@lucide/vue';
|
|
import { computed, ref } from 'vue';
|
|
import { toast } from 'vue-sonner';
|
|
import { RowDeleteAction, RowDetailAction, RowEditAction, RowStatusAction } from '@/components/button';
|
|
import BackButton from '@/components/button/BackButton.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 { useDestroy } from '@/composables/useDestroy';
|
|
import { MarketplaceFeeScope } from '@/constants/marketplace-fee-scope';
|
|
import { MarketplaceFeeValueType } from '@/constants/marketplace-fee-value-type';
|
|
import {
|
|
orderStatusActionIcon,
|
|
orderStatusBadgeVariant,
|
|
orderStatusTransitionConfirmDescription,
|
|
} from '@/constants/order-status';
|
|
import { StockQuality } from '@/constants/stock-quality';
|
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { index, edit, destroy, transition_status } from '@/routes/admin/manage/orders';
|
|
import type { OrderDetail, OrderStatusAction } from '@/types/order';
|
|
|
|
const props = defineProps<{
|
|
order: OrderDetail;
|
|
}>();
|
|
|
|
const { can } = useCan();
|
|
|
|
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 { open: deleteConfirmOpen, processing: deleteProcessing, destroy: destroyOrder } = useDestroy({
|
|
url: destroy.url(props.order.id),
|
|
preserveScroll: false,
|
|
errorMessage: 'Gagal menghapus pesanan.',
|
|
});
|
|
|
|
function canPerformAction(action: OrderStatusAction): boolean {
|
|
return can(action.permission);
|
|
}
|
|
|
|
function statusConfirmDescription(action: OrderStatusAction): string {
|
|
return orderStatusTransitionConfirmDescription(action.status, props.order.order_number);
|
|
}
|
|
|
|
function openStatusConfirm(action: OrderStatusAction) {
|
|
pendingAction.value = action;
|
|
statusConfirmOpen.value = true;
|
|
}
|
|
|
|
function transitionStatus() {
|
|
if (!pendingAction.value) {
|
|
return;
|
|
}
|
|
|
|
statusProcessing.value = true;
|
|
|
|
router.post(transition_status.url(props.order.id), {
|
|
status: pendingAction.value.status,
|
|
}, {
|
|
onSuccess: () => {
|
|
statusConfirmOpen.value = false;
|
|
pendingAction.value = null;
|
|
},
|
|
onError: (errors: any) => {
|
|
if (errors.system) {
|
|
toast.error(errors.system);
|
|
}
|
|
},
|
|
onFinish: () => {
|
|
statusProcessing.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.nego_price != null && props.order.nego_price > 0) {
|
|
rows.push({ label: 'Harga Nego', value: props.order.nego_price_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>
|
|
</div>
|
|
|
|
<div class="flex shrink-0 items-center gap-2 self-start sm:self-center">
|
|
<BackButton :href="index.url()" />
|
|
</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="orderStatusBadgeVariant(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">{{ order.creator_name }}</p>
|
|
</div>
|
|
<div v-if="order.marketing">
|
|
<p class="text-muted-foreground text-sm">Marketing</p>
|
|
<p class="font-medium">{{ order.marketing_name }}</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>
|
|
|
|
<!-- Fee & Profit Breakdown -->
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2">
|
|
<Receipt class="size-5" />
|
|
<span v-if="snapshot">Rincian Biaya {{ snapshot.platform === 'tiktok' ? 'TikTok Shop' : 'Shopee' }}</span>
|
|
<span v-else>Rincian Keuntungan</span>
|
|
<Badge v-if="snapshot?.is_affiliate" variant="outline" class="ml-1">Afiliasi</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div v-if="snapshot" 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>
|
|
<Separator class="my-4" />
|
|
</div>
|
|
|
|
<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 ? snapshot.base_amount : order.total_amount) }}</span>
|
|
</div>
|
|
<div v-if="snapshot" class="flex items-center justify-between text-sm">
|
|
<span class="text-muted-foreground">Total Potongan Marketplace</span>
|
|
<span class="tabular-nums text-destructive">-{{ formatRp(snapshot.total_fee_amount)
|
|
}}</span>
|
|
</div>
|
|
<div v-if="order.total_cost_price > 0" class="flex items-center justify-between text-sm">
|
|
<span class="text-muted-foreground">Total Harga Modal</span>
|
|
<span class="tabular-nums text-destructive">-{{ order.total_cost_price_formatted }}</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">
|
|
{{ order.net_amount_formatted }}
|
|
</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>
|
|
|
|
<!-- Bukti Transaksi -->
|
|
<Card v-if="order.photos">
|
|
<CardHeader>
|
|
<CardTitle class="flex items-center gap-2">
|
|
<Receipt class="size-5" />
|
|
Bukti Transaksi
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<a :href="order.photos.url" target="_blank" class="block overflow-hidden rounded-md border">
|
|
<img :src="order.photos.thumb_url" alt="Bukti Transaksi" class="w-full object-cover">
|
|
</a>
|
|
</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>
|