x
This commit is contained in:
1 parent
4fcdd8a816
commit
f49776f9cb
14 files changed
+4012
-21
No files matched your search
@@ -0,0 +1,495 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
X,
|
||||
Receipt,
|
||||
DollarSign,
|
||||
Wallet,
|
||||
BadgePercent,
|
||||
Coins,
|
||||
Package,
|
||||
Truck,
|
||||
ExternalLink,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Copy,
|
||||
Check,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { TIKTOK_SHOPS } from '../../pages/TiktokOrderPage';
|
||||
|
||||
function fmt(val: number | null | undefined) {
|
||||
if (val == null) return '0.00';
|
||||
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||
}
|
||||
|
||||
interface TiktokFeeBreakdownModalProps {
|
||||
order: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TiktokFeeBreakdownModal({ order, onClose }: TiktokFeeBreakdownModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Keyboard navigation & body scroll lock
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
// Prevent background page from scrolling
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
document.body.style.overflow = originalOverflow;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!order || typeof document === 'undefined') return null;
|
||||
|
||||
const settlement = order.settlement;
|
||||
const isSettled = order.isSettled || !!settlement;
|
||||
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
|
||||
|
||||
// Financial figures (Settlement fees are usually stored as negative numbers from TikTok reports)
|
||||
const grossRevenue = settlement?.grossRevenue ?? settlement?.subtotalAfterSellerDiscount ?? order.orderAmount ?? 0;
|
||||
const sellerDiscount = Math.abs(settlement?.sellerDiscount ?? 0);
|
||||
const subtotalBeforeDiscount = settlement?.subtotalBeforeDiscount ?? (grossRevenue + sellerDiscount);
|
||||
|
||||
const commissionFee = Math.abs(settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||
const affiliateCommission = Math.abs(settlement?.affiliateCommission ?? order.affiliateFee ?? 0);
|
||||
const affiliatePartnerFee = Math.abs(settlement?.affiliatePartnerFee ?? 0);
|
||||
const totalAffiliate = affiliateCommission + affiliatePartnerFee;
|
||||
const orderFee = Math.abs(settlement?.orderFee ?? 0);
|
||||
const growthSupportFee = Math.abs(settlement?.growthSupportFee ?? 0);
|
||||
const shippingFeeActual = Math.abs(settlement?.shippingFeeActual ?? 0);
|
||||
const platformShippingDiscount = Math.abs(settlement?.platformShippingDiscount ?? 0);
|
||||
const netShippingToSeller = Math.max(0, shippingFeeActual - platformShippingDiscount);
|
||||
|
||||
// Total deductions
|
||||
const rawTotalFee = settlement?.totalFee != null
|
||||
? Math.abs(settlement.totalFee)
|
||||
: (commissionFee + totalAffiliate + orderFee + growthSupportFee + netShippingToSeller);
|
||||
|
||||
// Net payout
|
||||
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (grossRevenue - commissionFee - totalAffiliate);
|
||||
|
||||
// BOM & Net Profit
|
||||
const totalBomCost = order.orderTotalBomCost || 0;
|
||||
const hasMissingBom = order.hasIncompleteBom || (order.missingBomCount ?? 0) > 0;
|
||||
const realNetProfit = netSettlement - totalBomCost;
|
||||
const marginOnSales = grossRevenue > 0 ? (realNetProfit / grossRevenue) * 100 : 0;
|
||||
const markupOnCost = totalBomCost > 0 ? (realNetProfit / totalBomCost) * 100 : 0;
|
||||
const realNetMargin = marginOnSales;
|
||||
|
||||
const handleCopyOrderId = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(order.orderId);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center p-3 sm:p-5 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="bg-slate-900 border border-white/15 rounded-3xl w-full max-w-5xl xl:max-w-6xl max-h-[92vh] flex flex-col shadow-2xl shadow-black/90 overflow-hidden text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-violet-500/20 to-cyan-500/20 border border-violet-500/30 flex items-center justify-center shadow-lg shadow-violet-500/10 shrink-0">
|
||||
<Receipt className="w-5 h-5 text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-bold text-white text-base">
|
||||
{t('tiktokOrders.feeModalTitle', 'TikTok Fee & Settlement Breakdown')}
|
||||
</h3>
|
||||
{isSettled ? (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 inline-flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
{t('tiktokOrders.settlementCompleteBadge', 'โอนเงินแล้ว')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30 inline-flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{t('tiktokOrders.settlementPendingBadge', 'รอ Settlement')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 mt-0.5 flex-wrap">
|
||||
<span className="font-mono text-rose-400 font-bold">#{order.orderId}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyOrderId}
|
||||
className="text-slate-400 hover:text-white p-0.5 rounded transition-colors inline-flex items-center gap-1 text-[11px]"
|
||||
title={t('tiktokOrders.copyOrderId', 'คัดลอก Order ID')}
|
||||
>
|
||||
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
||||
{copied && <span className="text-[10px] text-emerald-400 font-sans">{t('tiktokOrders.copied', 'คัดลอกแล้ว')}</span>}
|
||||
</button>
|
||||
<span>•</span>
|
||||
<span className={`font-semibold ${shopCfg.color}`}>{shopCfg.label}</span>
|
||||
{order.buyerUsername && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-slate-300">ผู้ซื้อ: {order.buyerUsername}</span>
|
||||
</>
|
||||
)}
|
||||
{order.createdTime && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-slate-400 font-mono">{order.createdTime.slice(0, 16)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all ml-2"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="p-6 space-y-5 overflow-y-auto">
|
||||
{/* Pending Settlement Notice if not settled */}
|
||||
{!settlement && (
|
||||
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/30 flex items-start gap-3 text-amber-300 text-xs">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div className="space-y-0.5">
|
||||
<p className="font-bold text-amber-200">
|
||||
{t('tiktokOrders.settlementPendingAlert', 'คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement')}
|
||||
</p>
|
||||
<p className="text-amber-300/80 leading-relaxed text-[11px]">
|
||||
{t(
|
||||
'tiktokOrders.settlementPendingDesc',
|
||||
'ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5 Financial Summary Cards (Compact Banner) */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-2.5">
|
||||
{/* 1. Revenue */}
|
||||
<div className="p-3.5 rounded-2xl bg-white/5 border border-white/10 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-slate-400 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.orderRevenueLabel', 'ยอดขายสินค้า')}</span>
|
||||
<DollarSign className="w-3.5 h-3.5 text-slate-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-white mt-1">฿{fmt(grossRevenue)}</p>
|
||||
<span className="text-[10px] text-slate-500 truncate" title={sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดร้านค้าแล้ว'}>
|
||||
{sellerDiscount > 0 ? `ลด ฿${fmt(sellerDiscount)} จาก ฿${fmt(subtotalBeforeDiscount)}` : 'รวมส่วนลดแล้ว'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 2. TikTok Deductions */}
|
||||
<div className="p-3.5 rounded-2xl bg-rose-500/10 border border-rose-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-rose-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.tiktokDeductionsLabel', 'TikTok หักรวม')}</span>
|
||||
<BadgePercent className="w-3.5 h-3.5 text-rose-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-rose-400 mt-1">-฿{fmt(rawTotalFee)}</p>
|
||||
<span className="text-[10px] text-rose-400/80 truncate">
|
||||
{grossRevenue > 0 ? `${((rawTotalFee / grossRevenue) * 100).toFixed(1)}% ของยอดขาย` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 3. Net Payout */}
|
||||
<div className="p-3.5 rounded-2xl bg-cyan-500/10 border border-cyan-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-cyan-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.netPayoutLabel', 'ยอดโอนสุทธิ')}</span>
|
||||
<Wallet className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-cyan-400 mt-1">฿{fmt(netSettlement)}</p>
|
||||
<span className="text-[10px] text-cyan-400/80 truncate">
|
||||
{settlement?.orderSettledTime ? `โอนเมื่อ ${settlement.orderSettledTime.slice(0, 10)}` : 'รอสรุปยอดโอน'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 4. Product Cost (BOM) */}
|
||||
<div className="p-3.5 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex flex-col justify-between min-h-[96px]">
|
||||
<div className="flex items-center justify-between text-amber-300 text-[11px]">
|
||||
<span className="truncate">{t('tiktokOrders.cardBomCost', 'ต้นทุนสินค้า (BOM)')}</span>
|
||||
<Coins className="w-3.5 h-3.5 text-amber-400 shrink-0" />
|
||||
</div>
|
||||
<p className="text-lg font-bold font-mono text-amber-400 mt-1">฿{fmt(totalBomCost)}</p>
|
||||
<span className="text-[10px] text-amber-400/80 truncate">
|
||||
{hasMissingBom
|
||||
? '⚠️ มีสินค้ายังไม่ใส่ BOM'
|
||||
: grossRevenue > 0
|
||||
? `${((totalBomCost / grossRevenue) * 100).toFixed(1)}% ของยอดขาย`
|
||||
: 'BOM ครบ 100%'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 5. Net Profit (with Margin % on sales and Markup % on cost) */}
|
||||
<div className={`p-3.5 rounded-2xl border flex flex-col justify-between min-h-[96px] ${
|
||||
realNetProfit >= 0 ? 'bg-emerald-500/10 border-emerald-500/25' : 'bg-rose-500/10 border-rose-500/25'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className={`truncate font-semibold ${realNetProfit >= 0 ? 'text-emerald-300' : 'text-rose-300'}`}>
|
||||
{t('tiktokOrders.cardNetProfit', 'กำไรสุทธิ')}
|
||||
</span>
|
||||
<TrendingUp className={`w-3.5 h-3.5 shrink-0 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`} />
|
||||
</div>
|
||||
<p className={`text-lg font-bold font-mono mt-1 ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
฿{fmt(realNetProfit)}
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-mono leading-tight flex-wrap">
|
||||
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||
กำไร/ขาย {marginOnSales.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-slate-500">•</span>
|
||||
<span className={realNetProfit >= 0 ? 'text-emerald-300 font-semibold' : 'text-rose-300 font-semibold'}>
|
||||
กำไร/ทุน {markupOnCost >= 0 ? '+' : ''}{markupOnCost.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2-Column Wide Landscape Grid Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5 items-start">
|
||||
{/* Left Column (7 Cols): TikTok Platform Fees Breakdown */}
|
||||
<div className="lg:col-span-7 p-4 sm:p-5 rounded-2xl bg-black/30 border border-white/10 space-y-3.5">
|
||||
<div className="flex items-center justify-between pb-2.5 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<BadgePercent className="w-4 h-4 text-violet-400" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-300">
|
||||
{t('tiktokOrders.feeBreakdownTitle', 'Breakdown รายการหักค่าใช้จ่ายของ TikTok')}
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-bold text-rose-400">-฿{fmt(rawTotalFee)}</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5 text-xs">
|
||||
{/* Commission Fee */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-violet-500/10 border border-violet-500/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-violet-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-violet-200">
|
||||
{t('tiktokOrders.tiktokCommissionFee', 'ค่าคอมมิชชั่น TikTok Shop (Commission Fee)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-violet-300/70">
|
||||
{t('tiktokOrders.tiktokCommissionDesc', 'หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-violet-400 text-sm">
|
||||
{commissionFee > 0 ? `-฿${fmt(commissionFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Affiliate Creator Commission */}
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-cyan-500/10 border border-cyan-500/20">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-cyan-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-cyan-200">
|
||||
{t('tiktokOrders.affiliateCommissionFee', 'ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-cyan-300/70">
|
||||
{t('tiktokOrders.affiliateCommissionDesc', 'จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono font-bold text-cyan-400 text-sm">
|
||||
{affiliateCommission > 0 ? `-฿${fmt(affiliateCommission)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Affiliate Partner Fee (TAP) */}
|
||||
{(affiliatePartnerFee > 0 || settlement) && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-cyan-400/60" />
|
||||
<span>{t('tiktokOrders.affiliatePartnerFee', 'ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-cyan-300 font-semibold">
|
||||
{affiliatePartnerFee > 0 ? `-฿${fmt(affiliatePartnerFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Order / Transaction Fee */}
|
||||
{(orderFee > 0 || settlement) && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-amber-400" />
|
||||
<span>{t('tiktokOrders.orderFee', 'ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-amber-400 font-semibold">
|
||||
{orderFee > 0 ? `-฿${fmt(orderFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Growth Support Fee */}
|
||||
{growthSupportFee > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 rounded-xl bg-white/[0.02] border border-white/5 text-slate-300">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
<span>{t('tiktokOrders.growthSupportFee', 'ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)')}</span>
|
||||
</div>
|
||||
<span className="font-mono text-blue-400 font-semibold">
|
||||
-฿{fmt(growthSupportFee)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shipping Breakdown */}
|
||||
{(shippingFeeActual > 0 || platformShippingDiscount > 0) && (
|
||||
<div className="p-3 rounded-xl bg-white/[0.02] border border-white/5 space-y-1">
|
||||
<div className="flex items-center justify-between text-slate-300 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Truck className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span>{t('tiktokOrders.shippingBreakdown', 'ค่าจัดส่งขนส่ง')}</span>
|
||||
</div>
|
||||
<span className="font-mono font-semibold text-slate-200">
|
||||
{netShippingToSeller > 0 ? `-฿${fmt(netShippingToSeller)}` : t('tiktokOrders.shippingFreeForSeller', 'ร้านค้าไม่เสียค่าส่ง')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] pl-5 text-slate-400">
|
||||
<span>
|
||||
{t('tiktokOrders.shippingBreakdownDetail', {
|
||||
actual: fmt(shippingFeeActual),
|
||||
subsidy: fmt(platformShippingDiscount),
|
||||
defaultValue: `ค่าส่งจริง ฿${fmt(shippingFeeActual)} | TikTok อุดหนุน -฿${fmt(platformShippingDiscount)}`
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column (5 Cols): BOM Profitability & Order Items */}
|
||||
<div className="lg:col-span-5 space-y-4">
|
||||
{/* BOM Profitability Analysis Card */}
|
||||
<div
|
||||
className={`p-4 sm:p-5 rounded-2xl border space-y-3 ${
|
||||
hasMissingBom ? 'bg-rose-500/10 border-rose-500/25' : 'bg-emerald-500/10 border-emerald-500/25'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between pb-2 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className={`w-4 h-4 ${hasMissingBom ? 'text-rose-400' : 'text-emerald-400'}`} />
|
||||
<h4 className="text-xs font-bold uppercase tracking-wider text-slate-200">
|
||||
{t('tiktokOrders.bomProfitAnalysis', 'วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM')}
|
||||
</h4>
|
||||
</div>
|
||||
{hasMissingBom ? (
|
||||
<span className="text-[10px] font-bold text-rose-300 px-2 py-0.5 rounded-full bg-rose-500/20 border border-rose-500/30">
|
||||
{t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-emerald-300 px-2 py-0.5 rounded-full bg-emerald-500/20 border border-emerald-500/30">
|
||||
{t('tiktokOrders.bomComplete100', 'BOM ครบ 100%')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex justify-between items-center text-slate-300">
|
||||
<span>{t('tiktokOrders.netPayoutLabel', 'ยอดเงินที่ TikTok โอนให้')}</span>
|
||||
<span className="font-mono font-bold text-white">฿{fmt(netSettlement)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-amber-300">
|
||||
<span>{t('tiktokOrders.totalBomCostLabel', 'หัก ต้นทุนสินค้าตามสูตร BOM')}</span>
|
||||
<span className="font-mono font-bold">
|
||||
{totalBomCost > 0 ? `-฿${fmt(totalBomCost)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2.5 border-t border-white/10 flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-bold text-white text-sm">
|
||||
{t('tiktokOrders.realNetProfitLabel', 'กำไรสุทธิแท้จริง (Real Net Profit)')}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-400">
|
||||
{t('tiktokOrders.realNetMarginLabel', 'คิดเป็นอัตรากำไร')}:{' '}
|
||||
<span className="font-mono font-bold text-slate-200">{realNetMargin.toFixed(1)}%</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`text-2xl font-bold font-mono ${realNetProfit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||
฿{fmt(realNetProfit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Items Snippet */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<div className="p-4 rounded-2xl bg-black/20 border border-white/10 space-y-2.5">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-400 pb-1 border-b border-white/5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Package className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>
|
||||
{t('tiktokOrders.orderItemsTitle', 'รายการสินค้าในออเดอร์นี้')} ({order.items.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5 max-h-48 overflow-y-auto pr-1">
|
||||
{order.items.map((item: any, idx: number) => (
|
||||
<div key={idx} className="py-2 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-slate-200 font-medium truncate">{item.productName}</p>
|
||||
<p className="text-[11px] text-slate-400 font-mono">
|
||||
{item.variation ? `[${item.variation}] ` : ''}จำนวน: {item.quantity} ชิ้น
|
||||
{item.masterSku ? ` • Master: ${item.masterSku}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0 font-mono">
|
||||
<p className="text-white font-bold">
|
||||
฿{fmt(item.subtotalAfterDiscount || item.unitOriginalPrice * (item.quantity || 1))}
|
||||
</p>
|
||||
<p className="text-[10px] text-amber-400">BOM: ฿{fmt(item.totalBomCost || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
|
||||
<Link
|
||||
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-xl bg-rose-500/15 hover:bg-rose-500/25 border border-rose-500/30 text-rose-300 font-bold text-xs transition-colors"
|
||||
>
|
||||
<span>{t('tiktokOrders.viewFullOrderDetail', 'เปิดดูคำสั่งซื้อฉบับเต็ม')}</span>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
|
||||
>
|
||||
{t('tiktokOrders.closeModal', 'ปิด')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -454,6 +454,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "Net Profit",
|
||||
"thActions": "Actions",
|
||||
"viewFeeBreakdown": "Click to view TikTok fee breakdown",
|
||||
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||
"feeModalSub": "Detailed breakdown of TikTok Shop commission, affiliate fees, and net payout",
|
||||
"settlementPendingAlert": "This order does not have a TikTok Settlement statement yet",
|
||||
"settlementPendingDesc": "Commissions and fees below are preliminary estimates from order data until a Settlement XLSX file is imported from TikTok Seller Center.",
|
||||
"settlementCompleteBadge": "Settled & Transferred",
|
||||
"settlementPendingBadge": "Pending Settlement",
|
||||
"orderRevenueLabel": "Product Revenue",
|
||||
"tiktokDeductionsLabel": "Total TikTok Deductions",
|
||||
"netPayoutLabel": "Net Payout",
|
||||
"cardBomCost": "Product Cost (BOM)",
|
||||
"cardNetProfit": "Net Profit",
|
||||
"profitOnSales": "Profit/Sales",
|
||||
"profitOnCost": "Profit/Cost",
|
||||
"feeBreakdownTitle": "TikTok Platform Fee Deductions Breakdown",
|
||||
"tiktokCommissionFee": "TikTok Shop Commission Fee",
|
||||
"tiktokCommissionDesc": "Platform category commission rate deduction",
|
||||
"affiliateCommissionFee": "Creator Affiliate Commission Fee",
|
||||
"affiliateCommissionDesc": "Commission paid to creators/affiliates",
|
||||
"affiliatePartnerFee": "Affiliate Partner Fee (TAP)",
|
||||
"orderFee": "Order / Transaction Fee",
|
||||
"growthSupportFee": "Growth Support Fee",
|
||||
"shippingBreakdown": "Shipping & Logistics Fees",
|
||||
"shippingBreakdownDetail": "Actual Shipping ฿{{actual}} | Platform Subsidy -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "Seller shipping covered",
|
||||
"sellerDiscountLabel": "Seller Discount",
|
||||
"bomProfitAnalysis": "Real Net Profit Analysis (After BOM Cost)",
|
||||
"totalBomCostLabel": "Deduct: BOM Material Cost",
|
||||
"realNetProfitLabel": "Real Net Profit",
|
||||
"realNetMarginLabel": "Net Margin",
|
||||
"orderItemsTitle": "Items in this Order",
|
||||
"viewFullOrderDetail": "View Full Order Detail",
|
||||
"closeModal": "Close",
|
||||
"copyOrderId": "Copy Order ID",
|
||||
"copied": "Copied",
|
||||
"processingData": "Processing data...",
|
||||
"noOrders": "No data available",
|
||||
"settledTransferred": "Settled ฿{{amount}}",
|
||||
|
||||
@@ -450,6 +450,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "純利益",
|
||||
"thActions": "操作",
|
||||
"viewFeeBreakdown": "クリックしてTikTok手数料明細を表示",
|
||||
"feeModalTitle": "TikTok 手数料・精算内訳 (Breakdown)",
|
||||
"feeModalSub": "TikTok Shop手数料、アフィリエイト報酬、純利益の詳細内訳",
|
||||
"settlementPendingAlert": "この注文にはまだTikTok精算明細データがありません",
|
||||
"settlementPendingDesc": "以下の手数料と数値は注文データに基づく概算です。TikTokセラーセンターから精算XLSXがインポートされると確定値に更新されます。",
|
||||
"settlementCompleteBadge": "振込完了 (Settled)",
|
||||
"settlementPendingBadge": "精算待ち",
|
||||
"orderRevenueLabel": "商品売上高",
|
||||
"tiktokDeductionsLabel": "TikTok 控除合計",
|
||||
"netPayoutLabel": "振込受取額 (Net Payout)",
|
||||
"cardBomCost": "商品原価 (BOM)",
|
||||
"cardNetProfit": "純利益",
|
||||
"profitOnSales": "売上利益率",
|
||||
"profitOnCost": "原価利益率",
|
||||
"feeBreakdownTitle": "TikTok プラットフォーム控除の内訳",
|
||||
"tiktokCommissionFee": "TikTok Shop プラットフォーム手数料 (Commission)",
|
||||
"tiktokCommissionDesc": "カテゴリ別プラットフォーム手数料率による控除",
|
||||
"affiliateCommissionFee": "クリエイターアフィリエイト報酬 (Affiliate)",
|
||||
"affiliateCommissionDesc": "紹介・ライブ配信クリエイターへの支払報酬",
|
||||
"affiliatePartnerFee": "パートナー手数料 (TAP)",
|
||||
"orderFee": "注文・決済処理手数料 (Order Fee)",
|
||||
"growthSupportFee": "成長支援手数料 (Growth Support)",
|
||||
"shippingBreakdown": "配送・物流手数料内訳",
|
||||
"shippingBreakdownDetail": "実送料 ฿{{actual}} | プラットフォーム補助 -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "ショップ負担なし",
|
||||
"sellerDiscountLabel": "ショップ割引",
|
||||
"bomProfitAnalysis": "BOM原価控除後の実質純利益分析",
|
||||
"totalBomCostLabel": "控除:BOM原料および包材原価",
|
||||
"realNetProfitLabel": "実質純利益 (Real Net Profit)",
|
||||
"realNetMarginLabel": "純利益率",
|
||||
"orderItemsTitle": "注文内商品一覧",
|
||||
"viewFullOrderDetail": "注文詳細画面を開く",
|
||||
"closeModal": "閉じる",
|
||||
"copyOrderId": "注文番号をコピー",
|
||||
"copied": "コピー完了",
|
||||
"processingData": "データを処理中...",
|
||||
"noOrders": "データがありません",
|
||||
"settledTransferred": "振込済 ฿{{amount}}",
|
||||
|
||||
@@ -454,6 +454,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "กำไรสุทธิ",
|
||||
"thActions": "การกระทำ",
|
||||
"viewFeeBreakdown": "คลิกดู Breakdown ค่าธรรมเนียม TikTok",
|
||||
"feeModalTitle": "TikTok Fee & Settlement Breakdown",
|
||||
"feeModalSub": "รายละเอียดค่าธรรมเนียม TikTok Shop, Affiliate และกำไรสุทธิ",
|
||||
"settlementPendingAlert": "คำสั่งซื้อนี้ยังไม่มีข้อมูลใบแจ้งยอด Settlement",
|
||||
"settlementPendingDesc": "ค่าคอมมิชชั่นและตัวเลขด้านล่างเป็นการคำนวณเบื้องต้นจากข้อมูล Order จนกว่าจะมีการ Import ไฟล์ Settlement XLSX จาก TikTok Seller Center",
|
||||
"settlementCompleteBadge": "โอนเงินแล้ว",
|
||||
"settlementPendingBadge": "รอ Settlement",
|
||||
"orderRevenueLabel": "ยอดขายสินค้า",
|
||||
"tiktokDeductionsLabel": "TikTok หักรวม",
|
||||
"netPayoutLabel": "ยอดโอนสุทธิ",
|
||||
"cardBomCost": "ต้นทุนสินค้า (BOM)",
|
||||
"cardNetProfit": "กำไรสุทธิ",
|
||||
"profitOnSales": "กำไร/ยอดขาย",
|
||||
"profitOnCost": "กำไร/ทุน",
|
||||
"feeBreakdownTitle": "Breakdown รายการหักค่าใช้จ่ายของ TikTok",
|
||||
"tiktokCommissionFee": "ค่าคอมมิชชั่น TikTok Shop (Commission Fee)",
|
||||
"tiktokCommissionDesc": "หักตามอัตราหมวดหมู่สินค้าของแพลตฟอร์ม",
|
||||
"affiliateCommissionFee": "ค่าคอมมิชชั่น Creator แอฟฟิลิเอต (Affiliate)",
|
||||
"affiliateCommissionDesc": "จ่ายให้ครีเอเตอร์/นายหน้าที่แปะลิงก์หรือไลฟ์",
|
||||
"affiliatePartnerFee": "ค่าคอมมิชชั่น Partner แอฟฟิลิเอต (TAP)",
|
||||
"orderFee": "ค่าธรรมเนียมคำสั่งซื้อ/ธุรกรรม (Order Fee)",
|
||||
"growthSupportFee": "ค่าธรรมเนียมสนับสนุนการเติบโต (Growth Support)",
|
||||
"shippingBreakdown": "ค่าจัดส่งขนส่ง",
|
||||
"shippingBreakdownDetail": "ค่าส่งจริง ฿{{actual}} | TikTok อุดหนุน -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "ร้านค้าไม่เสียค่าส่ง",
|
||||
"sellerDiscountLabel": "ส่วนลดจากร้านค้า",
|
||||
"bomProfitAnalysis": "วิเคราะห์กำไรสุทธิจริงหลังหักต้นทุน BOM",
|
||||
"totalBomCostLabel": "หัก ต้นทุนสินค้าตามสูตร BOM",
|
||||
"realNetProfitLabel": "กำไรสุทธิแท้จริง (Real Net Profit)",
|
||||
"realNetMarginLabel": "คิดเป็นอัตรากำไร",
|
||||
"orderItemsTitle": "รายการสินค้าในออเดอร์นี้",
|
||||
"viewFullOrderDetail": "เปิดดูคำสั่งซื้อฉบับเต็ม",
|
||||
"closeModal": "ปิด",
|
||||
"copyOrderId": "คัดลอก Order ID",
|
||||
"copied": "คัดลอกแล้ว",
|
||||
"processingData": "กำลังประมวลผลข้อมูล...",
|
||||
"noOrders": "ยังไม่มีข้อมูล",
|
||||
"settledTransferred": "โอนแล้ว ฿{{amount}}",
|
||||
|
||||
@@ -450,6 +450,41 @@
|
||||
"thAffiliate": "Affiliate (฿)",
|
||||
"thNetProfit": "净利润",
|
||||
"thActions": "操作",
|
||||
"viewFeeBreakdown": "点击查看 TikTok 费用明细",
|
||||
"feeModalTitle": "TikTok 费用与结算明细",
|
||||
"feeModalSub": "TikTok Shop 佣金、达人佣金与净利润详细拆解",
|
||||
"settlementPendingAlert": "该订单暂无 TikTok 结算对账单数据",
|
||||
"settlementPendingDesc": "以下佣金与费用为基于订单数据的预估,待从 TikTok 卖家中心导入结算 XLSX 文件后更新。",
|
||||
"settlementCompleteBadge": "已完成打款",
|
||||
"settlementPendingBadge": "等待结算",
|
||||
"orderRevenueLabel": "商品销售额",
|
||||
"tiktokDeductionsLabel": "TikTok 扣款合计",
|
||||
"netPayoutLabel": "最终打款金额",
|
||||
"cardBomCost": "商品成本 (BOM)",
|
||||
"cardNetProfit": "净利润",
|
||||
"profitOnSales": "利润率(销售)",
|
||||
"profitOnCost": "利润率(成本)",
|
||||
"feeBreakdownTitle": "TikTok 平台扣款明细",
|
||||
"tiktokCommissionFee": "TikTok Shop 平台佣金 (Commission Fee)",
|
||||
"tiktokCommissionDesc": "按平台品类佣金率扣除",
|
||||
"affiliateCommissionFee": "达人/分销佣金 (Affiliate Commission)",
|
||||
"affiliateCommissionDesc": "支付给带货达人的佣金",
|
||||
"affiliatePartnerFee": "机构/合作伙伴佣金 (TAP)",
|
||||
"orderFee": "订单/交易手续费 (Order Fee)",
|
||||
"growthSupportFee": "成长支持费 (Growth Support Fee)",
|
||||
"shippingBreakdown": "物流运费明细",
|
||||
"shippingBreakdownDetail": "实际运费 ฿{{actual}} | 平台补贴 -฿{{subsidy}}",
|
||||
"shippingFreeForSeller": "商家免运费",
|
||||
"sellerDiscountLabel": "商家折扣",
|
||||
"bomProfitAnalysis": "扣除 BOM 成本后真实净利润分析",
|
||||
"totalBomCostLabel": "扣除:BOM 原料及包装成本",
|
||||
"realNetProfitLabel": "真实净利润 (Real Net Profit)",
|
||||
"realNetMarginLabel": "净利润率",
|
||||
"orderItemsTitle": "订单内商品列表",
|
||||
"viewFullOrderDetail": "查看订单完整详情",
|
||||
"closeModal": "关闭",
|
||||
"copyOrderId": "复制订单号",
|
||||
"copied": "已复制",
|
||||
"processingData": "正在处理数据...",
|
||||
"noOrders": "暂无数据",
|
||||
"settledTransferred": "已打款 ฿{{amount}}",
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||
|
||||
function authHeaders() {
|
||||
return {
|
||||
@@ -66,6 +67,7 @@ export default function TiktokOrderDetailPage() {
|
||||
const [order, setOrder] = useState<any | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showFeeModal, setShowFeeModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId) return;
|
||||
@@ -410,19 +412,35 @@ export default function TiktokOrderDetailPage() {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-violet-300">
|
||||
<span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
|
||||
<span className="font-mono font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeeModal(true)}
|
||||
className="w-full flex justify-between items-center text-violet-300 hover:bg-violet-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||
{t('tiktokOrderDetail.commissionSummary')}
|
||||
<Receipt className="w-3 h-3 text-violet-400/50 group-hover:text-violet-300 transition-colors" />
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-xs text-violet-400 group-hover:underline">
|
||||
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex justify-between items-center text-cyan-300">
|
||||
<span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
|
||||
<span className="font-mono font-semibold">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowFeeModal(true)}
|
||||
className="w-full flex justify-between items-center text-cyan-300 hover:bg-cyan-500/10 p-1.5 -mx-1.5 rounded-lg transition-colors cursor-pointer group"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="text-slate-400 group-hover:text-slate-200 text-xs flex items-center gap-1">
|
||||
{t('tiktokOrderDetail.affiliateSummary')}
|
||||
<Receipt className="w-3 h-3 text-cyan-400/50 group-hover:text-cyan-300 transition-colors" />
|
||||
</span>
|
||||
<span className="font-mono font-semibold text-xs text-cyan-400 group-hover:underline">
|
||||
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||
<div>
|
||||
@@ -527,6 +545,13 @@ export default function TiktokOrderDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFeeModal && (
|
||||
<TiktokFeeBreakdownModal
|
||||
order={order}
|
||||
onClose={() => setShowFeeModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
||||
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
|
||||
FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw,
|
||||
BadgePercent, Wallet, Receipt
|
||||
BadgePercent, Wallet, Receipt, Info
|
||||
} from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
|
||||
import { TiktokFeeBreakdownModal } from '../components/tiktok-order/TiktokFeeBreakdownModal';
|
||||
|
||||
// ── Shop Config ───────────────────────────────────────────────────────────────
|
||||
export const TIKTOK_SHOPS = [
|
||||
@@ -164,6 +165,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [bomFilter, setBomFilter] = useState('all');
|
||||
const [selectedFeeOrder, setSelectedFeeOrder] = useState<any | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -396,8 +398,24 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
<th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
|
||||
<th
|
||||
className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400 cursor-help"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
{t('tiktokOrders.thCommission')}
|
||||
<Info className="w-3 h-3 text-violet-400/50" />
|
||||
</span>
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400 cursor-help"
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 justify-end">
|
||||
{t('tiktokOrders.thAffiliate')}
|
||||
<Info className="w-3 h-3 text-cyan-400/50" />
|
||||
</span>
|
||||
</th>
|
||||
<th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
|
||||
<th className="px-4 py-3.5 text-center" />
|
||||
</tr>
|
||||
@@ -507,20 +525,42 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
|
||||
{/* Commission (฿) */}
|
||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||
{order.commissionFee > 0 ? (
|
||||
<span className="text-violet-400 font-bold">-฿{fmt(order.commissionFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFeeOrder(order);
|
||||
}}
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-violet-500/15 border border-transparent hover:border-violet-500/30 transition-all cursor-pointer"
|
||||
>
|
||||
{order.commissionFee > 0 ? (
|
||||
<span className="text-violet-400 font-bold group-hover/btn:underline">-฿{fmt(order.commissionFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||
)}
|
||||
<Receipt className="w-3 h-3 text-violet-400/40 group-hover/btn:text-violet-300 transition-colors" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Affiliate (฿) */}
|
||||
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
|
||||
{order.affiliateFee > 0 ? (
|
||||
<span className="text-cyan-400 font-bold">-฿{fmt(order.affiliateFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px]">-</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFeeOrder(order);
|
||||
}}
|
||||
title={t('tiktokOrders.viewFeeBreakdown', 'คลิกดู Breakdown ค่าธรรมเนียม TikTok')}
|
||||
className="inline-flex items-center gap-1 group/btn px-2 py-1 rounded-lg hover:bg-cyan-500/15 border border-transparent hover:border-cyan-500/30 transition-all cursor-pointer"
|
||||
>
|
||||
{order.affiliateFee > 0 ? (
|
||||
<span className="text-cyan-400 font-bold group-hover/btn:underline">-฿{fmt(order.affiliateFee)}</span>
|
||||
) : (
|
||||
<span className="text-slate-500 font-mono text-[11px] group-hover/btn:text-slate-300">-</span>
|
||||
)}
|
||||
<Receipt className="w-3 h-3 text-cyan-400/40 group-hover/btn:text-cyan-300 transition-colors" />
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* Profit */}
|
||||
@@ -553,6 +593,14 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Fee Breakdown Modal */}
|
||||
{selectedFeeOrder && (
|
||||
<TiktokFeeBreakdownModal
|
||||
order={selectedFeeOrder}
|
||||
onClose={() => setSelectedFeeOrder(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -469,6 +469,33 @@ export const toolsRoute = new Elysia({ prefix: "/tools" })
|
||||
}
|
||||
)
|
||||
|
||||
// ── 4.1 POST Bulk Link / Unlink Master Variants ──────────────────────────────
|
||||
.post(
|
||||
"/marketplace-products/bulk-map",
|
||||
async ({ body }: { body: { mappings: Array<{ marketplaceProductId: number; masterVariantId: number | null }> } }) => {
|
||||
const { mappings } = body;
|
||||
await prisma.$transaction(
|
||||
mappings.map((m) =>
|
||||
prisma.marketplaceProduct.update({
|
||||
where: { id: m.marketplaceProductId },
|
||||
data: { masterVariantId: m.masterVariantId },
|
||||
})
|
||||
)
|
||||
);
|
||||
return { success: true, count: mappings.length };
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
mappings: t.Array(
|
||||
t.Object({
|
||||
marketplaceProductId: t.Number(),
|
||||
masterVariantId: t.Nullable(t.Number()),
|
||||
})
|
||||
),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
// ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
|
||||
.post("/marketplace-products/bulk-auto-match", async () => {
|
||||
const masterVariants = await prisma.productVariant.findMany({
|
||||
|
||||
@@ -430,6 +430,64 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["productId", "variants"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "list_marketplace_products",
|
||||
description: "List marketplace products (TikTok, Shopee, Lazada) extracted from orders. Allows filtering by status ('unmapped', 'mapped', 'all'), channel ('tiktok', 'shopee', 'lazada', 'all'), search text, and pagination. Returns sales metrics and current masterVariant mapping.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
channel: { type: "string", enum: ["tiktok", "shopee", "lazada", "all"], description: "Marketplace channel. Default is 'all'." },
|
||||
status: { type: "string", enum: ["unmapped", "mapped", "all"], description: "Mapping status. Default is 'unmapped'." },
|
||||
search: { type: "string", description: "Search by product name, variation, or SKU" },
|
||||
page: { type: "number", description: "Page number. Default 1." },
|
||||
limit: { type: "number", description: "Items per page. Default 50." }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_master_product_variants",
|
||||
description: "List all master product variants from the core catalog for mapping, including product name, options (size/scent), SKU, barcode, and unit BOM cost.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
search: { type: "string", description: "Search by product name, variant option, or SKU" }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "map_marketplace_product",
|
||||
description: "Link or unlink a single marketplace product (TikTok/Shopee/Lazada) to a master product variant (ProductVariant) to enable BOM cost and profitability calculation. Pass masterVariantId=null to unlink.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
|
||||
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
|
||||
},
|
||||
required: ["marketplaceProductId", "masterVariantId"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "bulk_map_marketplace_products",
|
||||
description: "Link multiple marketplace products to master catalog variants in a single batch operation.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
mappings: {
|
||||
type: "array",
|
||||
description: "Array of mappings to apply",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
marketplaceProductId: { type: "number", description: "Marketplace product ID" },
|
||||
masterVariantId: { type: ["number", "null"], description: "Master variant ID, or null to unlink" }
|
||||
},
|
||||
required: ["marketplaceProductId", "masterVariantId"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["mappings"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_category_translation",
|
||||
description: "Add or update translations for a specific category. Do NOT use this tool for products.",
|
||||
@@ -1524,6 +1582,41 @@ async function handleToolRequest(request: CallToolRequest) {
|
||||
const res = await api.put(`/products/${params.productId}/bom`, { variants: params.variants });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "list_marketplace_products": {
|
||||
const query = new URLSearchParams();
|
||||
if (params.channel) query.set("channel", String(params.channel));
|
||||
if (params.status) query.set("status", String(params.status));
|
||||
if (params.search) query.set("search", String(params.search));
|
||||
if (params.page) query.set("page", String(params.page));
|
||||
query.set("limit", String(params.limit ?? 50));
|
||||
const res = await api.get(`/tools/marketplace-products?${query.toString()}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "get_master_product_variants": {
|
||||
const res = await api.get(`/tools/marketplace-products/master-variants`);
|
||||
let variants = res.data.variants || [];
|
||||
if (params.search) {
|
||||
const words = String(params.search).toLowerCase().split(/\s+/).filter(Boolean);
|
||||
variants = variants.filter((v: any) => {
|
||||
const target = `${v.displayName} ${v.sku} ${v.productName} ${v.optionsStr}`.toLowerCase();
|
||||
return words.every((w: string) => target.includes(w));
|
||||
});
|
||||
}
|
||||
return { content: [{ type: "text", text: JSON.stringify({ variants }, null, 2) }] };
|
||||
}
|
||||
case "map_marketplace_product": {
|
||||
const res = await api.post(`/tools/marketplace-products/map`, {
|
||||
marketplaceProductId: params.marketplaceProductId,
|
||||
masterVariantId: params.masterVariantId,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "bulk_map_marketplace_products": {
|
||||
const res = await api.post(`/tools/marketplace-products/bulk-map`, {
|
||||
mappings: params.mappings,
|
||||
});
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "create_product": {
|
||||
const res = await api.post(`/products`, params);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
|
||||
Reference in new issue
Block a user