store/resources/js/pages/admin/manage/orders/Show.vue
Yoga Pangestu 15e2b7ec5b
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
feat: add material_result field to CuttingMaterial and CuttingMaterialCombination; update validation rules and service logic to handle new material result calculations; enhance frontend components for displaying material results in cutting management
2026-07-04 23:14:37 +07:00

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>