This commit is contained in:
Your Name committed 2026-09-06 14:39:13 +07:00
1 parent abee5249ca
commit 2c8da0ae2a
24 files changed
+9277 -278

No files matched your search

+13
View File
@@ -32,9 +32,11 @@ import TiktokPage from './pages/TiktokPage'
import TiktokAnalysisPage from './pages/TiktokAnalysisPage' import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
import ShopeePage from './pages/ShopeePage' import ShopeePage from './pages/ShopeePage'
import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage' import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage'
import ShopeeSalesMapPage from './pages/ShopeeSalesMapPage'
import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage' import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage'
import ShopeeOverviewPage from './pages/ShopeeOverviewPage' import ShopeeOverviewPage from './pages/ShopeeOverviewPage'
import TiktokOrderPage from './pages/TiktokOrderPage' import TiktokOrderPage from './pages/TiktokOrderPage'
import TiktokOrderDetailPage from './pages/TiktokOrderDetailPage'
import TiktokSalesMapPage from './pages/TiktokSalesMapPage' import TiktokSalesMapPage from './pages/TiktokSalesMapPage'
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage' import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
import TiktokOverviewPage from './pages/TiktokOverviewPage' import TiktokOverviewPage from './pages/TiktokOverviewPage'
@@ -54,6 +56,9 @@ import BackupCatalogPage from './pages/backup/BackupCatalogPage'
import BackupOrdersPage from './pages/backup/BackupOrdersPage' import BackupOrdersPage from './pages/backup/BackupOrdersPage'
import BackupCmsPage from './pages/backup/BackupCmsPage' import BackupCmsPage from './pages/backup/BackupCmsPage'
import BackupMediaPage from './pages/backup/BackupMediaPage' import BackupMediaPage from './pages/backup/BackupMediaPage'
import OmnichannelSalesMapPage from './pages/analytics/OmnichannelSalesMapPage'
import ProductSalesMapPage from './pages/analytics/ProductSalesMapPage'
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
function App() { function App() {
return ( return (
@@ -103,9 +108,11 @@ function App() {
<Route path="/shopee-shop-case" element={<ShopeeShopCasePage />} /> <Route path="/shopee-shop-case" element={<ShopeeShopCasePage />} />
<Route path="/shopee" element={<ShopeePage />} /> <Route path="/shopee" element={<ShopeePage />} />
<Route path="/shopee/:orderId" element={<ShopeeOrderDetailPage />} /> <Route path="/shopee/:orderId" element={<ShopeeOrderDetailPage />} />
<Route path="/shopee-sales-map" element={<ShopeeSalesMapPage />} />
<Route path="/shopee-analytics" element={<ShopeeAnalyticsPage />} /> <Route path="/shopee-analytics" element={<ShopeeAnalyticsPage />} />
<Route path="/shopee-overview" element={<ShopeeOverviewPage />} /> <Route path="/shopee-overview" element={<ShopeeOverviewPage />} />
<Route path="/tiktok-order" element={<TiktokOrderPage />} /> <Route path="/tiktok-order" element={<TiktokOrderPage />} />
<Route path="/tiktok-order/:orderId" element={<TiktokOrderDetailPage />} />
<Route path="/tiktok-sales-map" element={<TiktokSalesMapPage />} /> <Route path="/tiktok-sales-map" element={<TiktokSalesMapPage />} />
<Route path="/tiktok-analytics" element={<TiktokAnalyticsPage />} /> <Route path="/tiktok-analytics" element={<TiktokAnalyticsPage />} />
<Route path="/tiktok-overview" element={<TiktokOverviewPage />} /> <Route path="/tiktok-overview" element={<TiktokOverviewPage />} />
@@ -113,6 +120,12 @@ function App() {
<Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} /> <Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} />
<Route path="/lazada-overview" element={<LazadaOverviewPage />} /> <Route path="/lazada-overview" element={<LazadaOverviewPage />} />
<Route path="/global-analytics" element={<GlobalAnalyticsPage />} /> <Route path="/global-analytics" element={<GlobalAnalyticsPage />} />
{/* Business Analytics */}
<Route path="/business-analytics/sales-map" element={<OmnichannelSalesMapPage />} />
<Route path="/business-analytics/sales-map-products" element={<ProductSalesMapPage />} />
{/* Tools */}
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
{/* Backup & Restore */} {/* Backup & Restore */}
<Route path="/backup" element={<BackupDashboardPage />} /> <Route path="/backup" element={<BackupDashboardPage />} />
<Route path="/backup/database" element={<BackupDatabasePage />} /> <Route path="/backup/database" element={<BackupDatabasePage />} />
+74
View File
@@ -54,6 +54,15 @@ const SALES_MENU = [
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' }, { icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
] ]
const BUSINESS_ANALYTICS_MENU = [
{ icon: Globe2, label: 'Omnichannel Sales Map', to: '/business-analytics/sales-map' },
{ icon: Package, label: 'Product Sales Map', to: '/business-analytics/sales-map-products' },
]
const TOOL_MENU = [
{ icon: Package, label: 'Marketplace Products', to: '/tools/marketplace-products' },
]
const TIKTOK_MENU = [ const TIKTOK_MENU = [
{ icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' }, { icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' },
{ icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' }, { icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' },
@@ -70,6 +79,7 @@ const TIKTOK_LEGACY_MENU = [
const SHOPEE_MENU = [ const SHOPEE_MENU = [
{ icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' }, { icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' },
{ icon: Map, label: 'Thailand Sales Map', to: '/shopee-sales-map' },
{ icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' }, { icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' },
{ icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' }, { icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' },
{ icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' }, { icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' },
@@ -127,6 +137,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const location = useLocation() const location = useLocation()
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p)) const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p)) const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics')
const isInTools = location.pathname.startsWith('/tools')
const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis') const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis')
const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy
const isInShopee = location.pathname.startsWith('/shopee') const isInShopee = location.pathname.startsWith('/shopee')
@@ -139,6 +151,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const [productOpen, setProductOpen] = useState(isInProduct) const [productOpen, setProductOpen] = useState(isInProduct)
const [salesOpen, setSalesOpen] = useState(isInSales) const [salesOpen, setSalesOpen] = useState(isInSales)
const [businessAnalyticsOpen, setBusinessAnalyticsOpen] = useState(isInBusinessAnalytics)
const [toolsOpen, setToolsOpen] = useState(isInTools)
const [tiktokOpen, setTiktokOpen] = useState(isInTiktok) const [tiktokOpen, setTiktokOpen] = useState(isInTiktok)
const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy) const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy)
const [shopeeOpen, setShopeeOpen] = useState(isInShopee) const [shopeeOpen, setShopeeOpen] = useState(isInShopee)
@@ -260,6 +274,66 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
</div> </div>
{/* Business Analytics Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setBusinessAnalyticsOpen)}
title={isCollapsed ? 'Business Analytics' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInBusinessAnalytics ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<BarChart3 className="w-5 h-5 shrink-0 text-orange-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-orange-300">Business Analytics</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${businessAnalyticsOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && businessAnalyticsOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{BUSINESS_ANALYTICS_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0 text-orange-400" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Tools Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setToolsOpen)}
title={isCollapsed ? 'Tool' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTools ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<Wrench className="w-5 h-5 shrink-0 text-amber-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-amber-300">Tool</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${toolsOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && toolsOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TOOL_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0 text-amber-400" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* TikTok Group */} {/* TikTok Group */}
<div className="pt-1"> <div className="pt-1">
<button <button
@@ -0,0 +1,522 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo';
import { X, Search, RotateCcw, MapPin, ExternalLink } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types';
import { Link } from 'react-router-dom';
interface OmnichannelDistrictModalProps {
provinceName: string;
channel: string;
tiktokShop?: string;
shopeeShop?: string;
lazadaShop?: string;
statusFilter?: string;
dateFrom?: string;
dateTo?: string;
onClose: () => void;
}
export default function OmnichannelDistrictModal({
provinceName,
channel,
tiktokShop,
shopeeShop,
lazadaShop,
statusFilter,
dateFrom,
dateTo,
onClose,
}: OmnichannelDistrictModalProps) {
const [districtGeo, setDistrictGeo] = useState<any>(null);
const [loadingMap, setLoadingMap] = useState(true);
const [loadingData, setLoadingData] = useState(true);
const [districtData, setDistrictData] = useState<any>(null);
// Selected districts set (multi-select)
const [selectedDistricts, setSelectedDistricts] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
const [hoveredDistrict, setHoveredDistrict] = useState<any>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const width = 450;
const height = 450;
// Load district GeoJSON
useEffect(() => {
fetch('/maps/thailand-districts.json')
.then((r) => r.json())
.then((data) => setDistrictGeo(data))
.catch((err) => console.error('Failed to load districts GeoJSON:', err))
.finally(() => setLoadingMap(false));
}, []);
// Fetch backend API for district level stats & orders
useEffect(() => {
setLoadingData(true);
const params = new URLSearchParams();
params.set('province', provinceName);
if (channel && channel !== 'all') params.set('channel', channel);
if (tiktokShop && tiktokShop !== 'all') params.set('tiktokShop', tiktokShop);
if (shopeeShop && shopeeShop !== 'all') params.set('shopeeShop', shopeeShop);
if (lazadaShop && lazadaShop !== 'all') params.set('lazadaShop', lazadaShop);
if (statusFilter) params.set('statusFilter', statusFilter);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/business-analytics/sales-map/district?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((data) => setDistrictData(data))
.catch((err) => console.error('Failed to load district data:', err))
.finally(() => setLoadingData(false));
}, [provinceName, channel, tiktokShop, shopeeShop, lazadaShop, statusFilter, dateFrom, dateTo]);
// Filter district GeoJSON for this province
const provinceDistrictsGeo = useMemo(() => {
if (!districtGeo?.features) return [];
return districtGeo.features.filter((f: any) => {
const pName = f.properties.pro_th || f.properties.pro_en || '';
return pName.includes(provinceName) || provinceName.includes(pName);
});
}, [districtGeo, provinceName]);
// Calculate max district revenue for color scale
const maxDistrictRevenue = useMemo(() => {
if (!districtData?.districts?.length) return 1;
return Math.max(...districtData.districts.map((d: any) => d.revenue), 1);
}, [districtData]);
// District sales map lookup
const districtSalesMap = useMemo(() => {
const map = new Map<string, any>();
if (districtData?.districts) {
for (const d of districtData.districts) {
map.set(d.districtName, d);
}
}
return map;
}, [districtData]);
// Projection tailored to the province districts
const paths = useMemo(() => {
if (provinceDistrictsGeo.length === 0) return [];
const featureCollection: any = {
type: 'FeatureCollection',
features: provinceDistrictsGeo,
};
const projection = d3Geo.geoMercator().fitSize([width, height], featureCollection);
const generator = d3Geo.geoPath().projection(projection);
return provinceDistrictsGeo.map((feature: any) => {
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
const pathData = generator(feature);
const sales = districtSalesMap.get(dName);
const revenue = sales?.revenue || 0;
const orders = sales?.orders || 0;
const tiktokRevenue = sales?.tiktokRevenue || 0;
const shopeeRevenue = sales?.shopeeRevenue || 0;
const lazadaRevenue = sales?.lazadaRevenue || 0;
const isSelected = selectedDistricts.has(dName);
return {
feature,
pathData,
districtName: dName,
revenue,
orders,
tiktokRevenue,
shopeeRevenue,
lazadaRevenue,
isSelected,
color: getDistrictColor(revenue, maxDistrictRevenue, isSelected),
};
});
}, [provinceDistrictsGeo, districtSalesMap, maxDistrictRevenue, selectedDistricts]);
// Toggle district selection
const toggleDistrict = (dName: string) => {
setSelectedDistricts((prev) => {
const next = new Set(prev);
if (next.has(dName)) next.delete(dName);
else next.add(dName);
return next;
});
};
const clearSelectedDistricts = () => setSelectedDistricts(new Set());
// Filter orders by selected districts, channel filter, and search query
const filteredOrders = useMemo(() => {
if (!districtData?.orders) return [];
return districtData.orders.filter((o: any) => {
// District filter
if (selectedDistricts.size > 0 && !selectedDistricts.has(o.district)) {
return false;
}
// Channel filter
if (channelFilter !== 'all' && o.channel !== channelFilter) {
return false;
}
// Search query
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const inId = o.orderId.toLowerCase().includes(q);
const inUser = o.buyerUsername?.toLowerCase().includes(q);
const inDist = o.district?.toLowerCase().includes(q);
const inSku = o.items?.some((i: any) =>
i.productName?.toLowerCase().includes(q) || i.sellerSku?.toLowerCase().includes(q)
);
return inId || inUser || inDist || inSku;
}
return true;
});
}, [districtData, selectedDistricts, channelFilter, searchQuery]);
// Summary stats for selected districts
const selectionSummary = useMemo(() => {
if (!districtData?.orders) return { revenue: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
const orders = filteredOrders;
const rev = orders.reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const ttRev = orders.filter((o: any) => o.channel === 'tiktok').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const spRev = orders.filter((o: any) => o.channel === 'shopee').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const lzRev = orders.filter((o: any) => o.channel === 'lazada').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
return {
revenue: rev,
orders: orders.length,
tiktokRevenue: ttRev,
shopeeRevenue: spRev,
lazadaRevenue: lzRev,
};
}, [districtData, filteredOrders]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-6xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.03]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-rose-500/20 via-orange-500/20 to-blue-500/20 border border-orange-500/30 flex items-center justify-center">
<MapPin className="w-5 h-5 text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2>
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
Omnichannel (TikTok + Shopee + Lazada)
</span>
</div>
<p className="text-xs text-slate-400">
วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม
</p>
</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 border border-white/5"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body: 3 Columns Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0 flex-1 overflow-hidden">
{/* Col 1: District SVG Map (5 cols) */}
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-orange-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ)
</span>
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
</div>
<div
ref={containerRef}
className="flex-1 flex items-center justify-center min-h-[320px] bg-slate-900/60 rounded-xl border border-white/5 relative overflow-hidden"
onMouseLeave={() => setHoveredDistrict(null)}
>
{loadingMap || loadingData ? (
<div className="text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ...
</div>
) : paths.length === 0 ? (
<div className="text-center text-slate-500 text-xs p-6">
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้
</div>
) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
<g>
{paths.map((item: any, idx: number) => (
<path
key={idx}
d={item.pathData}
fill={item.color}
stroke="#0f172a"
strokeWidth="1.5"
strokeLinejoin="round"
className="cursor-pointer transition-all duration-150 hover:opacity-90 hover:stroke-orange-400 hover:stroke-2"
onClick={() => toggleDistrict(item.districtName)}
onMouseMove={(e) => {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setHoveredDistrict(item);
}
}}
/>
))}
</g>
</svg>
)}
{/* Hover Tooltip */}
{hoveredDistrict && tooltipPos && (
<div
className="absolute pointer-events-none z-30 px-3 py-2 rounded-xl bg-slate-900/95 border border-white/20 shadow-2xl text-xs backdrop-blur-md"
style={{ left: Math.min(tooltipPos.x + 12, width - 180), top: Math.max(tooltipPos.y - 65, 10) }}
>
<p className="font-bold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
อ.{hoveredDistrict.districtName}
</p>
<p className="text-emerald-400 font-mono font-bold mt-1">
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.orders} ออเดอร์)</span>
</p>
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
<span className="text-rose-400">TT: {fmtBaht(hoveredDistrict.tiktokRevenue)}</span>
<span>•</span>
<span className="text-orange-400">SP: {fmtBaht(hoveredDistrict.shopeeRevenue)}</span>
<span>•</span>
<span className="text-blue-400">LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)}</span>
</div>
)}
</div>
)}
</div>
{/* Map Legend */}
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-400 px-1">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
<span>฿0</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
<span>฿{fmtNum(maxDistrictRevenue)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-orange-500" />
<span>เลือกอยู่</span>
</div>
</div>
</div>
{/* Col 2 & 3: Selection Stats & Order Table (7 cols) */}
<div className="lg:col-span-7 flex flex-col h-full overflow-hidden bg-slate-900/30">
{/* Filter & Summary Header */}
<div className="p-4 border-b border-white/10 space-y-3 bg-white/[0.01]">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setChannelFilter('all')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'all'
? 'bg-white/15 text-white border-white/30'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ทุกช่องทาง
</button>
<button
onClick={() => setChannelFilter('tiktok')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'tiktok'
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
}`}
>
🔴 TikTok
</button>
<button
onClick={() => setChannelFilter('shopee')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'shopee'
? 'bg-orange-500/20 text-orange-300 border-orange-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-orange-400'
}`}
>
🟠 Shopee
</button>
<button
onClick={() => setChannelFilter('lazada')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'lazada'
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-blue-400'
}`}
>
🔵 Lazada
</button>
</div>
{selectedDistricts.size > 0 && (
<button
onClick={clearSelectedDistricts}
className="flex items-center gap-1 text-xs text-orange-400 hover:text-orange-300 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size})
</button>
)}
</div>
{/* KPI Cards for Selection */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p>
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">คำสั่งซื้อ</p>
<p className="text-base font-bold text-white font-mono">{selectionSummary.orders} ออเดอร์</p>
</div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
<p className="text-sm font-bold text-rose-400 font-mono">{fmtBaht(selectionSummary.tiktokRevenue)}</p>
</div>
<div className="bg-orange-500/10 border border-orange-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-orange-300 uppercase tracking-wider">Shopee</p>
<p className="text-sm font-bold text-orange-400 font-mono">{fmtBaht(selectionSummary.shopeeRevenue)}</p>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-blue-300 uppercase tracking-wider">Lazada</p>
<p className="text-sm font-bold text-blue-400 font-mono">{fmtBaht(selectionSummary.lazadaRevenue)}</p>
</div>
</div>
{/* District Tags */}
{selectedDistricts.size > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-slate-400">กรองเฉพาะ:</span>
{Array.from(selectedDistricts).map((dName) => (
<span
key={dName}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-orange-500/20 text-orange-300 border border-orange-500/30"
>
อ.{dName}
<button onClick={() => toggleDistrict(dName)} className="hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Search Bar */}
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือชื่อสินค้า..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
/>
</div>
</div>
{/* Orders Table */}
<div className="flex-1 overflow-y-auto p-4">
{loadingData ? (
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div>
) : filteredOrders.length === 0 ? (
<div className="py-12 text-center text-slate-500 text-xs">
ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก
</div>
) : (
<div className="space-y-2.5">
{filteredOrders.map((order: any) => (
<div
key={`${order.channel}-${order.orderId}`}
className="p-3 bg-white/5 hover:bg-white/[0.08] border border-white/10 rounded-xl transition-colors space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
{order.channel === 'tiktok' ? (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
TikTok
</span>
) : order.channel === 'shopee' ? (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
Shopee
</span>
) : (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-blue-500/20 text-blue-400 border border-blue-500/30">
Lazada
</span>
)}
<span className="font-mono text-xs font-bold text-white">{order.orderId}</span>
<span className="px-1.5 py-0.2 rounded text-[9px] bg-white/10 text-slate-300 font-mono">
{order.shop}
</span>
</div>
<p className="text-[11px] text-slate-400">
ผู้ซื้อ: <span className="text-slate-200">{order.buyerUsername || '-'}</span> • อ.{order.district} • {order.createdTime?.slice(0, 16)}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-emerald-400 font-mono">{fmtBaht(order.orderAmount)}</p>
<p className="text-[10px] text-slate-500 font-mono">ค่าส่ง {fmtBaht(order.shippingFee)}</p>
</div>
</div>
{/* Items Preview */}
{order.items?.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-1">
{order.items.map((it: any, iIdx: number) => (
<div key={iIdx} className="flex items-center justify-between text-xs text-slate-300">
<span className="truncate max-w-[280px] text-slate-400" title={it.productName}>
• {it.productName} {it.variation ? `(${it.variation})` : ''}
</span>
<span className="font-mono text-slate-400 shrink-0 text-[11px]">
x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)})
</span>
</div>
))}
</div>
)}
<div className="flex justify-end pt-1">
<Link
to={
order.channel === 'tiktok'
? `/tiktok-order/${order.orderId}`
: order.channel === 'shopee'
? `/shopee/${order.orderId}`
: `/lazada-order`
}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-orange-300 transition-colors"
>
ดูหน้าออเดอร์เต็ม <ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,523 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo';
import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types';
import { Link } from 'react-router-dom';
interface ProductDistrictModalProps {
provinceName: string;
productId?: number | null;
variantId?: number | null;
productName?: string;
variantName?: string;
channel: string;
statusFilter?: string;
dateFrom?: string;
dateTo?: string;
onClose: () => void;
}
export default function ProductDistrictModal({
provinceName,
productId,
variantId,
productName,
variantName,
channel,
statusFilter,
dateFrom,
dateTo,
onClose,
}: ProductDistrictModalProps) {
const [districtGeo, setDistrictGeo] = useState<any>(null);
const [loadingMap, setLoadingMap] = useState(true);
const [loadingData, setLoadingData] = useState(true);
const [districtData, setDistrictData] = useState<any>(null);
const [selectedDistricts, setSelectedDistricts] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState('');
const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
const [hoveredDistrict, setHoveredDistrict] = useState<any>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const width = 450;
const height = 450;
// Load district GeoJSON
useEffect(() => {
fetch('/maps/thailand-districts.json')
.then((r) => r.json())
.then((data) => setDistrictGeo(data))
.catch((err) => console.error('Failed to load districts GeoJSON:', err))
.finally(() => setLoadingMap(false));
}, []);
// Fetch backend API for product district level stats & orders
useEffect(() => {
setLoadingData(true);
const params = new URLSearchParams();
params.set('province', provinceName);
if (variantId) params.set('variantId', variantId.toString());
else if (productId) params.set('productId', productId.toString());
if (channel && channel !== 'all') params.set('channel', channel);
if (statusFilter) params.set('statusFilter', statusFilter);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/business-analytics/sales-map-products/district?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((data) => setDistrictData(data))
.catch((err) => console.error('Failed to load product district data:', err))
.finally(() => setLoadingData(false));
}, [provinceName, productId, variantId, channel, statusFilter, dateFrom, dateTo]);
// Filter district GeoJSON for this province
const provinceDistrictsGeo = useMemo(() => {
if (!districtGeo?.features) return [];
return districtGeo.features.filter((f: any) => {
const pName = f.properties.pro_th || f.properties.pro_en || '';
return pName.includes(provinceName) || provinceName.includes(pName);
});
}, [districtGeo, provinceName]);
// Calculate max district revenue for color scale
const maxDistrictRevenue = useMemo(() => {
if (!districtData?.districts?.length) return 1;
return Math.max(...districtData.districts.map((d: any) => d.revenue), 1);
}, [districtData]);
// District sales map lookup
const districtSalesMap = useMemo(() => {
const map = new Map<string, any>();
if (districtData?.districts) {
for (const d of districtData.districts) {
map.set(d.districtName, d);
}
}
return map;
}, [districtData]);
// Projection tailored to the province districts
const paths = useMemo(() => {
if (provinceDistrictsGeo.length === 0) return [];
const featureCollection: any = {
type: 'FeatureCollection',
features: provinceDistrictsGeo,
};
const projection = d3Geo.geoMercator().fitSize([width, height], featureCollection);
const generator = d3Geo.geoPath().projection(projection);
return provinceDistrictsGeo.map((feature: any) => {
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
const pathData = generator(feature);
const sales = districtSalesMap.get(dName);
const revenue = sales?.revenue || 0;
const unitsSold = sales?.unitsSold || 0;
const orders = sales?.orders || 0;
const tiktokRevenue = sales?.tiktokRevenue || 0;
const shopeeRevenue = sales?.shopeeRevenue || 0;
const lazadaRevenue = sales?.lazadaRevenue || 0;
const isSelected = selectedDistricts.has(dName);
return {
feature,
pathData,
districtName: dName,
revenue,
unitsSold,
orders,
tiktokRevenue,
shopeeRevenue,
lazadaRevenue,
isSelected,
color: getDistrictColor(revenue, maxDistrictRevenue, isSelected),
};
});
}, [provinceDistrictsGeo, districtSalesMap, maxDistrictRevenue, selectedDistricts]);
const toggleDistrict = (dName: string) => {
setSelectedDistricts((prev) => {
const next = new Set(prev);
if (next.has(dName)) next.delete(dName);
else next.add(dName);
return next;
});
};
const clearSelectedDistricts = () => setSelectedDistricts(new Set());
// Filter orders by selected districts, channel filter, and search query
const filteredOrders = useMemo(() => {
if (!districtData?.orders) return [];
return districtData.orders.filter((o: any) => {
if (selectedDistricts.size > 0 && !selectedDistricts.has(o.district)) {
return false;
}
if (channelFilter !== 'all' && o.channel !== channelFilter) {
return false;
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
const inId = o.orderId.toLowerCase().includes(q);
const inUser = o.buyerUsername?.toLowerCase().includes(q);
const inDist = o.district?.toLowerCase().includes(q);
const inSku = o.items?.some((i: any) =>
i.productName?.toLowerCase().includes(q) || i.sellerSku?.toLowerCase().includes(q)
);
return inId || inUser || inDist || inSku;
}
return true;
});
}, [districtData, selectedDistricts, channelFilter, searchQuery]);
// Summary stats for selection
const selectionSummary = useMemo(() => {
if (!districtData?.orders) return { revenue: 0, units: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
const orders = filteredOrders;
const rev = orders.reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const units = orders.reduce((sum: number, o: any) => sum + (o.items?.reduce((is: number, it: any) => is + it.quantity, 0) || 0), 0);
const ttRev = orders.filter((o: any) => o.channel === 'tiktok').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const spRev = orders.filter((o: any) => o.channel === 'shopee').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
const lzRev = orders.filter((o: any) => o.channel === 'lazada').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
return {
revenue: rev,
units,
orders: orders.length,
tiktokRevenue: ttRev,
shopeeRevenue: spRev,
lazadaRevenue: lzRev,
};
}, [districtData, filteredOrders]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-6xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.03]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 via-purple-500/20 to-pink-500/20 border border-indigo-500/30 flex items-center justify-center">
<MapPin className="w-5 h-5 text-indigo-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Package className="w-3 h-3" />
{productName} {variantName ? `(${variantName})` : ''}
</span>
</div>
<p className="text-xs text-slate-400">
สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม
</p>
</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 border border-white/5"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body: 3 Columns Layout */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0 flex-1 overflow-hidden">
{/* Col 1: District SVG Map (5 cols) */}
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
<div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-indigo-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ)
</span>
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
</div>
<div
ref={containerRef}
className="flex-1 flex items-center justify-center min-h-[320px] bg-slate-900/60 rounded-xl border border-white/5 relative overflow-hidden"
onMouseLeave={() => setHoveredDistrict(null)}
>
{loadingMap || loadingData ? (
<div className="text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ...
</div>
) : paths.length === 0 ? (
<div className="text-center text-slate-500 text-xs p-6">
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้
</div>
) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
<g>
{paths.map((item: any, idx: number) => (
<path
key={idx}
d={item.pathData}
fill={item.color}
stroke="#0f172a"
strokeWidth="1.5"
strokeLinejoin="round"
className="cursor-pointer transition-all duration-150 hover:opacity-90 hover:stroke-indigo-400 hover:stroke-2"
onClick={() => toggleDistrict(item.districtName)}
onMouseMove={(e) => {
const rect = containerRef.current?.getBoundingClientRect();
if (rect) {
setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
setHoveredDistrict(item);
}
}}
/>
))}
</g>
</svg>
)}
{/* Hover Tooltip */}
{hoveredDistrict && tooltipPos && (
<div
className="absolute pointer-events-none z-30 px-3 py-2 rounded-xl bg-slate-900/95 border border-white/20 shadow-2xl text-xs backdrop-blur-md"
style={{ left: Math.min(tooltipPos.x + 12, width - 180), top: Math.max(tooltipPos.y - 65, 10) }}
>
<p className="font-bold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
อ.{hoveredDistrict.districtName}
</p>
<p className="text-emerald-400 font-mono font-bold mt-1">
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.unitsSold} ชิ้น)</span>
</p>
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
<span className="text-rose-400">TT: {fmtBaht(hoveredDistrict.tiktokRevenue)}</span>
<span>•</span>
<span className="text-orange-400">SP: {fmtBaht(hoveredDistrict.shopeeRevenue)}</span>
<span>•</span>
<span className="text-blue-400">LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)}</span>
</div>
)}
</div>
)}
</div>
{/* Map Legend */}
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-400 px-1">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
<span>฿0</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
<span>฿{fmtNum(maxDistrictRevenue)}</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-indigo-500" />
<span>เลือกอยู่</span>
</div>
</div>
</div>
{/* Col 2 & 3: Selection Stats & Order Table (7 cols) */}
<div className="lg:col-span-7 flex flex-col h-full overflow-hidden bg-slate-900/30">
{/* Filter & Summary Header */}
<div className="p-4 border-b border-white/10 space-y-3 bg-white/[0.01]">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-1.5 flex-wrap">
<button
onClick={() => setChannelFilter('all')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'all'
? 'bg-white/15 text-white border-white/30'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ทุกช่องทาง
</button>
<button
onClick={() => setChannelFilter('tiktok')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'tiktok'
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
}`}
>
🔴 TikTok
</button>
<button
onClick={() => setChannelFilter('shopee')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'shopee'
? 'bg-orange-500/20 text-orange-300 border-orange-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-orange-400'
}`}
>
🟠 Shopee
</button>
<button
onClick={() => setChannelFilter('lazada')}
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
channelFilter === 'lazada'
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-blue-400'
}`}
>
🔵 Lazada
</button>
</div>
{selectedDistricts.size > 0 && (
<button
onClick={clearSelectedDistricts}
className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size})
</button>
)}
</div>
{/* KPI Cards for Selection */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p>
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">จำนวนชิ้นที่ขาย</p>
<p className="text-base font-bold text-amber-400 font-mono">{selectionSummary.units} ชิ้น</p>
</div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
<p className="text-sm font-bold text-rose-400 font-mono">{fmtBaht(selectionSummary.tiktokRevenue)}</p>
</div>
<div className="bg-orange-500/10 border border-orange-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-orange-300 uppercase tracking-wider">Shopee</p>
<p className="text-sm font-bold text-orange-400 font-mono">{fmtBaht(selectionSummary.shopeeRevenue)}</p>
</div>
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-blue-300 uppercase tracking-wider">Lazada</p>
<p className="text-sm font-bold text-blue-400 font-mono">{fmtBaht(selectionSummary.lazadaRevenue)}</p>
</div>
</div>
{/* District Tags */}
{selectedDistricts.size > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-slate-400">กรองเฉพาะ:</span>
{Array.from(selectedDistricts).map((dName) => (
<span
key={dName}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-indigo-500/20 text-indigo-300 border border-indigo-500/30"
>
อ.{dName}
<button onClick={() => toggleDistrict(dName)} className="hover:text-white">
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
)}
{/* Search Bar */}
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือ SKU..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
/>
</div>
</div>
{/* Orders Table */}
<div className="flex-1 overflow-y-auto p-4">
{loadingData ? (
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div>
) : filteredOrders.length === 0 ? (
<div className="py-12 text-center text-slate-500 text-xs">
ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก
</div>
) : (
<div className="space-y-2.5">
{filteredOrders.map((order: any) => (
<div
key={`${order.channel}-${order.orderId}`}
className="p-3 bg-white/5 hover:bg-white/[0.08] border border-white/10 rounded-xl transition-colors space-y-2"
>
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
{order.channel === 'tiktok' ? (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
TikTok
</span>
) : order.channel === 'shopee' ? (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
Shopee
</span>
) : (
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-blue-500/20 text-blue-400 border border-blue-500/30">
Lazada
</span>
)}
<span className="font-mono text-xs font-bold text-white">{order.orderId}</span>
<span className="px-1.5 py-0.2 rounded text-[9px] bg-white/10 text-slate-300 font-mono">
{order.shop}
</span>
</div>
<p className="text-[11px] text-slate-400">
ผู้ซื้อ: <span className="text-slate-200">{order.buyerUsername || '-'}</span> • อ.{order.district} • {order.createdTime?.slice(0, 16)}
</p>
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-emerald-400 font-mono">{fmtBaht(order.orderAmount)}</p>
</div>
</div>
{/* Items Preview */}
{order.items?.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-1">
{order.items.map((it: any, iIdx: number) => (
<div key={iIdx} className="flex items-center justify-between text-xs text-slate-300">
<span className="truncate max-w-[280px] text-slate-400" title={it.productName}>
• {it.productName} {it.variation ? `(${it.variation})` : ''}
</span>
<span className="font-mono text-slate-400 shrink-0 text-[11px]">
x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)})
</span>
</div>
))}
</div>
)}
<div className="flex justify-end pt-1">
<Link
to={
order.channel === 'tiktok'
? `/tiktok-order/${order.orderId}`
: order.channel === 'shopee'
? `/shopee/${order.orderId}`
: `/lazada-order`
}
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-indigo-300 transition-colors"
>
ดูหน้าออเดอร์เต็ม <ExternalLink className="w-3 h-3" />
</Link>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -9,6 +9,8 @@ interface DistrictMapModalProps {
shop: string; shop: string;
dateFrom?: string; dateFrom?: string;
dateTo?: string; dateTo?: string;
platformName?: string;
apiEndpoint?: string;
onClose: () => void; onClose: () => void;
} }
@@ -17,6 +19,8 @@ export default function DistrictMapModal({
shop, shop,
dateFrom, dateFrom,
dateTo, dateTo,
platformName = 'TikTok',
apiEndpoint = '/tiktok-order/sales-map/district',
onClose, onClose,
}: DistrictMapModalProps) { }: DistrictMapModalProps) {
const [districtGeo, setDistrictGeo] = useState<any>(null); const [districtGeo, setDistrictGeo] = useState<any>(null);
@@ -52,12 +56,16 @@ export default function DistrictMapModal({
if (dateFrom) params.set('dateFrom', dateFrom); if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo); if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/tiktok-order/sales-map/district?${params.toString()}`) const fullUrl = apiEndpoint.startsWith('http')
? `${apiEndpoint}?${params.toString()}`
: `${API_BASE}${apiEndpoint.startsWith('/') ? '' : '/'}${apiEndpoint}?${params.toString()}`;
fetch(fullUrl)
.then((r) => r.json()) .then((r) => r.json())
.then((data) => setDistrictData(data)) .then((data) => setDistrictData(data))
.catch((err) => console.error('Failed to load district data:', err)) .catch((err) => console.error('Failed to load district data:', err))
.finally(() => setLoadingData(false)); .finally(() => setLoadingData(false));
}, [provinceName, shop, dateFrom, dateTo]); }, [provinceName, shop, dateFrom, dateTo, apiEndpoint]);
// Filter district features matching the current province // Filter district features matching the current province
const provinceFeatures = useMemo(() => { const provinceFeatures = useMemo(() => {
@@ -186,7 +194,7 @@ export default function DistrictMapModal({
</span> </span>
</div> </div>
<h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5"> <h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5">
แผนที่อำเภอ & คำสั่งซื้อ TikTok • <span className="text-emerald-400">{provinceName}</span> แผนที่อำเภอ & คำสั่งซื้อ {platformName} • <span className="text-emerald-400">{provinceName}</span>
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก) คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก)
@@ -378,7 +386,7 @@ export default function DistrictMapModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-emerald-400" /> <ShoppingBag className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-white"> <span className="text-sm font-bold text-white">
คำสั่งซื้อ TikTok ในพื้นที่ ({filteredOrders.length}) คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length})
</span> </span>
</div> </div>
{selectedDistricts.size > 0 && ( {selectedDistricts.size > 0 && (
+361 -69
View File
@@ -1,8 +1,31 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { Save, Trash2, Plus, Loader2, CheckCircle2, AlertCircle, Package } from 'lucide-react' import {
Save,
Trash2,
Plus,
Loader2,
CheckCircle2,
AlertCircle,
Package,
ArrowLeft,
Coins,
Layers,
Calculator
} from 'lucide-react'
import { API_BASE, type Product } from '@homaroma/types' import { API_BASE, type Product } from '@homaroma/types'
function fmtMoney(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00'
return (
'฿' +
new Intl.NumberFormat('th-TH', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(val)
)
}
export default function ProductBomPage() { export default function ProductBomPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
@@ -31,7 +54,7 @@ export default function ProductBomPage() {
const prodData: Product = await prodRes.json() const prodData: Product = await prodRes.json()
setProduct(prodData) setProduct(prodData)
setVariants(JSON.parse(JSON.stringify(prodData.variants))) // Deep copy setVariants(JSON.parse(JSON.stringify(prodData.variants || []))) // Deep copy
} }
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
@@ -43,20 +66,36 @@ export default function ProductBomPage() {
fetchData() fetchData()
}, [id]) }, [id])
const getMaterial = (rawMaterialId: number) => {
return rawMaterials.find((rm) => rm.id === Number(rawMaterialId))
}
const getLineCost = (bomItem: any): number => {
const rm = getMaterial(bomItem.rawMaterialId)
if (!rm) return 0
const qty = Number(bomItem.quantity) || 0
return (rm.pricePerUnit || 0) * qty
}
const getVariantBomCost = (variant: any): number => {
if (!variant.bomItems || variant.bomItems.length === 0) return 0
return variant.bomItems.reduce((acc: number, item: any) => acc + getLineCost(item), 0)
}
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(false) setSuccess(false)
try { try {
if (!product) throw new Error("Product not loaded") if (!product) throw new Error('Product not loaded')
const payload = { const payload = {
categoryId: product.categoryId, categoryId: product.categoryId,
name: product.name, name: product.name,
options: product.options, options: product.options,
images: product.images, // Need to pass existing images to avoid deleting them in some backends images: product.images,
variants: variants.map(v => ({ variants: variants.map((v) => ({
id: v.id, id: v.id,
option1: v.option1, option1: v.option1,
option2: v.option2, option2: v.option2,
@@ -65,11 +104,12 @@ export default function ProductBomPage() {
stock: v.stock, stock: v.stock,
sku: v.sku, sku: v.sku,
barcode: v.barcode, barcode: v.barcode,
bomItems: v.bomItems?.map((b: any) => ({ bomItems:
rawMaterialId: b.rawMaterialId, v.bomItems?.map((b: any) => ({
quantity: Number(b.quantity) || 0 rawMaterialId: Number(b.rawMaterialId) || 0,
})) || [] quantity: Number(b.quantity) || 0,
})) })) || [],
})),
} }
const response = await fetch(`${API_BASE}/products/${id}`, { const response = await fetch(`${API_BASE}/products/${id}`, {
@@ -81,7 +121,7 @@ export default function ProductBomPage() {
if (!response.ok) throw new Error('Failed to save BOM') if (!response.ok) throw new Error('Failed to save BOM')
setSuccess(true) setSuccess(true)
setTimeout(() => setSuccess(false), 3000) setTimeout(() => setSuccess(false), 3500)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -91,84 +131,244 @@ export default function ProductBomPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-[50vh]"> <div className="flex flex-col items-center justify-center min-h-[50vh] space-y-3">
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" /> <Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
<p className="text-xs text-slate-400">กำลังโหลดโครงสร้างต้นทุน (BOM)...</p>
</div> </div>
) )
} }
if (!product) { if (!product) {
return <div className="text-center py-12 text-slate-400">Product not found.</div> return <div className="text-center py-12 text-slate-400">ไม่พบข้อมูลสินค้าที่ต้องการ</div>
} }
const productName = typeof product.name === 'string' ? product.name : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product' const productName =
typeof product.name === 'string'
? product.name
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
// Summary Metrics Across All Variants
const totalVariantsCount = variants.length
const configuredVariantsCount = variants.filter(
(v) => v.bomItems && v.bomItems.some((b: any) => b.rawMaterialId > 0 && Number(b.quantity) > 0)
).length
const bomCosts = variants.map((v) => getVariantBomCost(v)).filter((c) => c > 0)
const minBomCost = bomCosts.length > 0 ? Math.min(...bomCosts) : 0
const maxBomCost = bomCosts.length > 0 ? Math.max(...bomCosts) : 0
return ( return (
<div className="w-full space-y-8 animate-fade-in max-w-5xl mx-auto"> <div className="w-full space-y-8 animate-fade-in max-w-6xl mx-auto pb-16">
{/* Header & Actions */} {/* Header & Actions */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1"> <div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/products" className="hover:text-cyan-glow transition-colors">Products</Link> <Link to="/products" className="hover:text-cyan-glow transition-colors flex items-center gap-1">
<ArrowLeft className="w-3.5 h-3.5 inline" /> Products
</Link>
<span>/</span> <span>/</span>
<span className="text-white">BOM</span> <span className="text-white">Bill of Materials (BOM)</span>
</div> </div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight">
BOM <span className="text-cyan-glow">{productName}</span> โครงสร้างต้นทุน BOM: <span className="text-cyan-glow">{productName}</span>
</h1> </h1>
<p className="text-xs text-slate-400">
คำนวณและแจกแจงต้นทุนวัตถุดิบ (Raw Materials) ต่อหน่วย พร้อมวิเคราะห์กำไรขั้นต้น (Gross Profit & Margin)
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => navigate('/products')} onClick={() => navigate('/products')}
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all" className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
> >
Cancel ยกเลิก
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="btn-primary py-2.5 px-8 flex items-center gap-2" className="btn-primary py-2.5 px-7 flex items-center gap-2 shadow-lg shadow-cyan-500/20"
> >
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? 'Saving...' : 'Save BOM'} {saving ? 'กำลังบันทึก...' : 'บันทึก BOM'}
</button> </button>
</div> </div>
</div> </div>
{/* Alerts */}
{success && ( {success && (
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in"> <div className="flex items-center gap-3 p-4 bg-emerald-500/15 border border-emerald-500/30 rounded-2xl text-emerald-400 text-sm animate-fade-in shadow-lg">
<CheckCircle2 className="w-5 h-5 shrink-0" /> <CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
<p>BOM updated successfully!</p> <div>
<p className="font-bold">บันทึกโครงสร้างต้นทุน BOM สำเร็จ!</p>
<p className="text-xs text-emerald-300/80">
ระบบจะนำต้นทุน BOM ไปคำนวณกำไรในหน้ารายงานการขายและสินค้า Marketplace โดยอัตโนมัติ
</p>
</div>
</div> </div>
)} )}
{error && ( {error && (
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in"> <div className="flex items-center gap-3 p-4 bg-rose-500/15 border border-rose-500/30 rounded-2xl text-rose-400 text-sm animate-fade-in shadow-lg">
<AlertCircle className="w-5 h-5 shrink-0" /> <AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
<p>{error}</p> <p className="font-medium">{error}</p>
</div> </div>
)} )}
<div className="space-y-8"> {/* ── Summary Overview KPI Cards ────────────────────────────────────────── */}
{variants.length === 0 ? ( <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="admin-card p-8 text-center text-slate-400"> {/* Total Variants */}
No variants found for this product. <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
</div> <div className="flex items-center justify-between">
) : variants.map((v, vIdx) => { <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนตัวเลือกสินค้า</p>
const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default' <Layers className="w-4 h-4 text-cyan-400" />
return (
<section key={vIdx} className="admin-card space-y-4 relative">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center">
<Package className="w-5 h-5 text-cyan-glow" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white font-mono">
{totalVariantsCount} <span className="text-xs font-normal text-slate-400">ตัวเลือก</span>
</p>
<p className="text-xs text-slate-400 mt-1">
ผูก BOM แล้ว: <span className="text-emerald-400 font-bold">{configuredVariantsCount}</span> / {totalVariantsCount}
</p>
</div>
</div>
{/* BOM Cost Range */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ช่วงต้นทุน BOM</p>
<Coins className="w-4 h-4 text-amber-400" />
</div>
<div>
<p className="text-xl font-bold text-amber-300 font-mono">
{minBomCost === 0 && maxBomCost === 0
? 'ยังไม่ระบุ'
: minBomCost === maxBomCost
? fmtMoney(minBomCost)
: `${fmtMoney(minBomCost)} - ${fmtMoney(maxBomCost)}`}
</p>
<p className="text-xs text-slate-400 mt-1">ต้นทุนวัตถุดิบต่อ 1 ชิ้น</p>
</div>
</div>
{/* Master Raw Materials Catalog */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">คลังวัตถุดิบในระบบ</p>
<Package className="w-4 h-4 text-indigo-400" />
</div>
<div>
<p className="text-2xl font-bold text-indigo-300 font-mono">
{rawMaterials.length} <span className="text-xs font-normal text-slate-400">รายการ</span>
</p>
<Link
to="/raw-materials"
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors inline-flex items-center gap-1 mt-1"
>
<span>จัดการราคาวัตถุดิบ</span> &rarr;
</Link>
</div>
</div>
{/* Auto Calculation Status */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ระบบคำนวณอัตโนมัติ</p>
<Calculator className="w-4 h-4 text-emerald-400" />
</div>
<div>
<p className="text-sm font-bold text-emerald-400 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live Real-time Sync
</p>
<p className="text-[11px] text-slate-400 mt-1">ปรับสูตร BOM ปุ๊บ ต้นทุนและกำไรจะอัปเดตทันที</p>
</div>
</div>
</div>
{/* ── Variants BOM List ─────────────────────────────────────────────────── */}
<div className="space-y-6">
{variants.length === 0 ? (
<div className="admin-card p-12 text-center text-slate-400 space-y-2">
<Package className="w-10 h-10 mx-auto text-slate-600 mb-2" />
<p className="text-base font-bold text-white">ไม่พบตัวเลือกสินค้า (Variants)</p>
<p className="text-xs text-slate-500">กรุณาเพิ่มตัวเลือกสินค้าในหน้ารายละเอียดสินค้าก่อน</p>
</div>
) : (
variants.map((v, vIdx) => {
const variantName =
[v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default'
const variantBomCost = getVariantBomCost(v)
const retailPrice = Number(v.price) || 0
const profit = retailPrice - variantBomCost
const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0
const hasBom = v.bomItems && v.bomItems.length > 0
return (
<section
key={v.id || vIdx}
className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm space-y-5 shadow-xl transition-all"
>
{/* Variant Header & Price Summary */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between pb-4 border-b border-white/10 gap-4">
<div className="flex items-center gap-3.5">
<div className="w-12 h-12 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center shrink-0">
<Package className="w-6 h-6 text-cyan-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-lg text-white">{variantName}</h3> <h3 className="font-bold text-lg text-white">{variantName}</h3>
<p className="text-xs text-slate-500 uppercase tracking-widest">SKU: {v.sku || 'N/A'}</p> {v.sku && (
<span className="px-2 py-0.5 rounded-md bg-black/40 text-[11px] font-mono text-cyan-300 border border-white/5">
SKU: {v.sku}
</span>
)}
{v.barcode && (
<span className="px-2 py-0.5 rounded-md bg-black/40 text-[11px] font-mono text-slate-400 border border-white/5">
Barcode: {v.barcode}
</span>
)}
</div>
<p className="text-xs text-slate-400 mt-0.5">
สต็อกคงเหลือ: <span className="text-white font-mono">{v.stock ?? 0}</span> ชิ้น
</p>
</div> </div>
</div> </div>
{/* Financial KPI Badges for this Variant */}
<div className="flex items-center gap-3 flex-wrap">
{/* Retail Price */}
<div className="px-3.5 py-2 rounded-xl bg-black/40 border border-white/10 text-right min-w-[120px]">
<span className="text-[10px] text-slate-400 uppercase tracking-wider block">ราคาขาย</span>
<span className="text-sm font-bold text-white font-mono">{fmtMoney(retailPrice)}</span>
</div>
{/* Total BOM Cost */}
<div className="px-3.5 py-2 rounded-xl bg-amber-500/10 border border-amber-500/30 text-right min-w-[130px]">
<span className="text-[10px] text-amber-300 uppercase tracking-wider block font-semibold">
ต้นทุน BOM รวม
</span>
<span className="text-sm font-bold text-amber-400 font-mono">{fmtMoney(variantBomCost)}</span>
</div>
{/* Estimated Profit */}
<div
className={`px-3.5 py-2 rounded-xl border text-right min-w-[140px] ${
profit >= 0
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
: 'bg-rose-500/10 border-rose-500/30 text-rose-400'
}`}
>
<span className="text-[10px] uppercase tracking-wider block font-semibold opacity-80">
กำไรประเมิน / ชิ้น
</span>
<div className="flex items-center justify-end gap-1 font-mono">
<span className="text-sm font-bold">{fmtMoney(profit)}</span>
{retailPrice > 0 && (
<span className="text-[10px] font-bold opacity-90">({marginPercent.toFixed(1)}%)</span>
)}
</div>
</div>
{/* Add Material Button */}
<button <button
onClick={() => { onClick={() => {
const newVariants = [...variants] const newVariants = [...variants]
@@ -176,22 +376,46 @@ export default function ProductBomPage() {
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 }) newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
setVariants(newVariants) setVariants(newVariants)
}} }}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-white rounded-lg text-sm font-bold transition-all flex items-center gap-2" className="px-3.5 py-2.5 bg-white/10 hover:bg-cyan-500/20 hover:text-cyan-300 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 border border-white/10"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Material <span>เพิ่มวัตถุดิบ</span>
</button> </button>
</div> </div>
<div className="space-y-3 pt-2">
{(!v.bomItems || v.bomItems.length === 0) ? (
<div className="text-center py-6 text-slate-500 text-sm italic bg-white/[0.02] rounded-xl border border-white/5 border-dashed">
No materials specified for this variant.
</div> </div>
) : v.bomItems.map((bom: any, bIdx: number) => (
<div key={bIdx} className="flex items-center gap-4 bg-night-950/50 p-4 rounded-xl border border-white/5"> {/* BOM Items Table / Cards */}
<div className="flex-1"> <div className="space-y-2.5 pt-1">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Raw Material</label> {!hasBom ? (
<div className="text-center py-8 text-slate-400 text-xs bg-white/[0.02] rounded-2xl border border-dashed border-white/10 space-y-2">
<p>ยังไม่มีการกำหนดส่วนผสม / วัตถุดิบสำหรับตัวเลือกนี้</p>
<button
onClick={() => {
const newVariants = [...variants]
if (!newVariants[vIdx].bomItems) newVariants[vIdx].bomItems = []
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
setVariants(newVariants)
}}
className="px-4 py-1.5 rounded-xl bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-xs font-semibold hover:bg-cyan-500/30 transition-all inline-flex items-center gap-1"
>
<Plus className="w-3.5 h-3.5" /> + เพิ่มวัตถุดิบแรก
</button>
</div>
) : (
v.bomItems.map((bom: any, bIdx: number) => {
const selectedMat = getMaterial(bom.rawMaterialId)
const lineCost = getLineCost(bom)
return (
<div
key={bIdx}
className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-black/30 p-3.5 rounded-xl border border-white/10 hover:border-white/20 transition-all"
>
{/* Col 1: Material Selector */}
<div className="flex-1 min-w-[260px]">
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
วัตถุดิบ (Raw Material)
</label>
<select <select
value={bom.rawMaterialId || ''} value={bom.rawMaterialId || ''}
onChange={(e) => { onChange={(e) => {
@@ -199,48 +423,116 @@ export default function ProductBomPage() {
newVariants[vIdx].bomItems[bIdx].rawMaterialId = Number(e.target.value) newVariants[vIdx].bomItems[bIdx].rawMaterialId = Number(e.target.value)
setVariants(newVariants) setVariants(newVariants)
}} }}
className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" className="w-full px-3.5 py-2 bg-slate-900 border border-white/10 focus:border-cyan-400/40 rounded-xl outline-none text-xs text-white transition-all"
> >
<option value="" disabled>Select Raw Material</option> <option value="" disabled>
{rawMaterials.map(rm => ( -- เลือกวัตถุดิบจากระบบ --
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option> </option>
{rawMaterials.map((rm) => (
<option key={rm.id} value={rm.id}>
{rm.name} ({rm.unit}) — {fmtMoney(rm.pricePerUnit)}/{rm.unit}
</option>
))} ))}
</select> </select>
</div> </div>
<div className="w-48">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Quantity</label> {/* Col 2: Price Per Unit Display */}
<div className="w-full md:w-36">
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
ราคาต่อหน่วย
</label>
<div className="px-3.5 py-2 bg-white/5 border border-white/5 rounded-xl text-xs font-mono text-slate-300">
{selectedMat ? (
<span>
{fmtMoney(selectedMat.pricePerUnit)}{' '}
<span className="text-[10px] text-slate-500">/{selectedMat.unit}</span>
</span>
) : (
<span className="text-slate-600">-</span>
)}
</div>
</div>
{/* Col 3: Quantity Input */}
<div className="w-full md:w-36">
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
ปริมาณที่ใช้ {selectedMat ? `(${selectedMat.unit})` : ''}
</label>
<div className="relative">
<input <input
type="number" type="number"
value={bom.quantity || ''} value={bom.quantity === '' ? '' : bom.quantity}
onChange={(e) => { onChange={(e) => {
const newVariants = [...variants] const newVariants = [...variants]
newVariants[vIdx].bomItems[bIdx].quantity = Number(e.target.value) newVariants[vIdx].bomItems[bIdx].quantity =
e.target.value === '' ? '' : Number(e.target.value)
setVariants(newVariants) setVariants(newVariants)
}} }}
placeholder="0.00" placeholder="0.00"
step="0.01" step="any"
className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner" className="w-full px-3.5 py-2 bg-slate-900 border border-white/10 focus:border-cyan-400/40 rounded-xl outline-none text-xs text-white font-mono transition-all"
/> />
{selectedMat && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-slate-500 font-mono pointer-events-none">
{selectedMat.unit}
</span>
)}
</div> </div>
<div className="pt-5"> </div>
{/* Col 4: Line Subtotal Cost Display */}
<div className="w-full md:w-36 text-right">
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 mr-1 mb-1 block">
ต้นทุนรวมรายการนี้
</label>
<div className="px-3.5 py-2 bg-amber-500/10 border border-amber-500/20 rounded-xl text-xs font-mono font-bold text-amber-400 text-right">
{fmtMoney(lineCost)}
</div>
</div>
{/* Col 5: Remove Button */}
<div className="flex items-center justify-end pt-2 md:pt-5">
<button <button
onClick={() => { onClick={() => {
const newVariants = [...variants] const newVariants = [...variants]
newVariants[vIdx].bomItems.splice(bIdx, 1) newVariants[vIdx].bomItems.splice(bIdx, 1)
setVariants(newVariants) setVariants(newVariants)
}} }}
className="p-2.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors" className="p-2 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-xl transition-colors"
title="Remove Material" title="ลบวัตถุดิบนี้"
> >
<Trash2 className="w-5 h-5" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
))} )
})
)}
</div> </div>
{/* Variant Footer Breakdown */}
{hasBom && (
<div className="pt-3 border-t border-white/5 flex flex-col sm:flex-row sm:items-center justify-between text-xs text-slate-400 gap-2">
<p>
ใช้วัตถุดิบทั้งหมด <span className="text-white font-bold">{v.bomItems.length}</span> รายการ
</p>
<div className="flex items-center gap-4 font-mono">
<span>
ต้นทุนรวม BOM: <strong className="text-amber-400 text-sm">{fmtMoney(variantBomCost)}</strong>
</span>
<span>
กำไรประเมิน:{' '}
<strong className={profit >= 0 ? 'text-emerald-400 text-sm' : 'text-rose-400 text-sm'}>
{fmtMoney(profit)}
</strong>
</span>
</div>
</div>
)}
</section> </section>
) )
})} })
)}
</div> </div>
</div> </div>
) )
@@ -15,8 +15,7 @@ import {
Tag, Tag,
BadgePercent, BadgePercent,
} from 'lucide-react' } from 'lucide-react'
import { API_BASE } from '@homaroma/types'
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '')
function authHeaders() { function authHeaders() {
return { return {
@@ -56,7 +55,7 @@ export default function ShopeeOrderDetailPage() {
useEffect(() => { useEffect(() => {
if (!orderId) return if (!orderId) return
setLoading(true) setLoading(true)
fetch(`${API_URL}/api/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() }) fetch(`${API_BASE}/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (data.error) throw new Error(data.error) if (data.error) throw new Error(data.error)
+167 -37
View File
@@ -1,9 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store } from 'lucide-react'; import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store,
FileSpreadsheet, AlertTriangle, CheckCircle2, Files
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
// ── Shop Config ─────────────────────────────────────────────────────────────── // ── Shop Config ───────────────────────────────────────────────────────────────
export const SHOPEE_SHOPS = [ export const SHOPEE_SHOPS = [
@@ -92,7 +95,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
fetch(`${API_URL}/api/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() }) fetch(`${API_BASE}/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() })
.then(r => r.json()) .then(r => r.json())
.then(d => { .then(d => {
setItems(d.items || []); setItems(d.items || []);
@@ -181,13 +184,23 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
); );
} }
// ── Import Panel ────────────────────────────────────────────────────────────── // ── Import Panel (Supports Multiple Excel Files & Duplicate Detection) ────────
interface FileSummary {
name: string;
size: number;
sheetName: string;
rowsCount: number;
ordersCount: number;
}
function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) { function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) {
const [previewRows, setPreviewRows] = useState<any[]>([]); const [fileSummaries, setFileSummaries] = useState<FileSummary[]>([]);
const [allRows, setAllRows] = useState<any[]>([]); const [allRows, setAllRows] = useState<any[]>([]);
const [previewRows, setPreviewRows] = useState<any[]>([]);
const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0);
const [reading, setReading] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
const [sheetInfo, setSheetInfo] = useState('');
const shopCfg = getShop(shop); const shopCfg = getShop(shop);
const readFile = (f: File) => new Promise<XLSX.WorkBook>((resolve, reject) => { const readFile = (f: File) => new Promise<XLSX.WorkBook>((resolve, reject) => {
@@ -201,37 +214,92 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
}); });
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const f = e.target.files?.[0]; const files = e.target.files ? Array.from(e.target.files) : [];
if (!f) return; if (files.length === 0) return;
setMsg(null); setAllRows([]); setPreviewRows([]); setSheetInfo('');
setReading(true);
setMsg(null);
setAllRows([]);
setPreviewRows([]);
setFileSummaries([]);
setCrossFileDuplicateCount(0);
try { try {
const wb = await readFile(f); const summaries: FileSummary[] = [];
const combinedRows: any[] = [];
const orderFileMap = new Map<string, Set<string>>();
for (const file of files) {
const wb = await readFile(file);
const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0]; const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0];
const ws = wb.Sheets[sheetName]; const ws = wb.Sheets[sheetName];
setSheetInfo(`Sheet: "${sheetName}" | ${f.name}`);
const rawRows = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][]; const rawRows = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][];
if (rawRows.length < 2) { setMsg({ text: 'ไม่พบข้อมูลในไฟล์', type: 'error' }); return; }
if (rawRows.length < 2) continue;
const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : ''); const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : '');
const rows: any[] = []; const fileRows: any[] = [];
const fileOrderIds = new Set<string>();
for (let i = 1; i < rawRows.length; i++) { for (let i = 1; i < rawRows.length; i++) {
const cells = rawRows[i] as any[]; const cells = rawRows[i] as any[];
if (cells.every(c => c === null || c === undefined || c === '')) continue; if (cells.every(c => c === null || c === undefined || c === '')) continue;
const row: any = {}; const row: any = {};
headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; }); headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; });
if (row['หมายเลขคำสั่งซื้อ']) rows.push(row); const orderId = row['หมายเลขคำสั่งซื้อ'] ? String(row['หมายเลขคำสั่งซื้อ']).trim() : null;
if (orderId) {
fileRows.push(row);
fileOrderIds.add(orderId);
if (!orderFileMap.has(orderId)) {
orderFileMap.set(orderId, new Set());
} }
setAllRows(rows); orderFileMap.get(orderId)!.add(file.name);
setPreviewRows(rows.slice(0, 5)); }
}
summaries.push({
name: file.name,
size: file.size,
sheetName,
rowsCount: fileRows.length,
ordersCount: fileOrderIds.size,
});
combinedRows.push(...fileRows);
}
if (combinedRows.length === 0) {
setMsg({ text: 'ไม่พบข้อมูลคำสั่งซื้อในไฟล์ที่เลือก', type: 'error' });
setReading(false);
return;
}
// Calculate cross-file duplicates
let duplicatesAcrossFiles = 0;
orderFileMap.forEach((fileSet) => {
if (fileSet.size > 1) {
duplicatesAcrossFiles++;
}
});
setFileSummaries(summaries);
setAllRows(combinedRows);
setPreviewRows(combinedRows.slice(0, 5));
setCrossFileDuplicateCount(duplicatesAcrossFiles);
} catch (err: any) { } catch (err: any) {
setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' });
} finally {
setReading(false);
} }
}; };
const handleImport = async () => { const handleImport = async () => {
if (allRows.length === 0) return; if (allRows.length === 0) return;
setLoading(true); setMsg(null); setLoading(true);
setMsg(null);
try { try {
const res = await fetch(`${API_URL}/api/shopee/import/orders`, { const res = await fetch(`${API_BASE}/shopee/import/orders`, {
method: 'POST', method: 'POST',
headers: authHeaders(), headers: authHeaders(),
body: JSON.stringify({ rows: allRows, shop }), body: JSON.stringify({ rows: allRows, shop }),
@@ -239,14 +307,21 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
setMsg({ text: result.message, type: 'success' }); setMsg({ text: result.message, type: 'success' });
setAllRows([]); setPreviewRows([]); setSheetInfo(''); setAllRows([]);
setPreviewRows([]);
setFileSummaries([]);
setCrossFileDuplicateCount(0);
const inp = document.getElementById('shopee-file-input') as HTMLInputElement; const inp = document.getElementById('shopee-file-input') as HTMLInputElement;
if (inp) inp.value = ''; if (inp) inp.value = '';
onSuccess(); onSuccess();
} else throw new Error(result.message); } else {
throw new Error(result.message);
}
} catch (e: any) { } catch (e: any) {
setMsg({ text: e.message, type: 'error' }); setMsg({ text: e.message, type: 'error' });
} finally { setLoading(false); } } finally {
setLoading(false);
}
}; };
const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size; const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size;
@@ -270,30 +345,81 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<label <label
htmlFor="shopee-file-input" htmlFor="shopee-file-input"
className="flex flex-col items-center justify-center border-2 border-dashed border-white/10 hover:border-orange-500/30 rounded-2xl p-12 bg-black/20 cursor-pointer transition-all group gap-4" className="flex flex-col items-center justify-center border-2 border-dashed border-white/10 hover:border-orange-500/30 rounded-2xl p-10 bg-black/20 cursor-pointer transition-all group gap-4"
> >
<div className={`w-16 h-16 rounded-2xl ${shopCfg.bg} border ${shopCfg.border} flex items-center justify-center group-hover:opacity-80 transition-opacity`}> <div className={`w-16 h-16 rounded-2xl ${shopCfg.bg} border ${shopCfg.border} flex items-center justify-center group-hover:opacity-80 transition-opacity`}>
<Upload className={`w-8 h-8 ${shopCfg.color}`} /> <Upload className={`w-8 h-8 ${shopCfg.color}`} />
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold">คลิกเพื่อเลือกไฟล์ Excel</p> <p className="text-white font-bold text-base">คลิกเพื่อเลือกไฟล์ Excel (เลือกได้หลายไฟล์พร้อมกัน)</p>
<p className="text-slate-500 text-sm mt-1">รองรับ .xlsx จาก Shopee Seller Center (Orders Report)</p> <p className="text-slate-400 text-xs mt-1">กด <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Ctrl</kbd> หรือ <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Shift</kbd> ค้างไว้เพื่อเลือกหลายไฟล์ (.xlsx, .xls จาก Shopee Seller Center)</p>
</div> </div>
<input id="shopee-file-input" type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" /> <input id="shopee-file-input" type="file" multiple accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
</label> </label>
{sheetInfo && ( {reading && (
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/10 text-xs font-mono text-slate-400"> <div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2">
<span>📋 {sheetInfo}</span> <span className="animate-spin">⏳</span> กำลังประมวลผลไฟล์ Excel ทั้งหมด...
<span className={`font-bold ${allRows.length > 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
{allRows.length} แถว → {uniqueOrdersCount} คำสั่งซื้อ
</span>
</div> </div>
)} )}
{/* Selected Files Summary Cards */}
{fileSummaries.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-white">
<Files className="w-4 h-4 text-orange-400" />
ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์)
</div>
<div className="text-xs text-slate-400">
รวมทั้งหมด <span className="text-white font-bold">{allRows.length}</span> แถวสินค้า → <span className="text-emerald-400 font-bold">{uniqueOrdersCount}</span> คำสั่งซื้อไม่ซ้ำ
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{fileSummaries.map((f, idx) => (
<div key={idx} className="bg-white/5 border border-white/10 rounded-xl p-3.5 flex flex-col gap-2">
<div className="flex items-start gap-2.5">
<FileSpreadsheet className="w-4 h-4 text-orange-400 shrink-0 mt-0.5" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold text-white truncate" title={f.name}>{f.name}</p>
<p className="text-[11px] text-slate-400 font-mono mt-0.5">
{(f.size / 1024).toFixed(1)} KB • Sheet: {f.sheetName}
</p>
</div>
</div>
<div className="flex items-center justify-between text-xs pt-2 border-t border-white/5 text-slate-400">
<span>{f.rowsCount} แถว</span>
<span className="font-bold text-orange-300">{f.ordersCount} ออเดอร์</span>
</div>
</div>
))}
</div>
{/* Deduplication & Safety Alert */}
{crossFileDuplicateCount > 0 ? (
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1">
<p className="font-bold text-amber-300">ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ</p>
<p className="text-slate-300">
ระบบจะทำการรวมข้อมูลและ <strong>Upsert (อัปเดตอัตโนมัติ)</strong> ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน
</p>
</div>
</div>
) : (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2.5 text-xs text-emerald-300">
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
<span>ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์</span>
</div>
)}
</div>
)}
{/* Preview Table */}
{previewRows.length > 0 && ( {previewRows.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-slate-400 font-medium">Preview (5 แถวแรก):</p> <p className="text-sm text-slate-400 font-medium">Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):</p>
<div className="overflow-x-auto border border-white/10 rounded-xl"> <div className="overflow-x-auto border border-white/10 rounded-xl">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10"> <thead className="bg-white/5 text-slate-400 border-b border-white/10">
@@ -316,13 +442,17 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end pt-2">
<button <button
onClick={handleImport} onClick={handleImport}
disabled={loading} disabled={loading || reading}
className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`} className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`}
> >
{loading ? <><span className="animate-spin">⏳</span> กำลัง Import...</> : <><Upload className="w-4 h-4" /> Import {uniqueOrdersCount} คำสั่งซื้อ ({allRows.length} แถว) → {shopCfg.label}</>} {loading ? (
<><span className="animate-spin">⏳</span> กำลัง Import {fileSummaries.length} ไฟล์...</>
) : (
<><Upload className="w-4 h-4" /> Import {uniqueOrdersCount} คำสั่งซื้อ ({allRows.length} แถวสินค้า) → {shopCfg.label}</>
)}
</button> </button>
</div> </div>
</div> </div>
@@ -343,7 +473,7 @@ export default function ShopeePage() {
const loadSummary = (s: string) => { const loadSummary = (s: string) => {
setSummary(null); setSummary(null);
fetch(`${API_URL}/api/shopee/summary?shop=${s}`, { headers: authHeaders() }) fetch(`${API_BASE}/shopee/summary?shop=${s}`, { headers: authHeaders() })
.then(r => r.json()) .then(r => r.json())
.then(setSummary) .then(setSummary)
.catch(() => { }); .catch(() => { });
+525
View File
@@ -0,0 +1,525 @@
import { useState, useEffect, useMemo } from 'react';
import {
Map, ShoppingBag, DollarSign, Award,
Filter, Calendar, Table2, Search,
MapPin,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg';
import DistrictMapModal from '../components/tiktok-map/DistrictMapModal';
import { fmtBaht, fmtNum } from '../utils/thaiGeoUtils';
import { SHOPEE_SHOPS } from './ShopeePage';
type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
export default function ShopeeSalesMapPage() {
const [viewMode, setViewMode] = useState<ViewMode>('map');
const [shop, setShop] = useState('all');
const [period, setPeriod] = useState<TimePeriod>('all');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [loading, setLoading] = useState(false);
const [mapData, setMapData] = useState<any>(null);
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [listSearch, setListSearch] = useState('');
// Fetch sales map data
const loadData = () => {
setLoading(true);
const params = new URLSearchParams();
if (shop && shop !== 'all') params.set('shop', shop);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/shopee/sales-map?${params.toString()}`)
.then((r) => r.json())
.then((data) => setMapData(data))
.catch((err) => console.error('Failed to load Shopee sales map data:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
loadData();
}, [shop, dateFrom, dateTo]);
// Handle preset period change
const handlePeriodChange = (p: TimePeriod) => {
setPeriod(p);
const now = new Date();
if (p === 'all') {
setDateFrom('');
setDateTo('');
} else if (p === '30days') {
const past = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
setDateFrom(past.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
} else if (p === 'this_year') {
const startOfYear = new Date(now.getFullYear(), 0, 1);
setDateFrom(startOfYear.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
}
};
// Provinces lookup map by nameTh
const provincesLookup = useMemo(() => {
const map: Record<string, any> = {};
if (mapData?.provinces) {
for (const p of mapData.provinces) {
map[p.nameTh] = p;
}
}
return map;
}, [mapData]);
// Selected province detail
const currentProvinceInfo = useMemo(() => {
if (!selectedProvince || !provincesLookup[selectedProvince]) {
return null;
}
return provincesLookup[selectedProvince];
}, [selectedProvince, provincesLookup]);
return (
<div className="space-y-6 animate-fade-in text-white pb-10">
{/* Header */}
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
<div className="flex items-center gap-3.5">
<div className="w-12 h-12 rounded-2xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center text-orange-400">
<Map className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white">
แผนที่ยอดขายประเทศไทย (Shopee Sales Map)
</h1>
</div>
<p className="text-xs text-slate-400 mt-0.5">
วิเคราะห์การกระจายตัวของยอดขาย Shopee และศักยภาพลูกค้าแยกตาม 77 จังหวัด
</p>
</div>
</div>
{/* Top Control Bar */}
<div className="flex items-center gap-2.5 flex-wrap">
{/* View Mode Toggle */}
<div className="flex items-center p-1 bg-slate-900 border border-white/10 rounded-xl">
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewMode === 'map'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Map className="w-3.5 h-3.5" /> มุมมองแผนที่
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
viewMode === 'list'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-slate-400 hover:text-white'
}`}
>
<Table2 className="w-3.5 h-3.5" /> มุมมองรายการ
</button>
</div>
{/* Shop Switcher */}
<select
value={shop}
onChange={(e) => setShop(e.target.value)}
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50"
>
<option value="all">ทั้งหมด (ทุกร้าน)</option>
{SHOPEE_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
{/* Period Preset */}
<select
value={period}
onChange={(e) => handlePeriodChange(e.target.value as TimePeriod)}
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50"
>
<option value="all">ทั้งหมด (All Time)</option>
<option value="30days">30 วันล่าสุด</option>
<option value="this_year">ปีนี้</option>
<option value="custom">กำหนดเอง</option>
</select>
{/* Custom Date Pickers */}
<div className="flex items-center gap-1.5 bg-slate-900 border border-white/10 rounded-xl px-2 py-1">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
<input
type="date"
value={dateFrom}
onChange={(e) => {
setDateFrom(e.target.value);
setPeriod('custom');
}}
className="bg-transparent text-xs text-slate-300 focus:outline-none"
/>
<span className="text-slate-600 text-xs">-</span>
<input
type="date"
value={dateTo}
onChange={(e) => {
setDateTo(e.target.value);
setPeriod('custom');
}}
className="bg-transparent text-xs text-slate-300 focus:outline-none"
/>
</div>
<button
onClick={loadData}
disabled={loading}
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
>
{loading ? <span className="animate-spin">⏳</span> : <Filter className="w-3.5 h-3.5" />}
{loading ? 'กำลังโหลด...' : 'กรอง'}
</button>
</div>
</div>
{/* KPI 5 Cards Bar */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3.5">
{/* KPI 1 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดขายรวม</span>
<span className="text-orange-400 font-bold text-[11px] bg-orange-500/10 px-1.5 py-0.5 rounded-full border border-orange-500/20">
Shopee
</span>
</div>
<div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white font-mono">
{fmtBaht(mapData?.kpi?.totalRevenue || 0)}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">Shopee Total Revenue</p>
</div>
</div>
{/* KPI 2 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>คำสั่งซื้อสะสม</span>
<ShoppingBag className="w-3.5 h-3.5 text-orange-400" />
</div>
<div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white font-mono">
{fmtNum(mapData?.kpi?.totalOrders || 0)}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">รวมทุกคำสั่งซื้อ Shopee</p>
</div>
</div>
{/* KPI 3 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>การเข้าถึงจังหวัด</span>
<span className="text-emerald-400 font-bold text-[11px] font-mono">
{(mapData?.kpi?.coveragePercent || 0).toFixed(1)}%
</span>
</div>
<div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono">
{mapData?.kpi?.coverageCount || 0}{' '}
<span className="text-xs text-slate-500 font-normal">/ 77 จังหวัด</span>
</p>
<p className="text-[10px] text-slate-500 mt-0.5">ครอบคลุมทั่วประเทศ</p>
</div>
</div>
{/* KPI 4 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดเฉลี่ย/ออเดอร์</span>
<DollarSign className="w-3.5 h-3.5 text-amber-400" />
</div>
<div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-amber-400 font-mono">
{fmtBaht(mapData?.kpi?.aov || 0)}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">AOV คำนวณจากทุกคำสั่งซื้อ</p>
</div>
</div>
{/* KPI 5 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between col-span-2 md:col-span-1">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>ภาคที่มีสัดส่วนสูงสุด</span>
<span className="text-orange-400 font-bold text-[10px] bg-orange-500/10 px-2 py-0.5 rounded-full border border-orange-500/20">
Top Region
</span>
</div>
<div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white tracking-tight">
{mapData?.kpi?.topRegion?.name || 'ภาคกลาง'}
</p>
<p className="text-[10px] text-slate-500 mt-0.5">
{fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ (
{fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)})
</p>
</div>
</div>
</div>
{/* Main Content Area */}
{viewMode === 'map' ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
{/* Left/Center: Thailand SVG Map & Province Info Card (8 Cols) */}
<div className="lg:col-span-8 flex flex-col gap-4">
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6 flex flex-col">
<div className="mb-4">
<h2 className="text-lg font-bold text-white">แผนที่ยอดขายประเทศไทย (Shopee)</h2>
<p className="text-xs text-slate-400">
สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น
</p>
</div>
{/* Map & Focus Card Row */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-5 items-center">
{/* SVG Map (7 Cols) */}
<div className="md:col-span-7">
<ThailandMapSvg
provincesData={provincesLookup}
selectedProvince={selectedProvince}
onSelectProvince={(nameTh) => {
if (selectedProvince === nameTh) {
setSelectedProvince(null);
} else {
setSelectedProvince(nameTh);
}
}}
/>
</div>
{/* Province Focus Card (5 Cols) */}
<div className="md:col-span-5 flex flex-col bg-slate-950/70 border border-white/10 rounded-2xl p-5 space-y-4">
<div className="flex items-center justify-between text-xs">
<span className="text-slate-500 uppercase tracking-wider font-bold">
จังหวัด
</span>
{selectedProvince && (
<button
onClick={() => setSelectedProvince(null)}
className="text-xs text-orange-400 hover:underline font-bold"
>
ดูทั่วประเทศ
</button>
)}
</div>
<div>
<h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
{currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'}
</h3>
<p className="text-2xl font-bold text-orange-400 font-mono mt-1">
{fmtBaht(
currentProvinceInfo
? currentProvinceInfo.revenue
: mapData?.kpi?.totalRevenue || 0
)}
</p>
<p className="text-xs text-slate-400 mt-1">
{fmtNum(
currentProvinceInfo
? currentProvinceInfo.orders
: mapData?.kpi?.totalOrders || 0
)}{' '}
คำสั่งซื้อ •{' '}
{currentProvinceInfo
? currentProvinceInfo.regionTh
: `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`}
</p>
</div>
{/* Color Scale Legend */}
<div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between text-[11px] text-slate-500">
<span>ไม่มียอด</span>
<span>ยอดสูง</span>
</div>
<div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" />
</div>
{/* Map Focus Button */}
{selectedProvince ? (
<button
onClick={() => setIsModalOpen(true)}
className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
>
<MapPin className="w-4 h-4" /> ดูรายละเอียดและแผนที่อำเภอ (Map Focus)
</button>
) : (
<div className="text-[11px] text-slate-500 leading-relaxed pt-1">
เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง
</div>
)}
</div>
</div>
</div>
</div>
{/* Right: Top 20 Provinces Ranking (4 Cols) */}
<div className="lg:col-span-4 flex flex-col bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6">
<div className="flex items-center justify-between pb-4 border-b border-white/10">
<div className="flex items-center gap-2">
<Award className="w-5 h-5 text-orange-400" />
<h2 className="text-base font-bold text-white">จังหวัดยอดขายสูง (Shopee)</h2>
</div>
<span className="text-xs text-slate-400 font-mono">Top 20</span>
</div>
<div className="flex-1 overflow-y-auto max-h-[600px] divide-y divide-white/5 space-y-1 pr-1 mt-2">
{mapData?.topProvinces?.map((p: any) => {
const isSelected = selectedProvince === p.nameTh;
const isTop3 = p.rank <= 3;
return (
<div
key={p.code || p.nameTh}
onClick={() => setSelectedProvince(p.nameTh)}
className={`flex items-center justify-between p-3 rounded-2xl cursor-pointer transition-all ${
isSelected
? 'bg-orange-500/20 border border-orange-500/40 text-orange-300'
: 'hover:bg-white/5 text-slate-300'
}`}
>
<div className="flex items-center gap-3">
<span
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold font-mono ${
isTop3
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'bg-white/5 text-slate-500'
}`}
>
{p.rank}
</span>
<div>
<p className="text-xs font-bold text-white flex items-center gap-1.5">
{p.nameTh}
</p>
<p className="text-[10px] text-slate-500">
{p.orders} ออเดอร์ • {p.regionTh}
</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-bold font-mono text-orange-400">
{fmtBaht(p.revenue)}
</p>
<p className="text-[9px] text-slate-500">
AOV {fmtBaht(p.aov)}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
) : (
/* List / Table View */
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-6 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-bold text-white">
ตารางสรุปยอดขาย Shopee แยกราย 77 จังหวัด
</h2>
<div className="relative w-64">
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={listSearch}
onChange={(e) => setListSearch(e.target.value)}
placeholder="ค้นหาชื่อจังหวัด หรือ ภาค..."
className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/50"
/>
</div>
</div>
<div className="overflow-x-auto border border-white/10 rounded-2xl">
<table className="w-full text-xs text-left">
<thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10">
<tr>
<th className="px-4 py-3">รหัส</th>
<th className="px-4 py-3">จังหวัด</th>
<th className="px-4 py-3">ภูมิภาค</th>
<th className="px-4 py-3 text-right">ยอดขายรวม (฿)</th>
<th className="px-4 py-3 text-right">จำนวนออเดอร์</th>
<th className="px-4 py-3 text-right">AOV (฿/ออเดอร์)</th>
<th className="px-4 py-3 text-center">จัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{mapData?.provinces
?.filter(
(p: any) =>
!listSearch ||
p.nameTh.includes(listSearch) ||
p.nameEn.toLowerCase().includes(listSearch.toLowerCase()) ||
p.regionTh.includes(listSearch)
)
.sort((a: any, b: any) => b.revenue - a.revenue)
.map((p: any) => (
<tr
key={p.code || p.nameTh}
className="hover:bg-white/[0.03] transition-colors"
>
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white">
{p.nameTh}{' '}
<span className="text-slate-500 text-[10px] font-mono">({p.nameEn})</span>
</td>
<td className="px-4 py-3 text-slate-400">{p.regionTh}</td>
<td className="px-4 py-3 text-right font-mono font-bold text-orange-400">
{fmtBaht(p.revenue)}
</td>
<td className="px-4 py-3 text-right font-mono text-white">
{fmtNum(p.orders)}
</td>
<td className="px-4 py-3 text-right font-mono text-amber-400">
{fmtBaht(p.orders > 0 ? p.revenue / p.orders : 0)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => {
setSelectedProvince(p.nameTh);
setIsModalOpen(true);
}}
className="px-3 py-1 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold border border-orange-500/20 transition-colors inline-flex items-center gap-1 cursor-pointer"
>
<MapPin className="w-3 h-3" /> แผนที่อำเภอ
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* District Focus Modal Dialog */}
{isModalOpen && selectedProvince && (
<DistrictMapModal
provinceName={selectedProvince}
shop={shop}
dateFrom={dateFrom}
dateTo={dateTo}
platformName="Shopee"
apiEndpoint="/shopee/sales-map/district"
onClose={() => setIsModalOpen(false)}
/>
)}
</div>
);
}
+110 -28
View File
@@ -297,15 +297,79 @@ export default function TiktokAnalyticsPage() {
)} )}
</div> </div>
{/* KPI Cards Row 1 */} {/* KPI Cards Row 1 - Main Financials */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="ยอดขายรวม (Order Amount)" value={fmtCurrency(summary.totalRevenue)} icon={DollarSign} color="text-emerald-400" bgColor="from-emerald-500/10" sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} /> <StatCard
<StatCard label="ยอดสุทธิก่อนหักคอมมิชชั่น" value={fmtCurrency(summary.netRevenue)} icon={TrendingUp} color="text-cyan-glow" bgColor="from-cyan-500/10" sub={`รายได้หลังหักค่าส่งจัดส่ง`} /> label="ยอดขายรวม (Gross Revenue)"
<StatCard label="ส่วนลด Platform (TikTok ออก)" value={fmtCurrency(summary.totalPlatformDisc)} icon={BadgePercent} color="text-purple-400" bgColor="from-purple-500/10" sub="TikTok อุดหนุนส่วนลดให้" /> value={fmtCurrency(summary.totalRevenue)}
<StatCard label="ส่วนลดร้านค้า (เราออก)" value={fmtCurrency(summary.totalSellerDisc)} icon={ReceiptText} color="text-rose-400" bgColor="from-rose-500/10" sub="ส่วนลดที่เราให้ลูกค้า" /> icon={DollarSign}
color="text-emerald-400"
bgColor="from-emerald-500/10"
sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`}
/>
<StatCard
label="ยอดเงินโอนสุทธิ (Settlement Payout)"
value={fmtCurrency(summary.totalSettlementAmount || summary.netRevenue)}
icon={TrendingUp}
color="text-cyan-glow"
bgColor="from-cyan-500/10"
sub="ยอดเงินเข้าบัญชีจริงจาก TikTok"
/>
<StatCard
label="ต้นทุนสินค้า BOM (BOM Cost)"
value={fmtCurrency(summary.totalBOMCost || 0)}
icon={Package}
color="text-amber-400"
bgColor="from-amber-500/10"
sub="คำนวณจากสูตรการผลิต"
/>
<StatCard
label="กำไรสุทธิแท้จริง (Net Profit)"
value={fmtCurrency(summary.totalNetProfit || (summary.totalSettlementAmount - summary.totalBOMCost) || 0)}
icon={ShoppingBag}
color="text-emerald-300"
bgColor="from-emerald-500/20"
sub={`Margin ${fmt(summary.netProfitMargin || 0, 1)}% ของยอดขาย`}
/>
</div> </div>
{/* KPI Cards Row 2 */} {/* KPI Cards Row 2 - Fees & Commissions Breakdown */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="ค่าคอมมิชชั่น TikTok Shop"
value={fmtCurrency(summary.totalCommission || 0)}
icon={ReceiptText}
color="text-rose-400"
bgColor="from-rose-500/10"
sub="TikTok Commission Fee"
/>
<StatCard
label="ค่าคอมมิชชั่น Affiliate"
value={fmtCurrency(summary.totalAffiliate || 0)}
icon={BadgePercent}
color="text-orange-400"
bgColor="from-orange-500/10"
sub="Affiliate Partner & Creator Fee"
/>
<StatCard
label="ส่วนลด Platform (TikTok ออก)"
value={fmtCurrency(summary.totalPlatformDisc)}
icon={BadgePercent}
color="text-purple-400"
bgColor="from-purple-500/10"
sub="TikTok อุดหนุนส่วนลดให้"
/>
<StatCard
label="ส่วนลดร้านค้า (เราออก)"
value={fmtCurrency(summary.totalSellerDisc)}
icon={ReceiptText}
color="text-pink-400"
bgColor="from-pink-500/10"
sub="ส่วนลดที่เราให้ลูกค้า"
/>
</div>
{/* KPI Cards Row 3 - Operations & Orders */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} /> <StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} />
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} /> <StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} />
@@ -313,14 +377,17 @@ export default function TiktokAnalyticsPage() {
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" /> <StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" />
</div> </div>
{/* Monthly Revenue Chart */} {/* Monthly Revenue & Financial Trend Chart */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
<div className="flex items-center gap-2">
<TrendingUp className={`w-5 h-5 ${shopCfg.color}`} /> <TrendingUp className={`w-5 h-5 ${shopCfg.color}`} />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน</h2> <h2 className="text-lg font-bold text-white">ยอดขาย ค่าธรรมเนียม และกำไรสุทธิรายเดือน</h2>
</div>
<span className="text-xs text-slate-400">เปรียบเทียบยอดขาย ยอดโอน ค่าคอมมิชชั่น และกำไรสุทธิ</span>
</div> </div>
{monthlyChart.length > 0 ? ( {monthlyChart.length > 0 ? (
<div className="h-[320px]"> <div className="h-[340px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyChart} margin={{ top: 5, right: 20, left: 10, bottom: 5 }} barGap={3}> <BarChart data={monthlyChart} margin={{ top: 5, right: 20, left: 10, bottom: 5 }} barGap={3}>
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff08" /> <CartesianGrid strokeDasharray="3 3" stroke="#ffffff08" />
@@ -329,8 +396,10 @@ export default function TiktokAnalyticsPage() {
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} /> <Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
<Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} />
<Bar dataKey="platformDiscount" name="฿ ส่วนลด Platform" fill="#c084fc" radius={[4, 4, 0, 0]} /> <Bar dataKey="settlement" name="฿ ยอดเงินโอนสุทธิ" fill="#22d3ee" radius={[4, 4, 0, 0]} />
<Bar dataKey="sellerDiscount" name="฿ ส่วนลดร้านค้า" fill="#fb7185" radius={[4, 4, 0, 0]} /> <Bar dataKey="commission" name="฿ ค่าคอม TikTok" fill="#f43f5e" radius={[4, 4, 0, 0]} />
<Bar dataKey="affiliate" name="฿ ค่า Affiliate" fill="#fb923c" radius={[4, 4, 0, 0]} />
<Bar dataKey="netProfit" name="฿ กำไรสุทธิ" fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -472,33 +541,46 @@ export default function TiktokAnalyticsPage() {
</div> </div>
</div> </div>
{/* Cost Waterfall Summary */} {/* Complete Financial & Cost Waterfall Summary */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
<div className="flex items-center gap-2">
<ReceiptText className="w-5 h-5 text-rose-400" /> <ReceiptText className="w-5 h-5 text-rose-400" />
<h2 className="text-lg font-bold text-white">สรุปค่าใช้จ่ายและส่วนลด</h2> <h2 className="text-lg font-bold text-white">โครงสร้างต้นทุน ค่าธรรมเนียม และกำไรสุทธิ (Financial Breakdown)</h2>
</div> </div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <span className="text-xs text-slate-400">หักลบรายได้ตามลำดับสู่กำไรสุทธิแท้จริง</span>
</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3 mb-6">
{[ {[
{ label: 'ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' }, { label: '1. ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' },
{ label: 'หัก: ค่าจัดส่ง (ผู้ซื้อจ่าย)', value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' }, { label: '2. หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' },
{ label: 'หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', bar: 'bg-rose-400' }, { label: '3. หัก: ค่าคอม TikTok', value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' },
{ label: 'ยอดสุทธิก่อนหักคอมมิชชั่น', value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true }, { label: '4. หัก: ค่า Affiliate', value: -(summary.totalAffiliate || 0), color: 'text-orange-400', sign: '-' },
{ label: '5. หัก: ต้นทุน BOM สินค้า', value: -(summary.totalBOMCost || 0), color: 'text-amber-400', sign: '-' },
{ label: '6. กำไรสุทธิแท้จริง (Net Profit)', value: summary.totalNetProfit || 0, color: 'text-emerald-300 font-bold', highlight: true, sign: '=' },
].map((item, i) => ( ].map((item, i) => (
<div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/10 bg-white/[0.02]'}`}> <div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-white/10 bg-white/[0.02]'}`}>
<p className="text-[10px] uppercase tracking-widest text-slate-500 mb-1">{item.label}</p> <div className="flex items-center justify-between mb-1">
<p className={`text-xl font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p> <p className="text-[10px] uppercase tracking-widest text-slate-400 truncate">{item.label}</p>
<span className="text-xs font-bold text-slate-500">{item.sign}</span>
</div>
<p className={`text-lg font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p>
{summary.totalRevenue > 0 && ( {summary.totalRevenue > 0 && (
<p className="text-[10px] text-slate-600 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด</p> <p className="text-[10px] text-slate-500 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอดขาย</p>
)} )}
</div> </div>
))} ))}
</div> </div>
<div className="flex items-center gap-6 p-4 rounded-xl border border-purple-500/20 bg-purple-500/5">
<BadgePercent className="w-8 h-8 text-purple-400" /> <div className="flex items-center gap-4 p-4 rounded-xl border border-purple-500/20 bg-purple-500/5">
<BadgePercent className="w-7 h-7 text-purple-400 flex-shrink-0" />
<div className="flex items-center justify-between w-full flex-wrap gap-2">
<div> <div>
<p className="text-xs text-slate-400">ส่วนลด Platform สนับสนุน (TikTok ออกให้)</p> <p className="text-xs text-slate-300 font-medium">ส่วนลด Platform สนับสนุน (TikTok ช่วยจ่ายให้ลูกค้า)</p>
<p className="text-xl font-bold text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p> <p className="text-[11px] text-slate-500">ยอดเงินที่ TikTok ออกคูปองสนับสนุนให้ผู้ซื้อ โดยไม่หักเงินจากร้านค้า</p>
</div>
<p className="text-xl font-bold font-mono text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,512 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
ArrowLeft,
ShoppingBag,
Package,
Truck,
User,
MapPin,
Receipt,
ExternalLink,
Loader2,
Calendar,
AlertTriangle,
CheckCircle2,
Calculator,
Layers,
ArrowUpRight,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage';
function authHeaders() {
return {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
};
}
function fmt(val: number | null | undefined) {
if (val == null) return '0.00';
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
}
function InfoRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
if (!value) return null;
return (
<div className="flex flex-col gap-0.5">
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">{label}</p>
<p className={`text-sm text-slate-200 leading-relaxed ${mono ? 'font-mono' : ''}`}>{value}</p>
</div>
);
}
function statusStyle(status: string) {
if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20';
if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20';
if (status.includes('จะจัดส่ง') || status.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20';
if (status.includes('ยกเลิก') || status.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20';
return 'bg-white/5 text-slate-400 border-white/10';
}
export default function TiktokOrderDetailPage() {
const { orderId } = useParams<{ orderId: string }>();
const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!orderId) return;
setLoading(true);
fetch(`${API_BASE}/tiktok-order/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() })
.then((r) => r.json())
.then((data) => {
if (data.error) throw new Error(data.error);
setOrder(data);
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [orderId]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 animate-pulse">
<Loader2 className="w-10 h-10 text-rose-400 animate-spin" />
<p className="text-slate-500 text-sm">กำลังโหลดรายละเอียดคำสั่งซื้อ TikTok และคำนวณต้นทุน BOM...</p>
</div>
);
}
if (error || !order) {
return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<ShoppingBag className="w-14 h-14 text-slate-700" />
<p className="text-slate-400 text-sm">{error || 'ไม่พบคำสั่งซื้อนี้'}</p>
<Link to="/tiktok-order" className="px-6 py-2 rounded-xl bg-rose-500/20 text-rose-400 border border-rose-500/30 font-bold text-xs flex items-center gap-2 hover:bg-rose-500/30 transition-all">
<ArrowLeft className="w-4 h-4" /> กลับหน้ารายการ
</Link>
</div>
);
}
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0;
return (
<div className="space-y-6 animate-fade-in text-white pb-12">
{/* Header Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Link
to="/tiktok-order"
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</Link>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl font-bold font-mono tracking-tight text-rose-400">
#{order.orderId}
</h1>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusStyle(order.status)}`}>
{order.status}
</span>
{order.subStatus && (
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded-full border border-white/10">
{order.subStatus}
</span>
)}
{/* BOM Status Pill */}
{hasIncompleteBom ? (
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 flex items-center gap-1.5 shadow-sm shadow-rose-950">
<AlertTriangle className="w-3.5 h-3.5" /> ขาด BOM ({order.missingBomCount} รายการ)
</span>
) : (
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" /> BOM ครบถ้วน
</span>
)}
</div>
<p className="text-xs text-slate-400 mt-1 flex items-center gap-2">
<span className={`font-bold ${shopCfg.color}`}>ร้าน: {shopCfg.label}</span>
<span>•</span>
<span>สั่งซื้อเมื่อ: {order.createdTime || '—'}</span>
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Link
to="/tiktok-sales-map"
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs font-bold transition-all"
>
<MapPin className="w-3.5 h-3.5" /> ดูใน Sales Map
</Link>
<a
href={`https://seller-th.tiktok.com/order`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-bold transition-all"
>
<ExternalLink className="w-3.5 h-3.5" /> TikTok Seller Center
</a>
</div>
</div>
{/* Missing BOM Alert Banner */}
{hasIncompleteBom && (
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/30 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-rose-300 shadow-lg shadow-rose-950/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-rose-500/20 text-rose-400 shrink-0">
<AlertTriangle className="w-5 h-5" />
</div>
<div>
<p className="text-sm font-bold text-rose-200">
ตรวจพบสินค้าขาดสูตร BOM หรือยังไม่ได้ใส่ราคาต้นทุน ({order.missingBomCount} รายการ)
</p>
<p className="text-xs text-rose-300/80">
พนักงานสามารถคลิกปุ่มสีแดงที่รายการสินค้าด้านล่าง เพื่อเข้าไปกรอกสูตร BOM หรือเชื่อมโยงสินค้าได้ทันที
</p>
</div>
</div>
<Link
to="/tools/marketplace-products"
className="px-3.5 py-1.5 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-bold text-xs shrink-0 flex items-center gap-1.5 shadow-md shadow-rose-900/50 transition-colors"
>
<Layers className="w-3.5 h-3.5" /> ตรวจสอบตาราง Mapping
</Link>
</div>
)}
{/* Main Layout Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column (2 Cols): Products Table & Details */}
<div className="lg:col-span-2 space-y-6">
{/* Order Items Table */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
<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-2.5">
<Package className="w-4 h-4 text-rose-400" />
<h2 className="font-bold text-sm text-white">
รายการสินค้าในคำสั่งซื้อ ({order.items?.length || 0} SKU)
</h2>
</div>
<div className="text-xs text-slate-400 flex items-center gap-3 font-mono">
<span>ต้นทุนรวม BOM: <strong className={order.orderTotalBomCost > 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)}</strong></span>
</div>
</div>
<div className="divide-y divide-white/5">
{order.items?.map((item: any) => {
const isItemBomMissing = !item.hasBom;
return (
<div
key={item.id}
className={`p-5 flex flex-col gap-3 transition-colors ${
isItemBomMissing
? 'bg-rose-500/[0.04] border-l-4 border-l-rose-500'
: 'hover:bg-white/[0.02]'
}`}
>
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
{/* Product Name & SKU */}
<div className="space-y-1.5 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-sm font-bold text-white leading-snug">{item.productName}</p>
{isItemBomMissing ? (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 animate-pulse">
🔴 ขาด BOM / รอใส่ราคา
</span>
) : (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/20">
✓ มีสูตร BOM
</span>
)}
</div>
<div className="flex items-center gap-2 flex-wrap text-xs text-slate-400">
{item.variation && (
<span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-300 font-mono text-[11px]">
ตัวเลือก: {item.variation}
</span>
)}
{item.sellerSku && (
<span className="text-[11px] font-mono text-slate-500">
SKU: {item.sellerSku}
</span>
)}
{item.masterProductName && (
<span className="text-[11px] font-mono text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded border border-violet-500/20">
เชื่อมโยง: {item.masterProductName} ({item.masterSku || 'No SKU'})
</span>
)}
</div>
</div>
{/* Pricing, Quantity & Subtotal */}
<div className="flex items-center justify-between md:justify-end gap-5 text-right shrink-0">
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ราคา/หน่วย</p>
<p className="text-xs font-mono text-slate-300">฿{fmt(item.unitOriginalPrice)}</p>
</div>
<div>
<p className="text-[10px] text-slate-500 uppercase tracking-widest">จำนวน</p>
<p className="text-sm font-bold font-mono text-white">x{item.quantity}</p>
</div>
<div className="min-w-[80px]">
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ยอดขาย (SKU)</p>
<p className="text-sm font-bold font-mono text-white">฿{fmt(item.subtotalAfterDiscount)}</p>
</div>
</div>
</div>
{/* BOM & Profit Row */}
<div className="pt-3 border-t border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
{item.hasBom ? (
<div className="flex items-center gap-4 flex-wrap">
<div>
<span className="text-slate-500 text-[11px]">ต้นทุน BOM/ชิ้น: </span>
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.unitBomCost)}</span>
</div>
<div>
<span className="text-slate-500 text-[11px]">ต้นทุนรวม: </span>
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.totalBomCost)}</span>
</div>
<div>
<span className="text-slate-500 text-[11px]">กำไรขั้นต้น: </span>
<span className={`font-mono font-bold ${item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%)
</span>
</div>
</div>
) : (
<div className="flex items-center gap-2 text-rose-400 font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
<span>ไม่พบต้นทุน BOM สำหรับสินค้านี้ (ทำให้คำนวณกำไรไม่ได้)</span>
</div>
)}
{/* Action Links */}
<div className="flex items-center gap-2 shrink-0">
{item.masterProductId ? (
<Link
to={`/products/${item.masterProductId}/bom`}
className="px-2.5 py-1 rounded-lg bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
>
<Calculator className="w-3 h-3 text-rose-400" />
{item.hasBom ? 'แก้ไขสูตร BOM' : '➕ ไปใส่สูตร BOM'}
<ArrowUpRight className="w-3 h-3" />
</Link>
) : (
<Link
to="/tools/marketplace-products"
className="px-2.5 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
>
<Layers className="w-3 h-3 text-amber-400" />
🔗 ไปเชื่อมโยง Master SKU
<ArrowUpRight className="w-3 h-3" />
</Link>
)}
</div>
</div>
{/* BOM Raw Material Breakdown (if available) */}
{item.bomItemsDetail && item.bomItemsDetail.length > 0 && (
<div className="p-2.5 rounded-xl bg-black/20 border border-white/5 space-y-1 text-[11px] font-mono text-slate-400">
<p className="text-[10px] text-slate-500 uppercase font-sans font-bold tracking-wider mb-1">
สูตรวัตถุดิบ BOM ({item.bomItemsDetail.length} รายการ):
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
{item.bomItemsDetail.map((bom: any, bIdx: number) => (
<div key={bIdx} className="flex justify-between items-center text-slate-300">
<span>• {bom.name} ({bom.quantity} {bom.unit})</span>
<span className="text-amber-400/90 font-mono">฿{fmt(bom.lineCost)}</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Shipping & Delivery Info */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Truck className="w-4 h-4 text-blue-400" />
<h2 className="font-bold text-sm text-white">ข้อมูลการจัดส่งและขนส่ง</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
<InfoRow label="Tracking ID" value={order.trackingId} mono />
<InfoRow label="ผู้ให้บริการขนส่ง" value={order.shippingProvider} />
<InfoRow label="ประเภทการส่ง" value={order.deliveryOption} />
<InfoRow label="Fulfillment" value={order.fulfillmentType} />
<InfoRow label="คลังสินค้า" value={order.warehouseName} />
<InfoRow label="ประเภทออเดอร์" value={order.orderType} />
</div>
</div>
{/* Timeline Dates */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Calendar className="w-4 h-4 text-amber-400" />
<h2 className="font-bold text-sm text-white">ไทม์ไลน์คำสั่งซื้อ</h2>
</div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
<InfoRow label="วันที่สร้างออเดอร์" value={order.createdTime} />
<InfoRow label="วันที่ชำระเงิน" value={order.paidTime} />
<InfoRow label="เวลา RTS" value={order.rtsTime} />
<InfoRow label="เวลาจัดส่งออก" value={order.shippedTime} />
<InfoRow label="เวลาส่งถึงลูกค้า" value={order.deliveredTime} />
<InfoRow label="เวลายกเลิก" value={order.cancelledTime} />
</div>
</div>
</div>
{/* Right Column (1 Col): Financials & Customer */}
<div className="space-y-6">
{/* BOM Cost & Profit Summary Card */}
<div className={`border rounded-2xl p-6 space-y-4 backdrop-blur-sm ${
hasIncompleteBom
? 'bg-rose-950/20 border-rose-500/30 shadow-lg shadow-rose-950/30'
: 'bg-slate-900/60 border-emerald-500/30 shadow-lg shadow-emerald-950/20'
}`}>
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Calculator className={`w-4 h-4 ${hasIncompleteBom ? 'text-rose-400' : 'text-emerald-400'}`} />
<h2 className="font-bold text-sm text-white">สรุปต้นทุน & กำไร (BOM)</h2>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between items-center text-slate-400">
<span>ยอดขายสุทธิ (Revenue)</span>
<span className="font-mono text-slate-200 font-bold">
฿{fmt(order.orderAmount)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-slate-400">ต้นทุนวัตถุดิบ (Total BOM Cost)</span>
<span className={`font-mono font-bold ${order.orderTotalBomCost > 0 ? 'text-amber-400' : 'text-rose-400'}`}>
฿{fmt(order.orderTotalBomCost)}
</span>
</div>
<div className="flex justify-between items-center text-violet-300">
<span className="text-slate-400">ค่าคอมมิชชั่น (Commission)</span>
<span className="font-mono font-semibold">
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
</span>
</div>
<div className="flex justify-between items-center text-cyan-300">
<span className="text-slate-400">ค่าพันธมิตร (Affiliate)</span>
<span className="font-mono font-semibold">
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
</span>
</div>
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div>
<span className="text-sm font-bold text-white">กำไรสุทธิ (Net Profit)</span>
<p className="text-[10px] text-slate-400">Margin: {order.orderGrossMargin?.toFixed(1)}%</p>
</div>
<span className={`text-xl font-bold font-mono ${
(order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400'
}`}>
฿{fmt(order.orderNetProfit ?? order.orderEstProfit)}
</span>
</div>
{hasIncompleteBom && (
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 space-y-1 text-rose-300">
<div className="flex items-center gap-1.5 font-bold text-rose-200 text-xs">
<AlertTriangle className="w-3.5 h-3.5" />
<span>คำเตือน: ตัวเลขยังไม่สมบูรณ์</span>
</div>
<p className="text-[11px] leading-relaxed">
มีสินค้า <strong>{order.missingBomCount} รายการ</strong> ยังไม่มีสูตร BOM หรือไม่มีราคาต้นทุน กรุณาใส่ราคา BOM เพื่อให้กำไรถูกต้อง
</p>
</div>
)}
</div>
</div>
{/* Customer & Address */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<User className="w-4 h-4 text-violet-400" />
<h2 className="font-bold text-sm text-white">ข้อมูลลูกค้าและพื้นที่</h2>
</div>
<div className="space-y-3 text-xs">
<InfoRow label="ชื่อผู้ใช้ TikTok" value={order.buyerUsername} />
<InfoRow label="ช่องทางชำระเงิน" value={order.paymentMethod} />
<div className="pt-2 border-t border-white/5 space-y-2">
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
พิกัดปลายทาง
</p>
<div className="flex items-center gap-1.5 text-sm text-slate-200">
<MapPin className="w-4 h-4 text-emerald-400" />
<span>
{order.district ? `อ.${order.district}, ` : ''}จ.{order.province || 'ไม่ระบุ'}
</span>
</div>
</div>
</div>
</div>
{/* Payment & Financial Summary */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Receipt className="w-4 h-4 text-emerald-400" />
<h2 className="font-bold text-sm text-white">สรุปยอดเงิน (Financials)</h2>
</div>
<div className="space-y-3 text-xs">
<div className="flex justify-between items-center text-slate-400">
<span>ยอดรวมสินค้า</span>
<span className="font-mono text-slate-200">
฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))}
</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>ส่วนลดแพลตฟอร์ม</span>
<span className="font-mono text-rose-400">
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))}
</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>ส่วนลดร้านค้า</span>
<span className="font-mono text-rose-400">
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))}
</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>ค่าจัดส่ง</span>
<span className="font-mono text-amber-400">฿{fmt(order.shippingFee)}</span>
</div>
<div className="flex justify-between items-center text-slate-400">
<span>ภาษี (Taxes)</span>
<span className="font-mono text-blue-400">฿{fmt(order.taxes)}</span>
</div>
{order.refundAmount > 0 && (
<div className="flex justify-between items-center text-rose-400 font-bold">
<span>ยอดคืนเงิน (Refund)</span>
<span className="font-mono">-฿{fmt(order.refundAmount)}</span>
</div>
)}
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
<span className="text-sm font-bold text-white">ยอดสุทธิ (Order Amount)</span>
<span className="text-xl font-bold font-mono text-emerald-400">
฿{fmt(order.orderAmount)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,829 @@
import { useState, useEffect, useMemo } from 'react';
import {
Map, ShoppingBag, DollarSign, Award,
Calendar, Table2, Search, CheckCircle2, XCircle, Filter,
MapPin, Globe2, TrendingUp, Layers, ArrowUpRight
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
import OmnichannelDistrictModal from '../../components/analytics/OmnichannelDistrictModal';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import { TIKTOK_SHOPS } from '../TiktokOrderPage';
import { SHOPEE_SHOPS } from '../ShopeePage';
import { LAZADA_SHOPS } from '../LazadaOrderPage';
type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type StatusFilter = 'completed' | 'all' | 'cancelled';
export default function OmnichannelSalesMapPage() {
const [viewMode, setViewMode] = useState<ViewMode>('map');
const [channel, setChannel] = useState<ChannelType>('all');
const [tiktokShop, setTiktokShop] = useState('all');
const [shopeeShop, setShopeeShop] = useState('all');
const [lazadaShop, setLazadaShop] = useState('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
const [period, setPeriod] = useState<TimePeriod>('all');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [loading, setLoading] = useState(false);
const [mapData, setMapData] = useState<any>(null);
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
const [showDistrictModal, setShowDistrictModal] = useState(false);
const [searchTable, setSearchTable] = useState('');
// Handle Period Presets
const handlePeriodChange = (newPeriod: TimePeriod) => {
setPeriod(newPeriod);
const now = new Date();
if (newPeriod === 'all') {
setDateFrom('');
setDateTo('');
} else if (newPeriod === '30days') {
const past = new Date();
past.setDate(now.getDate() - 30);
setDateFrom(past.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
} else if (newPeriod === 'this_year') {
const startOfYear = new Date(now.getFullYear(), 0, 1);
setDateFrom(startOfYear.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
}
};
// Fetch Map Data
const fetchData = () => {
setLoading(true);
const params = new URLSearchParams();
if (channel !== 'all') params.set('channel', channel);
if (tiktokShop !== 'all') params.set('tiktokShop', tiktokShop);
if (shopeeShop !== 'all') params.set('shopeeShop', shopeeShop);
if (lazadaShop !== 'all') params.set('lazadaShop', lazadaShop);
if (statusFilter) params.set('statusFilter', statusFilter);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/business-analytics/sales-map?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((data) => {
setMapData(data);
if (!selectedProvince && data.topProvinces?.length > 0) {
setSelectedProvince(data.topProvinces[0].nameTh);
}
})
.catch((err) => console.error('Failed to load omnichannel map data:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchData();
}, [channel, tiktokShop, shopeeShop, lazadaShop, statusFilter, dateFrom, dateTo]);
// Provinces lookup record for SVG Map
const provincesLookup = useMemo(() => {
const map: Record<string, any> = {};
if (mapData?.provinces) {
for (const p of mapData.provinces) {
map[p.nameTh] = p;
}
}
return map;
}, [mapData]);
// Lookup details of the currently selected province
const selectedProvinceDetail = useMemo(() => {
if (!mapData?.provinces || !selectedProvince) return null;
return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null;
}, [mapData, selectedProvince]);
// Filtered provinces for table view
const filteredProvinces = useMemo(() => {
if (!mapData?.provinces) return [];
if (!searchTable.trim()) return mapData.provinces;
const q = searchTable.toLowerCase();
return mapData.provinces.filter(
(p: any) =>
p.nameTh.toLowerCase().includes(q) ||
p.nameEn.toLowerCase().includes(q) ||
p.regionTh.toLowerCase().includes(q)
);
}, [mapData, searchTable]);
const kpi = mapData?.kpi;
return (
<div className="space-y-6 animate-fade-in">
{/* ── Page Header ──────────────────────────────────────────────────────── */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-rose-500/20 via-amber-500/20 to-blue-500/20 border border-orange-500/30 flex items-center justify-center shadow-lg shadow-orange-500/10">
<Globe2 className="w-6 h-6 text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white tracking-tight">Omnichannel Sales Map</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
TikTok + Shopee + Lazada
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">
Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ
</p>
</div>
</div>
{/* View Switcher */}
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${
viewMode === 'map'
? 'bg-gradient-to-r from-orange-500/20 to-rose-500/20 text-orange-300 border border-orange-500/40 shadow-sm'
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`}
>
<Map className="w-4 h-4" /> มุมมองแผนที่
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${
viewMode === 'list'
? 'bg-gradient-to-r from-orange-500/20 to-rose-500/20 text-orange-300 border border-orange-500/40 shadow-sm'
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`}
>
<Table2 className="w-4 h-4" /> ตาราง 77 จังหวัด
</button>
</div>
</div>
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Channel Selector */}
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
<button
onClick={() => setChannel('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'all'
? 'bg-white/15 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<Globe2 className="w-3.5 h-3.5 text-cyan-400" />
ทุกช่องทาง (Omnichannel)
</button>
<button
onClick={() => setChannel('tiktok')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'tiktok'
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
: 'text-slate-400 hover:text-rose-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-500" />
เฉพาะ TikTok
</button>
<button
onClick={() => setChannel('shopee')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'shopee'
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
: 'text-slate-400 hover:text-orange-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-orange-500" />
เฉพาะ Shopee
</button>
<button
onClick={() => setChannel('lazada')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'lazada'
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
: 'text-slate-400 hover:text-blue-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-blue-500" />
เฉพาะ Lazada
</button>
</div>
{/* Time Period Filter */}
<div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" />
<button
onClick={() => handlePeriodChange('all')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'all'
? 'bg-white/15 text-white border-white/20'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ทั้งหมด (All Time)
</button>
<button
onClick={() => handlePeriodChange('30days')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === '30days'
? 'bg-white/15 text-white border-white/20'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
30 วันล่าสุด
</button>
<button
onClick={() => handlePeriodChange('this_year')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'this_year'
? 'bg-white/15 text-white border-white/20'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ปีนี้
</button>
<button
onClick={() => handlePeriodChange('custom')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'custom'
? 'bg-white/15 text-white border-white/20'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
กำหนดเอง
</button>
</div>
</div>
{/* Secondary Filters: Shop selections & Custom Date Pickers */}
<div className="flex flex-wrap items-center justify-between gap-4 pt-3 border-t border-white/5 text-xs">
<div className="flex items-center gap-3 flex-wrap">
{/* TikTok Shop Dropdown */}
{(channel === 'all' || channel === 'tiktok') && (
<div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน TikTok:</span>
<select
value={tiktokShop}
onChange={(e) => setTiktokShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-rose-300 font-bold focus:outline-none"
>
<option value="all">TikTok ทุกร้าน</option>
{TIKTOK_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
)}
{/* Shopee Shop Dropdown */}
{(channel === 'all' || channel === 'shopee') && (
<div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน Shopee:</span>
<select
value={shopeeShop}
onChange={(e) => setShopeeShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-orange-300 font-bold focus:outline-none"
>
<option value="all">Shopee ทุกร้าน</option>
{SHOPEE_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
)}
{/* Lazada Shop Dropdown */}
{(channel === 'all' || channel === 'lazada') && (
<div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน Lazada:</span>
<select
value={lazadaShop}
onChange={(e) => setLazadaShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-blue-300 font-bold focus:outline-none"
>
<option value="all">Lazada ทุกร้าน</option>
{LAZADA_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
)}
{/* Status Filter */}
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
<Filter className="w-3 h-3 text-slate-400" /> สถานะ:
</span>
<button
type="button"
onClick={() => setStatusFilter('completed')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
statusFilter === 'completed'
? 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40 shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
สำเร็จ (ไม่รวมยกเลิก)
</button>
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
statusFilter === 'all'
? 'bg-white/20 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
ทุกสถานะ (รวมยกเลิก)
</button>
<button
type="button"
onClick={() => setStatusFilter('cancelled')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
statusFilter === 'cancelled'
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
: 'text-slate-400 hover:text-rose-400'
}`}
>
<XCircle className="w-3 h-3 text-rose-400" />
เฉพาะที่ยกเลิก
</button>
</div>
</div>
{/* Custom Date Range */}
{period === 'custom' && (
<div className="flex items-center gap-2">
<span className="text-slate-400">ตั้งแต่วันที่:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/>
<span className="text-slate-400">ถึง</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/>
</div>
)}
</div>
</div>
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Total Revenue & Channel Split */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวมทุกช่องทาง</p>
<DollarSign className="w-4 h-4 text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold text-emerald-400 font-mono">
{fmtBaht(kpi?.totalRevenue || 0)}
</p>
{/* Split bar: TikTok Rose / Shopee Orange / Lazada Blue */}
<div className="mt-2.5 space-y-1">
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500 transition-all"
style={{ width: `${kpi?.tiktokSharePercent || 0}%` }}
title={`TikTok: ${kpi?.tiktokSharePercent?.toFixed(1)}%`}
/>
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${kpi?.shopeeSharePercent || 0}%` }}
title={`Shopee: ${kpi?.shopeeSharePercent?.toFixed(1)}%`}
/>
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${kpi?.lazadaSharePercent || 0}%` }}
title={`Lazada: ${kpi?.lazadaSharePercent?.toFixed(1)}%`}
/>
</div>
<div className="flex justify-between text-[10px] text-slate-400 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT {kpi?.tiktokSharePercent?.toFixed(0)}%</span>
<span className="text-orange-400">SP {kpi?.shopeeSharePercent?.toFixed(0)}%</span>
<span className="text-blue-400">LZ {kpi?.lazadaSharePercent?.toFixed(0)}%</span>
</div>
</div>
</div>
</div>
{/* Total Orders & Channel Count */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">คำสั่งซื้อทั้งหมด</p>
<ShoppingBag className="w-4 h-4 text-orange-400" />
</div>
<div>
<p className="text-2xl font-bold text-white font-mono">
{fmtNum(kpi?.totalOrders || 0)} <span className="text-xs font-normal text-slate-400">ออเดอร์</span>
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokOrders || 0)}</span>
<span className="text-orange-400">SP: {fmtNum(kpi?.totalShopeeOrders || 0)}</span>
<span className="text-blue-400">LZ: {fmtNum(kpi?.totalLazadaOrders || 0)}</span>
</div>
</div>
</div>
{/* Province Coverage */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การเข้าถึงพื้นที่</p>
<Map className="w-4 h-4 text-cyan-400" />
</div>
<div>
<p className="text-2xl font-bold text-cyan-400 font-mono">
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span>
</p>
<p className="text-xs text-slate-400 mt-1">
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย
</p>
</div>
</div>
{/* AOV (Average Order Value) */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดซื้อเฉลี่ย (AOV)</p>
<TrendingUp className="w-4 h-4 text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-amber-400 font-mono">
{fmtBaht(kpi?.aov || 0)}
</p>
<div className="flex items-center justify-between text-[10px] text-slate-400 mt-1 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT: ฿{fmtNum(kpi?.tiktokAov || 0)}</span>
<span className="text-orange-400">SP: ฿{fmtNum(kpi?.shopeeAov || 0)}</span>
<span className="text-blue-400">LZ: ฿{fmtNum(kpi?.lazadaAov || 0)}</span>
</div>
</div>
</div>
{/* Top Region */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p>
<Award className="w-4 h-4 text-rose-400" />
</div>
<div>
<p className="text-xl font-bold text-white">
{kpi?.topRegion?.name || '-'}
</p>
<p className="text-xs text-slate-400 mt-1">
{fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์
</p>
</div>
</div>
</div>
{/* ── Main Section: Map View or Table View ─────────────────────────────── */}
{viewMode === 'map' ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Map & Focus Card Container (8 cols) */}
<div className="lg:col-span-8 flex flex-col gap-4">
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm relative min-h-[560px] flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Map className="w-4 h-4 text-orange-400" />
<h3 className="font-bold text-white text-sm">แผนที่ความหนาแน่นยอดขาย (Thailand Choropleth)</h3>
</div>
<div className="text-xs text-slate-400 flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
<span>ยอดขายน้อย</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
<span>ยอดขายสูง</span>
</div>
</div>
</div>
{/* Thailand SVG Map */}
<div className="flex-1 flex items-center justify-center">
{loading ? (
<div className="py-24 text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังประมวลผลข้อมูลแผนที่...
</div>
) : (
<ThailandMapSvg
provincesData={provincesLookup}
selectedProvince={selectedProvince}
onSelectProvince={(provName) => setSelectedProvince(provName)}
/>
)}
</div>
</div>
{/* Selected Province Details Card */}
{selectedProvinceDetail && (
<div className="bg-gradient-to-r from-orange-500/10 via-rose-500/10 to-blue-500/10 border border-orange-500/30 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-orange-400" />
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4>
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span>
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
{selectedProvinceDetail.regionTh}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
<span>ยอดรวม: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.orders}</strong> รายการ</span>
<span>AOV: <strong className="text-amber-400">{fmtBaht(selectedProvinceDetail.aov)}</strong></span>
</div>
{/* Platform Breakdown Mini Bar */}
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
<span className="text-[11px] text-slate-400">สัดส่วนยอดขาย:</span>
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500"
style={{ width: `${selectedProvinceDetail.tiktokShare}%` }}
title={`TikTok: ${selectedProvinceDetail.tiktokShare?.toFixed(1)}%`}
/>
<div
className="h-full bg-orange-500"
style={{ width: `${selectedProvinceDetail.shopeeShare}%` }}
title={`Shopee: ${selectedProvinceDetail.shopeeShare?.toFixed(1)}%`}
/>
<div
className="h-full bg-blue-500"
style={{ width: `${selectedProvinceDetail.lazadaShare}%` }}
title={`Lazada: ${selectedProvinceDetail.lazadaShare?.toFixed(1)}%`}
/>
</div>
<span className="text-rose-400 font-mono text-[11px]">TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)}</span>
<span className="text-orange-400 font-mono text-[11px]">SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)}</span>
<span className="text-blue-400 font-mono text-[11px]">LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)}</span>
</div>
</div>
<button
onClick={() => setShowDistrictModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-rose-600 hover:from-orange-600 hover:to-rose-700 text-white font-bold text-sm shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
>
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span>
<ArrowUpRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */}
<div className="lg:col-span-4 space-y-6">
{/* Top 20 Provinces Card */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center justify-between pb-3 border-b border-white/10">
<div className="flex items-center gap-2">
<Award className="w-4 h-4 text-orange-400" />
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดยอดขายสูงสุด</h3>
</div>
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">Omnichannel</span>
</div>
<div className="mt-3 space-y-2 max-h-[460px] overflow-y-auto pr-1">
{mapData?.topProvinces?.map((item: any) => {
const isSelected = selectedProvince === item.nameTh;
return (
<div
key={item.code}
onClick={() => setSelectedProvince(item.nameTh)}
className={`p-3 rounded-xl border transition-all cursor-pointer ${
isSelected
? 'bg-orange-500/20 border-orange-500/40 shadow-sm'
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={`w-6 h-6 rounded-lg flex items-center justify-center font-mono font-bold text-xs ${
item.rank <= 3
? 'bg-gradient-to-br from-amber-400 to-orange-500 text-slate-950 font-black'
: 'bg-white/10 text-slate-400'
}`}
>
{item.rank}
</span>
<div>
<p className="text-xs font-bold text-white">{item.nameTh}</p>
<p className="text-[10px] text-slate-400 font-mono">{item.orders} ออเดอร์</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-bold text-emerald-400 font-mono">{fmtBaht(item.revenue)}</p>
<div className="flex items-center gap-1 text-[9px] font-mono mt-0.5">
<span className="text-rose-400">TT {item.tiktokShare?.toFixed(0)}%</span>
<span className="text-slate-500">•</span>
<span className="text-orange-400">SP {item.shopeeShare?.toFixed(0)}%</span>
<span className="text-slate-500">•</span>
<span className="text-blue-400">LZ {item.lazadaShare?.toFixed(0)}%</span>
</div>
</div>
</div>
{/* Mini platform share bar */}
<div className="mt-2 h-1 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div className="h-full bg-rose-500" style={{ width: `${item.tiktokShare}%` }} />
<div className="h-full bg-orange-500" style={{ width: `${item.shopeeShare}%` }} />
<div className="h-full bg-blue-500" style={{ width: `${item.lazadaShare}%` }} />
</div>
</div>
);
})}
</div>
</div>
{/* Region Breakdown Card */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center gap-2 pb-3 border-b border-white/10">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="font-bold text-white text-sm">สัดส่วนยอดขายตามภูมิภาค</h3>
</div>
<div className="mt-3 space-y-3">
{mapData?.regionBreakdown?.map((reg: any) => (
<div key={reg.region} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-white font-medium">{reg.regionTh}</span>
<span className="font-mono text-emerald-400 font-bold">
{fmtBaht(reg.revenue)}{' '}
<span className="text-slate-400 font-normal">({reg.percentage?.toFixed(1)}%)</span>
</span>
</div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500"
style={{ width: `${(reg.tiktokRevenue / (reg.revenue || 1)) * 100}%` }}
/>
<div
className="h-full bg-orange-500"
style={{ width: `${(reg.shopeeRevenue / (reg.revenue || 1)) * 100}%` }}
/>
<div
className="h-full bg-blue-500"
style={{ width: `${(reg.lazadaRevenue / (reg.revenue || 1)) * 100}%` }}
/>
</div>
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
<span>{reg.orders} ออเดอร์</span>
<span className="text-slate-400">
TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)}
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
) : (
/* ── Table View: 77 Provinces ────────────────────────────────────────── */
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
<div className="p-4 border-b border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.02]">
<div className="relative flex-1 max-w-sm">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาชื่อจังหวัด หรือภาค..."
value={searchTable}
onChange={(e) => setSearchTable(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
/>
</div>
<p className="text-xs text-slate-400">
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
<tr>
<th className="px-4 py-3">รหัส</th>
<th className="px-4 py-3">จังหวัด</th>
<th className="px-4 py-3">ภูมิภาค</th>
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th>
<th className="px-4 py-3 text-right">ออเดอร์รวม</th>
<th className="px-4 py-3 text-right">TikTok (฿)</th>
<th className="px-4 py-3 text-right">Shopee (฿)</th>
<th className="px-4 py-3 text-right">Lazada (฿)</th>
<th className="px-4 py-3 text-right">AOV (฿)</th>
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filteredProvinces.length === 0 ? (
<tr>
<td colSpan={11} className="px-4 py-12 text-center text-slate-500">
ไม่พบข้อมูลจังหวัดตามคำค้นหา
</td>
</tr>
) : (
filteredProvinces.map((p: any) => (
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white">
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
{p.regionTh}
</span>
</td>
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-white">
{p.orders > 0 ? `${p.orders} ออเดอร์` : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-rose-400">
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-orange-400">
{p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-blue-400">
{p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-amber-400">
{p.aov > 0 ? fmtBaht(p.aov) : '-'}
</td>
<td className="px-4 py-3">
{p.revenue > 0 ? (
<div className="w-28 mx-auto space-y-1">
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div className="h-full bg-rose-500" style={{ width: `${p.tiktokShare}%` }} />
<div className="h-full bg-orange-500" style={{ width: `${p.shopeeShare}%` }} />
<div className="h-full bg-blue-500" style={{ width: `${p.lazadaShare}%` }} />
</div>
<div className="flex justify-between text-[9px] font-mono text-slate-400">
<span className="text-rose-400">{p.tiktokShare?.toFixed(0)}%</span>
<span className="text-orange-400">{p.shopeeShare?.toFixed(0)}%</span>
<span className="text-blue-400">{p.lazadaShare?.toFixed(0)}%</span>
</div>
</div>
) : (
<span className="text-slate-600 block text-center">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
setSelectedProvince(p.nameTh);
setShowDistrictModal(true);
}}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-orange-500/20 hover:text-orange-300 text-slate-400 border border-white/10 text-xs transition-colors"
>
ดูรายอำเภอ
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* ── District Level Modal ─────────────────────────────────────────────── */}
{showDistrictModal && selectedProvince && (
<OmnichannelDistrictModal
provinceName={selectedProvince}
channel={channel}
tiktokShop={tiktokShop}
shopeeShop={shopeeShop}
lazadaShop={lazadaShop}
statusFilter={statusFilter}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setShowDistrictModal(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,970 @@
import { useState, useEffect, useMemo } from 'react';
import {
Map, ShoppingBag, DollarSign, Award,
Calendar, Table2, Search, CheckCircle2, XCircle, Filter,
MapPin, Globe2, Layers, ArrowUpRight,
Package, ChevronDown, Loader2
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
import ProductDistrictModal from '../../components/analytics/ProductDistrictModal';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type MetricType = 'revenue' | 'quantity';
type StatusFilter = 'completed' | 'all' | 'cancelled';
interface ProductItem {
id: number;
name: string;
category: string;
brand: string;
totalMappedMarketplaceCount: number;
totalSoldUnits: number;
totalRevenue: number;
variants: Array<{
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
displayName: string;
mappedMarketplaceCount: number;
soldUnits: number;
revenue: number;
}>;
}
export default function ProductSalesMapPage() {
const [viewMode, setViewMode] = useState<ViewMode>('map');
const [metric, setMetric] = useState<MetricType>('revenue');
const [channel, setChannel] = useState<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
const [period, setPeriod] = useState<TimePeriod>('all');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
// Products list
const [productsList, setProductsList] = useState<ProductItem[]>([]);
const [loadingProducts, setLoadingProducts] = useState(true);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const [selectedVariantId, setSelectedVariantId] = useState<number | null>(null);
const [showProductDropdown, setShowProductDropdown] = useState(false);
const [productSearch, setProductSearch] = useState('');
// Map Data
const [loading, setLoading] = useState(false);
const [mapData, setMapData] = useState<any>(null);
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
const [showDistrictModal, setShowDistrictModal] = useState(false);
const [searchTable, setSearchTable] = useState('');
// 1. Fetch Products List
useEffect(() => {
setLoadingProducts(true);
fetch(`${API_BASE}/business-analytics/products-list`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((res) => {
if (res.products && res.products.length > 0) {
setProductsList(res.products);
// Default to first product
setSelectedProductId(res.products[0].id);
}
})
.catch((err) => console.error('Failed to load products list:', err))
.finally(() => setLoadingProducts(false));
}, []);
// Handle Period Presets
const handlePeriodChange = (newPeriod: TimePeriod) => {
setPeriod(newPeriod);
const now = new Date();
if (newPeriod === 'all') {
setDateFrom('');
setDateTo('');
} else if (newPeriod === '30days') {
const past = new Date();
past.setDate(now.getDate() - 30);
setDateFrom(past.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
} else if (newPeriod === 'this_year') {
const startOfYear = new Date(now.getFullYear(), 0, 1);
setDateFrom(startOfYear.toISOString().slice(0, 10));
setDateTo(now.toISOString().slice(0, 10));
}
};
// 2. Fetch Map Data for Selected Product / Variant
const fetchMapData = () => {
if (!selectedProductId && !selectedVariantId) return;
setLoading(true);
const params = new URLSearchParams();
if (selectedVariantId) params.set('variantId', selectedVariantId.toString());
else if (selectedProductId) params.set('productId', selectedProductId.toString());
if (channel !== 'all') params.set('channel', channel);
if (statusFilter) params.set('statusFilter', statusFilter);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
params.set('metric', metric);
fetch(`${API_BASE}/business-analytics/sales-map-products?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((data) => {
setMapData(data);
if (!selectedProvince && data.topProvinces?.length > 0) {
setSelectedProvince(data.topProvinces[0].nameTh);
}
})
.catch((err) => console.error('Failed to load product map data:', err))
.finally(() => setLoading(false));
};
useEffect(() => {
fetchMapData();
}, [selectedProductId, selectedVariantId, channel, metric, statusFilter, dateFrom, dateTo]);
// Selected Product & Variant Details
const currentProduct = useMemo(() => {
return productsList.find((p) => p.id === selectedProductId) || null;
}, [productsList, selectedProductId]);
const currentVariant = useMemo(() => {
if (!currentProduct || !selectedVariantId) return null;
return currentProduct.variants.find((v) => v.id === selectedVariantId) || null;
}, [currentProduct, selectedVariantId]);
// Filter products in dropdown search
const filteredDropdownProducts = useMemo(() => {
if (!productSearch.trim()) return productsList;
const q = productSearch.toLowerCase();
return productsList.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q) ||
p.variants.some((v) => v.sku.toLowerCase().includes(q) || v.optionsStr.toLowerCase().includes(q))
);
}, [productsList, productSearch]);
// Provinces lookup record for SVG Map
const provincesLookup = useMemo(() => {
const map: Record<string, any> = {};
if (mapData?.provinces) {
for (const p of mapData.provinces) {
map[p.nameTh] = {
...p,
// When metric is quantity, set revenue to unitsSold for SVG heat scale
revenue: metric === 'quantity' ? p.unitsSold : p.revenue,
};
}
}
return map;
}, [mapData, metric]);
// Lookup details of the currently selected province
const selectedProvinceDetail = useMemo(() => {
if (!mapData?.provinces || !selectedProvince) return null;
return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null;
}, [mapData, selectedProvince]);
// Filtered provinces for table view
const filteredProvinces = useMemo(() => {
if (!mapData?.provinces) return [];
if (!searchTable.trim()) return mapData.provinces;
const q = searchTable.toLowerCase();
return mapData.provinces.filter(
(p: any) =>
p.nameTh.toLowerCase().includes(q) ||
p.nameEn.toLowerCase().includes(q) ||
p.regionTh.toLowerCase().includes(q)
);
}, [mapData, searchTable]);
const kpi = mapData?.kpi;
return (
<div className="space-y-6 animate-fade-in pb-12">
{/* ── Page Header ──────────────────────────────────────────────────────── */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-500/20 via-purple-500/20 to-pink-500/20 border border-indigo-500/30 flex items-center justify-center shadow-lg shadow-indigo-500/10">
<Package className="w-6 h-6 text-indigo-400" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-3xl font-bold text-white tracking-tight">Product Sales Map</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
Geo-Intelligence by Product
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">
Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ
</p>
</div>
</div>
{/* View Switcher & Metric Switcher */}
<div className="flex items-center gap-3 flex-wrap">
{/* Metric Switcher */}
<div className="flex items-center p-1 bg-black/40 rounded-xl border border-white/10 text-xs">
<button
onClick={() => setMetric('revenue')}
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
metric === 'revenue'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
฿ ดูตามยอดขาย
</button>
<button
onClick={() => setMetric('quantity')}
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
metric === 'quantity'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
: 'text-slate-400 hover:text-white'
}`}
>
📦 ดูตามจำนวนชิ้น
</button>
</div>
{/* View Switcher */}
<div className="flex items-center gap-1.5">
<button
onClick={() => setViewMode('map')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
viewMode === 'map'
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/40 shadow-sm'
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`}
>
<Map className="w-4 h-4" /> แผนที่
</button>
<button
onClick={() => setViewMode('list')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
viewMode === 'list'
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/40 shadow-sm'
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`}
>
<Table2 className="w-4 h-4" /> ตาราง
</button>
</div>
</div>
</div>
{/* ── Product & Variant Selector Bar ────────────────────────────────────── */}
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-4 backdrop-blur-md relative z-30">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 relative">
<span className="text-xs font-bold text-slate-300 shrink-0">เลือกสินค้า Master:</span>
{/* Product Selector Dropdown Button */}
<div className="relative flex-1 max-w-xl">
<button
onClick={() => setShowProductDropdown(!showProductDropdown)}
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-slate-900/90 hover:bg-slate-900 border border-indigo-500/40 text-left text-xs font-bold text-white transition-all shadow-md"
>
<div className="flex items-center gap-2 truncate">
<Package className="w-4 h-4 text-indigo-400 shrink-0" />
<span className="truncate">
{loadingProducts ? 'กำลังโหลดสินค้า...' : currentProduct ? currentProduct.name : 'เลือกสินค้า'}
</span>
{currentProduct?.category && (
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-normal">
{currentProduct.category}
</span>
)}
</div>
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-2" />
</button>
{/* Dropdown Menu */}
{showProductDropdown && (
<div className="absolute left-0 top-full mt-2 w-full max-h-[380px] bg-slate-900 border border-white/20 rounded-2xl shadow-2xl p-3 z-50 flex flex-col space-y-2 backdrop-blur-xl animate-scale-in">
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาชื่อสินค้าหลัก, SKU, หมวดหมู่..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-black/50 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto space-y-1 pr-1 max-h-[280px]">
{filteredDropdownProducts.map((prod) => {
const isSelected = selectedProductId === prod.id && selectedVariantId === null;
return (
<div
key={prod.id}
onClick={() => {
setSelectedProductId(prod.id);
setSelectedVariantId(null);
setShowProductDropdown(false);
}}
className={`p-2.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between text-xs ${
isSelected
? 'bg-indigo-500/20 border-indigo-500/40 text-white font-bold'
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 text-slate-300'
}`}
>
<div className="space-y-0.5 truncate mr-2">
<p className="truncate font-semibold">{prod.name}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-400">
<span>{prod.category}</span>
<span>• {prod.variants.length} ตัวเลือก</span>
</div>
</div>
<div className="text-right shrink-0 font-mono text-[11px]">
<p className="text-emerald-400 font-bold">{fmtBaht(prod.totalRevenue)}</p>
<p className="text-[10px] text-slate-400">{fmtNum(prod.totalSoldUnits)} ชิ้น</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
</div>
{/* Variant Selector Tabs */}
{currentProduct && currentProduct.variants.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap text-xs">
<span className="text-slate-400 mr-1">ขนาด/ตัวเลือก:</span>
<button
onClick={() => setSelectedVariantId(null)}
className={`px-3 py-1 rounded-lg font-bold border transition-all ${
selectedVariantId === null
? 'bg-indigo-500/25 text-indigo-300 border-indigo-500/40 shadow'
: 'bg-black/30 text-slate-400 border-white/10 hover:text-white'
}`}
>
ทุกตัวเลือกรวม ({currentProduct.variants.length})
</button>
{currentProduct.variants.map((v) => {
const isSelected = selectedVariantId === v.id;
return (
<button
key={v.id}
onClick={() => setSelectedVariantId(v.id)}
className={`px-3 py-1 rounded-lg font-mono text-xs font-bold border transition-all ${
isSelected
? 'bg-indigo-500/25 text-indigo-300 border-indigo-500/40 shadow'
: 'bg-black/30 text-slate-400 border-white/10 hover:text-white'
}`}
>
{v.optionsStr || v.sku || `Variant #${v.id}`}
</button>
);
})}
</div>
)}
</div>
</div>
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4">
{/* Channel Selector */}
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
<button
onClick={() => setChannel('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
}`}
>
<Globe2 className="w-3.5 h-3.5 text-cyan-400" />
ทุกช่องทาง (Omnichannel)
</button>
<button
onClick={() => setChannel('tiktok')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'tiktok'
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
: 'text-slate-400 hover:text-rose-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-rose-500" />
เฉพาะ TikTok
</button>
<button
onClick={() => setChannel('shopee')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'shopee'
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
: 'text-slate-400 hover:text-orange-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-orange-500" />
เฉพาะ Shopee
</button>
<button
onClick={() => setChannel('lazada')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
channel === 'lazada'
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
: 'text-slate-400 hover:text-blue-400'
}`}
>
<span className="w-2 h-2 rounded-full bg-blue-500" />
เฉพาะ Lazada
</button>
</div>
{/* Time Period Filter */}
<div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" />
<button
onClick={() => handlePeriodChange('all')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'all' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ทั้งหมด (All Time)
</button>
<button
onClick={() => handlePeriodChange('30days')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === '30days' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
30 วันล่าสุด
</button>
<button
onClick={() => handlePeriodChange('this_year')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'this_year' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ปีนี้
</button>
<button
onClick={() => handlePeriodChange('custom')}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
period === 'custom' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
กำหนดเอง
</button>
</div>
</div>
{/* Secondary Row: Status Filter & Custom Date Range */}
<div className="flex flex-wrap items-center justify-between gap-4 pt-3 border-t border-white/5 text-xs">
{/* Status Filter */}
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
<Filter className="w-3 h-3 text-slate-400" /> สถานะ:
</span>
<button
type="button"
onClick={() => setStatusFilter('completed')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
statusFilter === 'completed'
? 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40 shadow'
: 'text-slate-400 hover:text-white'
}`}
>
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
สำเร็จ (ไม่รวมยกเลิก)
</button>
<button
type="button"
onClick={() => setStatusFilter('all')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
statusFilter === 'all'
? 'bg-white/20 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
ทุกสถานะ (รวมยกเลิก)
</button>
<button
type="button"
onClick={() => setStatusFilter('cancelled')}
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
statusFilter === 'cancelled'
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
: 'text-slate-400 hover:text-rose-400'
}`}
>
<XCircle className="w-3 h-3 text-rose-400" />
เฉพาะที่ยกเลิก
</button>
</div>
{/* Custom Date Range */}
{period === 'custom' && (
<div className="flex items-center gap-2">
<span className="text-slate-400">ตั้งแต่วันที่:</span>
<input
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/>
<span className="text-slate-400">ถึง</span>
<input
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/>
</div>
)}
</div>
</div>
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Product Total Revenue */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายสินค้านี้รวม</p>
<DollarSign className="w-4 h-4 text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold text-emerald-400 font-mono">
{fmtBaht(kpi?.totalRevenue || 0)}
</p>
{/* Split bar */}
<div className="mt-2.5 space-y-1">
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500 transition-all"
style={{ width: `${kpi?.tiktokSharePercent || 0}%` }}
title={`TikTok: ${kpi?.tiktokSharePercent?.toFixed(1)}%`}
/>
<div
className="h-full bg-orange-500 transition-all"
style={{ width: `${kpi?.shopeeSharePercent || 0}%` }}
title={`Shopee: ${kpi?.shopeeSharePercent?.toFixed(1)}%`}
/>
<div
className="h-full bg-blue-500 transition-all"
style={{ width: `${kpi?.lazadaSharePercent || 0}%` }}
title={`Lazada: ${kpi?.lazadaSharePercent?.toFixed(1)}%`}
/>
</div>
<div className="flex justify-between text-[10px] text-slate-400 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT {kpi?.tiktokSharePercent?.toFixed(0)}%</span>
<span className="text-orange-400">SP {kpi?.shopeeSharePercent?.toFixed(0)}%</span>
<span className="text-blue-400">LZ {kpi?.lazadaSharePercent?.toFixed(0)}%</span>
</div>
</div>
</div>
</div>
{/* Total Units Sold */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้</p>
<ShoppingBag className="w-4 h-4 text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-amber-400 font-mono">
{fmtNum(kpi?.totalUnitsSold || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span>
</p>
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokUnits || 0)}</span>
<span className="text-orange-400">SP: {fmtNum(kpi?.totalShopeeUnits || 0)}</span>
<span className="text-blue-400">LZ: {fmtNum(kpi?.totalLazadaUnits || 0)}</span>
</div>
</div>
</div>
{/* Province Coverage */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การกระจายตัวในจังหวัด</p>
<Map className="w-4 h-4 text-cyan-400" />
</div>
<div>
<p className="text-2xl font-bold text-cyan-400 font-mono">
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span>
</p>
<p className="text-xs text-slate-400 mt-1">
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย
</p>
</div>
</div>
{/* Top Province */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จังหวัดที่ขายดีที่สุด</p>
<Award className="w-4 h-4 text-rose-400" />
</div>
<div>
<p className="text-xl font-bold text-white">
{kpi?.topProvince?.name ? `จ.${kpi.topProvince.name}` : '-'}
</p>
<p className="text-xs text-slate-400 mt-1 font-mono">
{fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} ชิ้น
</p>
</div>
</div>
{/* Top Region */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p>
<Layers className="w-4 h-4 text-indigo-400" />
</div>
<div>
<p className="text-xl font-bold text-white">
{kpi?.topRegion?.name || '-'}
</p>
<p className="text-xs text-slate-400 mt-1 font-mono">
{fmtBaht(kpi?.topRegion?.revenue || 0)}
</p>
</div>
</div>
</div>
{/* ── Main Section: Map View or Table View ─────────────────────────────── */}
{viewMode === 'map' ? (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Map & Focus Card Container (8 cols) */}
<div className="lg:col-span-8 flex flex-col gap-4">
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm relative min-h-[560px] flex flex-col">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Map className="w-4 h-4 text-indigo-400" />
<h3 className="font-bold text-white text-sm">
แผนที่ความหนาแน่น {metric === 'quantity' ? 'จำนวนชิ้นที่ขาย (Units)' : 'ยอดขาย (Revenue)'}
</h3>
</div>
<div className="text-xs text-slate-400 flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
<span>น้อย</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
<span>มาก</span>
</div>
</div>
</div>
{/* Thailand SVG Map */}
<div className="flex-1 flex items-center justify-center">
{loading ? (
<div className="py-24 text-center text-slate-400 text-sm">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-indigo-400" />
กำลังประมวลผลข้อมูลแผนที่สินค้า...
</div>
) : (
<ThailandMapSvg
provincesData={provincesLookup}
selectedProvince={selectedProvince}
onSelectProvince={(provName) => setSelectedProvince(provName)}
/>
)}
</div>
</div>
{/* Selected Province Details Card */}
{selectedProvinceDetail && (
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-indigo-400" />
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4>
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span>
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
{selectedProvinceDetail.regionTh}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
<span>ยอดขาย: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
<span>จำนวนที่ขาย: <strong className="text-amber-400 text-sm">{selectedProvinceDetail.unitsSold}</strong> ชิ้น</span>
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.ordersCount}</strong> ออเดอร์</span>
</div>
{/* Platform Breakdown Mini Bar */}
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
<span className="text-[11px] text-slate-400">สัดส่วนช่องทาง:</span>
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500"
style={{ width: `${selectedProvinceDetail.tiktokShare}%` }}
title={`TikTok: ${selectedProvinceDetail.tiktokShare?.toFixed(1)}%`}
/>
<div
className="h-full bg-orange-500"
style={{ width: `${selectedProvinceDetail.shopeeShare}%` }}
title={`Shopee: ${selectedProvinceDetail.shopeeShare?.toFixed(1)}%`}
/>
<div
className="h-full bg-blue-500"
style={{ width: `${selectedProvinceDetail.lazadaShare}%` }}
title={`Lazada: ${selectedProvinceDetail.lazadaShare?.toFixed(1)}%`}
/>
</div>
<span className="text-rose-400 font-mono text-[11px]">TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)}</span>
<span className="text-orange-400 font-mono text-[11px]">SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)}</span>
<span className="text-blue-400 font-mono text-[11px]">LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)}</span>
</div>
</div>
<button
onClick={() => setShowDistrictModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:to-pink-600 text-white font-bold text-sm shadow-lg shadow-indigo-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
>
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span>
<ArrowUpRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */}
<div className="lg:col-span-4 space-y-6">
{/* Top 20 Provinces Card */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center justify-between pb-3 border-b border-white/10">
<div className="flex items-center gap-2">
<Award className="w-4 h-4 text-indigo-400" />
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด</h3>
</div>
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">By {metric}</span>
</div>
<div className="mt-3 space-y-2 max-h-[460px] overflow-y-auto pr-1">
{mapData?.topProvinces?.map((item: any) => {
const isSelected = selectedProvince === item.nameTh;
return (
<div
key={item.code}
onClick={() => setSelectedProvince(item.nameTh)}
className={`p-3 rounded-xl border transition-all cursor-pointer ${
isSelected
? 'bg-indigo-500/20 border-indigo-500/40 shadow-sm'
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<span
className={`w-6 h-6 rounded-lg flex items-center justify-center font-mono font-bold text-xs ${
item.rank <= 3
? 'bg-gradient-to-br from-indigo-400 to-purple-500 text-white font-black'
: 'bg-white/10 text-slate-400'
}`}
>
{item.rank}
</span>
<div>
<p className="text-xs font-bold text-white">{item.nameTh}</p>
<p className="text-[10px] text-amber-400 font-mono font-bold">{item.unitsSold} ชิ้น</p>
</div>
</div>
<div className="text-right">
<p className="text-xs font-bold text-emerald-400 font-mono">{fmtBaht(item.revenue)}</p>
<div className="flex items-center gap-1 text-[9px] font-mono mt-0.5">
<span className="text-rose-400">TT {item.tiktokShare?.toFixed(0)}%</span>
<span className="text-slate-500">•</span>
<span className="text-orange-400">SP {item.shopeeShare?.toFixed(0)}%</span>
<span className="text-slate-500">•</span>
<span className="text-blue-400">LZ {item.lazadaShare?.toFixed(0)}%</span>
</div>
</div>
</div>
{/* Mini platform share bar */}
<div className="mt-2 h-1 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div className="h-full bg-rose-500" style={{ width: `${item.tiktokShare}%` }} />
<div className="h-full bg-orange-500" style={{ width: `${item.shopeeShare}%` }} />
<div className="h-full bg-blue-500" style={{ width: `${item.lazadaShare}%` }} />
</div>
</div>
);
})}
</div>
</div>
{/* Region Breakdown Card */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center gap-2 pb-3 border-b border-white/10">
<Layers className="w-4 h-4 text-cyan-400" />
<h3 className="font-bold text-white text-sm">สัดส่วนตามภูมิภาค</h3>
</div>
<div className="mt-3 space-y-3">
{mapData?.regionBreakdown?.map((reg: any) => (
<div key={reg.region} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="text-white font-medium">{reg.regionTh}</span>
<span className="font-mono text-emerald-400 font-bold">
{fmtBaht(reg.revenue)}{' '}
<span className="text-amber-400 font-normal">({reg.unitsSold} ชิ้น)</span>
</span>
</div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div
className="h-full bg-rose-500"
style={{ width: `${(reg.tiktokRevenue / (reg.revenue || 1)) * 100}%` }}
/>
<div
className="h-full bg-orange-500"
style={{ width: `${(reg.shopeeRevenue / (reg.revenue || 1)) * 100}%` }}
/>
<div
className="h-full bg-blue-500"
style={{ width: `${(reg.lazadaRevenue / (reg.revenue || 1)) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
</div>
</div>
) : (
/* ── Table View: 77 Provinces ────────────────────────────────────────── */
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
<div className="p-4 border-b border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.02]">
<div className="relative flex-1 max-w-sm">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาชื่อจังหวัด หรือภาค..."
value={searchTable}
onChange={(e) => setSearchTable(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
/>
</div>
<p className="text-xs text-slate-400">
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
<tr>
<th className="px-4 py-3">รหัส</th>
<th className="px-4 py-3">จังหวัด</th>
<th className="px-4 py-3">ภูมิภาค</th>
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th>
<th className="px-4 py-3 text-right">จำนวนที่ขาย (ชิ้น)</th>
<th className="px-4 py-3 text-right">TikTok (฿)</th>
<th className="px-4 py-3 text-right">Shopee (฿)</th>
<th className="px-4 py-3 text-right">Lazada (฿)</th>
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{filteredProvinces.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-12 text-center text-slate-500">
ไม่พบข้อมูลจังหวัดตามคำค้นหา
</td>
</tr>
) : (
filteredProvinces.map((p: any) => (
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white">
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
{p.regionTh}
</span>
</td>
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-amber-400 font-bold">
{p.unitsSold > 0 ? `${p.unitsSold} ชิ้น` : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-rose-400">
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-orange-400">
{p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'}
</td>
<td className="px-4 py-3 text-right font-mono text-blue-400">
{p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'}
</td>
<td className="px-4 py-3">
{p.revenue > 0 ? (
<div className="w-28 mx-auto space-y-1">
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
<div className="h-full bg-rose-500" style={{ width: `${p.tiktokShare}%` }} />
<div className="h-full bg-orange-500" style={{ width: `${p.shopeeShare}%` }} />
<div className="h-full bg-blue-500" style={{ width: `${p.lazadaShare}%` }} />
</div>
<div className="flex justify-between text-[9px] font-mono text-slate-400">
<span className="text-rose-400">{p.tiktokShare?.toFixed(0)}%</span>
<span className="text-orange-400">{p.shopeeShare?.toFixed(0)}%</span>
<span className="text-blue-400">{p.lazadaShare?.toFixed(0)}%</span>
</div>
</div>
) : (
<span className="text-slate-600 block text-center">-</span>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => {
setSelectedProvince(p.nameTh);
setShowDistrictModal(true);
}}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-indigo-500/20 hover:text-indigo-300 text-slate-400 border border-white/10 text-xs transition-colors"
>
ดูรายอำเภอ
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
{/* ── Product District Level Modal ─────────────────────────────────────── */}
{showDistrictModal && selectedProvince && (
<ProductDistrictModal
provinceName={selectedProvince}
productId={selectedProductId}
variantId={selectedVariantId}
productName={currentProduct?.name}
variantName={currentVariant?.optionsStr}
channel={channel}
statusFilter={statusFilter}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setShowDistrictModal(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,908 @@
import { useState, useEffect, useMemo } from 'react';
import {
Wrench,
RefreshCw,
Search,
CheckCircle2,
AlertCircle,
Link2,
Unlink,
DollarSign,
ShoppingBag,
TrendingUp,
Package,
Sparkles,
X,
ChevronLeft,
ChevronRight,
Loader2,
} from 'lucide-react';
import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import { TIKTOK_SHOPS } from '../TiktokOrderPage';
import { SHOPEE_SHOPS } from '../ShopeePage';
import { LAZADA_SHOPS } from '../LazadaOrderPage';
interface MasterVariant {
id: number;
sku: string;
barcode: string;
price: number;
stock: number;
optionsStr: string;
productName: string;
categoryName: string;
brandName: string;
bomCost: number;
displayName: string;
}
export default function MarketplaceProductsPage() {
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [autoMatching, setAutoMatching] = useState(false);
const [data, setData] = useState<any>(null);
// Filters
const [channel, setChannel] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
const [shop, setShop] = useState('all');
const [status, setStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
const [sortBy, setSortBy] = useState('revenue');
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const limit = 30;
// Master variants for mapping picker
const [masterVariants, setMasterVariants] = useState<MasterVariant[]>([]);
const [mappingModalItem, setMappingModalItem] = useState<any>(null);
const [variantSearch, setVariantSearch] = useState('');
const [selectedVariantId, setSelectedVariantId] = useState<number | null>(null);
const [savingMapping, setSavingMapping] = useState(false);
// Toast / notification message
const [toast, setToast] = useState<{ type: 'success' | 'info' | 'error'; message: string } | null>(null);
const showToast = (message: string, type: 'success' | 'info' | 'error' = 'success') => {
setToast({ type, message });
setTimeout(() => setToast(null), 4000);
};
// 1. Fetch Marketplace Products
const fetchData = () => {
setLoading(true);
const params = new URLSearchParams();
if (channel !== 'all') params.set('channel', channel);
if (shop !== 'all') params.set('shop', shop);
if (status !== 'all') params.set('status', status);
if (sortBy) params.set('sortBy', sortBy);
if (search) params.set('search', search);
params.set('page', page.toString());
params.set('limit', limit.toString());
fetch(`${API_BASE}/tools/marketplace-products?${params.toString()}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((res) => setData(res))
.catch((err) => {
console.error('Failed to load marketplace products:', err);
showToast('เกิดข้อผิดพลาดในการโหลดข้อมูลสินค้า', 'error');
})
.finally(() => setLoading(false));
};
// 2. Fetch Master Variants
const fetchMasterVariants = () => {
fetch(`${API_BASE}/tools/marketplace-products/master-variants`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
})
.then((r) => r.json())
.then((res) => {
if (res.variants) setMasterVariants(res.variants);
})
.catch((err) => console.error('Failed to load master variants:', err));
};
useEffect(() => {
fetchData();
}, [channel, shop, status, sortBy, search, page]);
useEffect(() => {
fetchMasterVariants();
}, []);
// 3. Trigger Sync All Platforms
const handleSyncAll = async () => {
setSyncing(true);
try {
const res = await fetch(`${API_BASE}/tools/marketplace-products/sync`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
});
const result = await res.json();
if (result.success) {
showToast(
`Sync ข้อมูลสินค้าเรียบร้อย! ทั้งหมด ${fmtNum(result.totalMarketplaceProducts)} รายการ (จับคู่ใหม่อัตโนมัติ ${result.matchedCount} รายการ)`,
'success'
);
fetchData();
} else {
showToast('ไม่สามารถ Sync ข้อมูลได้', 'error');
}
} catch (err) {
console.error(err);
showToast('เกิดข้อผิดพลาดในการเชื่อมต่อ Sync', 'error');
} finally {
setSyncing(false);
}
};
// 4. Trigger Bulk Auto Match
const handleBulkAutoMatch = async () => {
setAutoMatching(true);
try {
const res = await fetch(`${API_BASE}/tools/marketplace-products/bulk-auto-match`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
});
const result = await res.json();
if (result.success) {
showToast(
`Auto-Match สำเร็จ! จับคู่ SKU ตรงกันได้เพิ่ม ${fmtNum(result.matched)} รายการ (คงเหลือยังไม่ผูก ${fmtNum(result.remainingUnmapped)} รายการ)`,
'success'
);
fetchData();
}
} catch (err) {
console.error(err);
showToast('เกิดข้อผิดพลาดในการ Auto-Match', 'error');
} finally {
setAutoMatching(false);
}
};
// 5. Save Mapping for Single Item
const handleSaveMapping = async (marketplaceProductId: number, masterVariantId: number | null) => {
setSavingMapping(true);
try {
const res = await fetch(`${API_BASE}/tools/marketplace-products/map`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
},
body: JSON.stringify({ marketplaceProductId, masterVariantId }),
});
const result = await res.json();
if (result.success) {
showToast(masterVariantId ? 'ผูกสินค้ากับ Master Catalog เรียบร้อย' : 'ยกเลิกการผูกสินค้าเรียบร้อย', 'success');
setMappingModalItem(null);
fetchData();
}
} catch (err) {
console.error(err);
showToast('ไม่สามารถบันทึกการผูกสินค้าได้', 'error');
} finally {
setSavingMapping(false);
}
};
// Filter master variants inside modal
const filteredMasterVariants = useMemo(() => {
if (!variantSearch.trim()) return masterVariants;
const q = variantSearch.toLowerCase();
return masterVariants.filter(
(v) =>
v.productName.toLowerCase().includes(q) ||
v.sku.toLowerCase().includes(q) ||
v.barcode.toLowerCase().includes(q) ||
v.optionsStr.toLowerCase().includes(q) ||
v.categoryName.toLowerCase().includes(q)
);
}, [masterVariants, variantSearch]);
const kpi = data?.kpi;
const items = data?.items || [];
const pagination = data?.pagination;
return (
<div className="space-y-6 animate-fade-in pb-12">
{/* ── Toast Notification ──────────────────────────────────────────────── */}
{toast && (
<div
className={`fixed bottom-6 right-6 z-50 px-4 py-3 rounded-2xl shadow-2xl border text-sm font-medium flex items-center gap-3 backdrop-blur-md animate-slide-up ${
toast.type === 'success'
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
: toast.type === 'error'
? 'bg-rose-950/90 border-rose-500/40 text-rose-200'
: 'bg-slate-900/90 border-white/20 text-white'
}`}
>
{toast.type === 'success' ? (
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
) : (
<AlertCircle className="w-5 h-5 text-rose-400" />
)}
<span>{toast.message}</span>
</div>
)}
{/* ── Page Header ──────────────────────────────────────────────────────── */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-amber-500/20 via-orange-500/20 to-rose-500/20 border border-amber-500/30 flex items-center justify-center shadow-lg shadow-amber-500/10">
<Wrench className="w-6 h-6 text-amber-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white tracking-tight">Marketplace Products & SKU Manager</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30">
Tool • TikTok + Shopee + Lazada
</span>
</div>
<p className="text-xs text-slate-400 mt-0.5">
รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2.5 flex-wrap">
<button
onClick={handleBulkAutoMatch}
disabled={autoMatching || syncing}
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 hover:text-white border border-white/10 text-xs font-bold transition-all disabled:opacity-50"
>
{autoMatching ? <Loader2 className="w-4 h-4 animate-spin text-amber-400" /> : <Sparkles className="w-4 h-4 text-amber-400" />}
<span>⚡ Auto-Match SKUs</span>
</button>
<button
onClick={handleSyncAll}
disabled={syncing || autoMatching}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 hover:from-amber-600 hover:to-rose-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
<span>{syncing ? 'กำลังประมวลผลดึงข้อมูล...' : '🔄 Sync จาก Order ทั้งหมด'}</span>
</button>
</div>
</div>
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
{/* Total SKUs */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สินค้าบน Marketplace</p>
<Package className="w-4 h-4 text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-white font-mono">
{fmtNum(kpi?.totalSkus || 0)} <span className="text-xs font-normal text-slate-400">รายการ/SKU</span>
</p>
<p className="text-xs text-slate-400 mt-1">
รวมทุกรูปแบบสินค้าที่เคยขายได้
</p>
</div>
</div>
{/* Mapping Progress */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สถานะการผูก Master SKU</p>
<Link2 className="w-4 h-4 text-cyan-400" />
</div>
<div>
<div className="flex items-baseline justify-between">
<p className="text-2xl font-bold text-cyan-400 font-mono">
{kpi?.mappedSkus || 0} <span className="text-xs font-normal text-slate-400">/ {kpi?.totalSkus || 0}</span>
</p>
<span className="text-xs font-bold text-cyan-400 font-mono">
{kpi?.mappedPercent?.toFixed(1)}%
</span>
</div>
{/* Progress bar */}
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden mt-2">
<div
className="h-full bg-gradient-to-r from-cyan-500 to-emerald-400 transition-all duration-500"
style={{ width: `${kpi?.mappedPercent || 0}%` }}
/>
</div>
<p className="text-[11px] text-slate-400 mt-1 font-mono">
ยังไม่ผูก: <span className="text-rose-400 font-bold">{kpi?.unmappedSkus || 0}</span> รายการ
</p>
</div>
</div>
{/* Total Sold Units */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้รวม</p>
<ShoppingBag className="w-4 h-4 text-orange-400" />
</div>
<div>
<p className="text-2xl font-bold text-orange-400 font-mono">
{fmtNum(kpi?.totalSoldQty || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span>
</p>
<p className="text-xs text-slate-400 mt-1">
ยอดขายรวมสะสมจากทุกออเดอร์
</p>
</div>
</div>
{/* Total Revenue */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวม (Gross)</p>
<DollarSign className="w-4 h-4 text-emerald-400" />
</div>
<div>
<p className="text-2xl font-bold text-emerald-400 font-mono">
{fmtBaht(kpi?.totalRevenue || 0)}
</p>
<p className="text-xs text-slate-400 mt-1">
ยอดเงินที่ได้รับจากลูกค้า
</p>
</div>
</div>
{/* Est. Gross Profit (From Linked BOM) */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">กำไรขั้นต้นประเมิน (BOM)</p>
<TrendingUp className="w-4 h-4 text-amber-400" />
</div>
<div>
<p className="text-2xl font-bold text-amber-400 font-mono">
{fmtBaht(kpi?.totalEstProfit || 0)}
</p>
<p className="text-xs text-slate-400 mt-1 font-mono">
Margin รวม: <span className="text-emerald-400 font-bold">{kpi?.grossProfitMargin?.toFixed(1)}%</span>
</p>
</div>
</div>
</div>
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
{/* Channel Tabs */}
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
<button
onClick={() => {
setChannel('all');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
}`}
>
🌐 ทุกช่องทาง
</button>
<button
onClick={() => {
setChannel('tiktok');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
channel === 'tiktok'
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
: 'text-slate-400 hover:text-rose-400'
}`}
>
🔴 TikTok
</button>
<button
onClick={() => {
setChannel('shopee');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
channel === 'shopee'
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
: 'text-slate-400 hover:text-orange-400'
}`}
>
🟠 Shopee
</button>
<button
onClick={() => {
setChannel('lazada');
setPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
channel === 'lazada'
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
: 'text-slate-400 hover:text-blue-400'
}`}
>
🔵 Lazada
</button>
</div>
{/* Shop Selector */}
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-400">ร้านค้า:</span>
<select
value={shop}
onChange={(e) => {
setShop(e.target.value);
setPage(1);
}}
className="px-2.5 py-1.5 rounded-xl bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
>
<option value="all">ทุกร้านค้า (All Shops)</option>
{channel === 'tiktok' &&
TIKTOK_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
{channel === 'shopee' &&
SHOPEE_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
{channel === 'lazada' &&
LAZADA_SHOPS.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
{channel === 'all' && (
<>
<optgroup label="TikTok Shops">
{TIKTOK_SHOPS.map((s) => (
<option key={`tt-${s.id}`} value={s.id}>
{s.label}
</option>
))}
</optgroup>
<optgroup label="Shopee Shops">
{SHOPEE_SHOPS.map((s) => (
<option key={`sp-${s.id}`} value={s.id}>
{s.label}
</option>
))}
</optgroup>
<optgroup label="Lazada Shops">
{LAZADA_SHOPS.map((s) => (
<option key={`lz-${s.id}`} value={s.id}>
{s.label}
</option>
))}
</optgroup>
</>
)}
</select>
</div>
{/* Status Filter */}
<div className="flex items-center gap-1.5">
<button
onClick={() => {
setStatus('all');
setPage(1);
}}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
status === 'all'
? 'bg-white/15 text-white border-white/20'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`}
>
ทั้งหมด
</button>
<button
onClick={() => {
setStatus('mapped');
setPage(1);
}}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
status === 'mapped'
? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-cyan-400'
}`}
>
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1 text-cyan-400" />
ผูกแล้ว (Mapped)
</button>
<button
onClick={() => {
setStatus('unmapped');
setPage(1);
}}
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
status === 'unmapped'
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
}`}
>
<AlertCircle className="w-3.5 h-3.5 inline mr-1 text-rose-400" />
ยังไม่ผูก (Unmapped)
</button>
</div>
</div>
{/* Secondary: Search & Sort */}
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-white/5">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="ค้นหาชื่อสินค้า, SKU, ตัวเลือก, หรือ Master Variant..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40"
/>
</div>
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-2">
<span className="text-slate-400">เรียงตาม:</span>
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value);
setPage(1);
}}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
>
<option value="revenue">ยอดขายรวมสูงสุด (Revenue)</option>
<option value="sold">จำนวนชิ้นขายสูงสุด (Sold Qty)</option>
<option value="name">ชื่อสินค้า (A-Z)</option>
<option value="recent">ขายล่าสุด (Recent)</option>
</select>
</div>
</div>
</div>
</div>
{/* ── Marketplace Products Table ────────────────────────────────────────── */}
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm shadow-xl">
<div className="overflow-x-auto">
<table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase tracking-wider">
<tr>
<th className="px-4 py-3.5">ช่องทาง / ร้านค้า</th>
<th className="px-4 py-3.5">ชื่อสินค้า & ตัวเลือก (Marketplace)</th>
<th className="px-4 py-3.5">Seller SKU</th>
<th className="px-4 py-3.5 text-right">จำนวนที่ขาย</th>
<th className="px-4 py-3.5 text-right">ยอดขายรวม (฿)</th>
<th className="px-4 py-3.5 text-right">ราคาเฉลี่ย</th>
<th className="px-4 py-3.5">สินค้า Master (Core Catalog)</th>
<th className="px-4 py-3.5 text-right">ต้นทุน BOM (฿)</th>
<th className="px-4 py-3.5 text-right">กำไรประเมิน</th>
<th className="px-4 py-3.5 text-center">จัดการ</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={10} className="px-4 py-16 text-center text-slate-400">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-amber-400" />
กำลังโหลดข้อมูลสินค้า...
</td>
</tr>
) : items.length === 0 ? (
<tr>
<td colSpan={10} className="px-4 py-16 text-center text-slate-400 space-y-2">
<p>ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก</p>
<button
onClick={handleSyncAll}
className="px-4 py-2 rounded-xl bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-bold hover:bg-amber-500/30 transition-all"
>
กดเพื่อ Sync ข้อมูลจากออเดอร์
</button>
</td>
</tr>
) : (
items.map((item: any) => {
const hasMapping = !!item.masterVariantId && !!item.masterVariant;
const profit = item.profitability;
return (
<tr key={item.id} className="hover:bg-white/[0.03] transition-colors">
{/* Platform & Shop */}
<td className="px-4 py-3.5">
<div className="space-y-1">
{item.channel === 'tiktok' ? (
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">
TikTok
</span>
) : item.channel === 'shopee' ? (
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-orange-500/20 text-orange-300 border border-orange-500/30">
Shopee
</span>
) : (
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30">
Lazada
</span>
)}
<p className="text-[10px] font-mono text-slate-400">{item.shop}</p>
</div>
</td>
{/* Product Name & Variation */}
<td className="px-4 py-3.5 max-w-xs">
<p className="font-bold text-white line-clamp-2" title={item.productName}>
{item.productName}
</p>
{item.variation && (
<span className="inline-block mt-1 px-2 py-0.5 rounded text-[10px] bg-white/5 text-amber-300 border border-white/10">
ตัวเลือก: {item.variation}
</span>
)}
</td>
{/* Seller SKU */}
<td className="px-4 py-3.5 font-mono text-slate-300">
{item.marketplaceSku ? (
<span className="bg-black/30 px-2 py-1 rounded border border-white/5 text-[11px]">
{item.marketplaceSku}
</span>
) : (
<span className="text-slate-600">-</span>
)}
</td>
{/* Sold Qty */}
<td className="px-4 py-3.5 text-right font-mono font-bold text-white">
{fmtNum(item.totalSoldQty)} <span className="text-[10px] font-normal text-slate-500">ชิ้น</span>
</td>
{/* Total Revenue */}
<td className="px-4 py-3.5 text-right font-mono font-bold text-emerald-400">
{fmtBaht(item.totalRevenue)}
</td>
{/* Avg Price */}
<td className="px-4 py-3.5 text-right font-mono text-slate-300">
{fmtBaht(item.avgPrice)}
</td>
{/* Master Core Catalog Mapping */}
<td className="px-4 py-3.5">
{hasMapping ? (
<div className="space-y-0.5">
<div className="flex items-center gap-1.5 text-cyan-300 font-bold">
<CheckCircle2 className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
<span className="truncate max-w-[200px]" title={item.masterVariant?.productName}>
{item.masterVariant?.productName}
</span>
</div>
<div className="flex items-center gap-1 text-[10px] text-slate-400 font-mono">
<span>SKU: {item.masterVariant?.sku || '-'}</span>
{item.masterVariant?.option1 && <span>• {item.masterVariant.option1}</span>}
</div>
</div>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-rose-500/10 text-rose-300 border border-rose-500/20">
<AlertCircle className="w-3 h-3 text-rose-400" /> ยังไม่ผูก SKU
</span>
)}
</td>
{/* BOM Cost */}
<td className="px-4 py-3.5 text-right font-mono">
{hasMapping && profit?.unitBomCost > 0 ? (
<div>
<span className="text-slate-300">{fmtBaht(profit.unitBomCost)}/ชิ้น</span>
<p className="text-[10px] text-slate-500">รวม {fmtBaht(profit.totalCost)}</p>
</div>
) : hasMapping ? (
<span className="text-slate-500 text-[10px]">ยังไม่ตั้ง BOM</span>
) : (
<span className="text-slate-600">-</span>
)}
</td>
{/* Estimated Profit */}
<td className="px-4 py-3.5 text-right font-mono">
{hasMapping && profit ? (
<div>
<span className="font-bold text-amber-400">{fmtBaht(profit.profit)}</span>
<p className="text-[10px] text-emerald-400 font-bold">{profit.marginPercent.toFixed(1)}% margin</p>
</div>
) : (
<span className="text-slate-600">-</span>
)}
</td>
{/* Actions */}
<td className="px-4 py-3.5 text-center">
<button
onClick={() => {
setMappingModalItem(item);
setSelectedVariantId(item.masterVariantId);
setVariantSearch('');
}}
className="px-2.5 py-1.5 rounded-xl bg-white/5 hover:bg-amber-500/20 hover:text-amber-300 text-slate-300 border border-white/10 text-xs font-semibold transition-all inline-flex items-center gap-1.5"
>
<Link2 className="w-3.5 h-3.5" />
<span>{hasMapping ? 'เปลี่ยนผูก' : 'ผูกสินค้า'}</span>
</button>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination Footer */}
{pagination && pagination.totalPages > 1 && (
<div className="p-4 border-t border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.01]">
<p className="text-xs text-slate-400">
แสดงหน้าที่ <strong className="text-white">{pagination.page}</strong> จาก{' '}
<strong className="text-white">{pagination.totalPages}</strong> (ทั้งหมด {fmtNum(pagination.total)} รายการ)
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(p - 1, 1))}
disabled={pagination.page <= 1}
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
>
<ChevronLeft className="w-4 h-4" /> ก่อนหน้า
</button>
<button
onClick={() => setPage((p) => Math.min(p + 1, pagination.totalPages))}
disabled={pagination.page >= pagination.totalPages}
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
>
ถัดไป <ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
{/* ── Mapping Modal ────────────────────────────────────────────────────── */}
{mappingModalItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
{/* Modal 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-xl bg-amber-500/20 border border-amber-500/30 flex items-center justify-center">
<Link2 className="w-5 h-5 text-amber-400" />
</div>
<div>
<h3 className="font-bold text-white text-base">ผูกสินค้า Marketplace เข้า Master Catalog</h3>
<p className="text-xs text-slate-400">เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้</p>
</div>
</div>
<button
onClick={() => setMappingModalItem(null)}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Target Marketplace Item Summary */}
<div className="p-4 mx-6 mt-4 bg-white/5 border border-white/10 rounded-xl space-y-1.5 text-xs">
<div className="flex items-center gap-2">
<span className="text-slate-400">สินค้าบน {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}):</span>
{mappingModalItem.marketplaceSku && (
<span className="px-2 py-0.2 rounded bg-black/40 text-amber-300 font-mono">
SKU: {mappingModalItem.marketplaceSku}
</span>
)}
</div>
<p className="font-bold text-white text-sm">{mappingModalItem.productName}</p>
{mappingModalItem.variation && (
<p className="text-amber-400">ตัวเลือก: {mappingModalItem.variation}</p>
)}
<div className="flex items-center gap-4 text-slate-400 font-mono pt-1">
<span>ขายแล้ว: <strong className="text-white">{fmtNum(mappingModalItem.totalSoldQty)}</strong> ชิ้น</span>
<span>ยอดขาย: <strong className="text-emerald-400">{fmtBaht(mappingModalItem.totalRevenue)}</strong></span>
<span>ราคาเฉลี่ย: <strong className="text-amber-400">{fmtBaht(mappingModalItem.avgPrice)}</strong></span>
</div>
</div>
{/* Search & Variants List */}
<div className="p-6 flex-1 overflow-hidden flex flex-col space-y-3">
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="พิมพ์ค้นหาชื่อสินค้าหลัก, รหัส SKU, หรือขนาด (เช่น 50ml, 120ml)..."
value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40"
autoFocus
/>
</div>
<div className="flex-1 overflow-y-auto space-y-2 pr-1 max-h-[300px]">
{filteredMasterVariants.length === 0 ? (
<div className="py-8 text-center text-slate-500 text-xs">
ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา
</div>
) : (
filteredMasterVariants.map((variant) => {
const isSelected = selectedVariantId === variant.id;
return (
<div
key={variant.id}
onClick={() => setSelectedVariantId(variant.id)}
className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-3 ${
isSelected
? 'bg-amber-500/20 border-amber-500/40 shadow-sm'
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
}`}
>
<div className="space-y-0.5">
<p className="text-xs font-bold text-white">{variant.productName}</p>
<div className="flex items-center gap-2 text-[11px] text-slate-400 flex-wrap">
<span className="font-mono text-cyan-300">SKU: {variant.sku || '-'}</span>
{variant.optionsStr && <span className="text-amber-300">• {variant.optionsStr}</span>}
{variant.categoryName && <span className="text-slate-500">• {variant.categoryName}</span>}
</div>
</div>
<div className="text-right shrink-0 font-mono text-xs">
<p className="text-emerald-400 font-bold">{fmtBaht(variant.price)}</p>
<p className="text-[10px] text-slate-400">
ต้นทุน BOM: <span className="text-amber-300">{fmtBaht(variant.bomCost)}</span>
</p>
</div>
</div>
);
})
)}
</div>
</div>
{/* Modal Footer */}
<div className="p-4 px-6 border-t border-white/10 flex items-center justify-between bg-white/[0.02]">
{mappingModalItem.masterVariantId ? (
<button
onClick={() => handleSaveMapping(mappingModalItem.id, null)}
disabled={savingMapping}
className="px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-bold transition-all flex items-center gap-1.5"
>
<Unlink className="w-4 h-4" /> ยกเลิกการผูก
</button>
) : <div />}
<div className="flex items-center gap-2">
<button
onClick={() => setMappingModalItem(null)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-bold transition-all"
>
ปิด
</button>
<button
onClick={() => handleSaveMapping(mappingModalItem.id, selectedVariantId)}
disabled={savingMapping || !selectedVariantId}
className="px-5 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50 flex items-center gap-1.5"
>
{savingMapping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
<span>บันทึกการผูกสินค้า</span>
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "MarketplaceProduct" (
"id" SERIAL NOT NULL,
"channel" TEXT NOT NULL,
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
"marketplaceSku" TEXT NOT NULL DEFAULT '',
"productName" TEXT NOT NULL,
"variation" TEXT NOT NULL DEFAULT '',
"avgPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
"totalSoldQty" INTEGER NOT NULL DEFAULT 0,
"totalRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
"firstSoldAt" TEXT,
"lastSoldAt" TEXT,
"masterVariantId" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MarketplaceProduct_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MarketplaceProduct_channel_idx" ON "MarketplaceProduct"("channel");
-- CreateIndex
CREATE INDEX "MarketplaceProduct_shop_idx" ON "MarketplaceProduct"("shop");
-- CreateIndex
CREATE INDEX "MarketplaceProduct_marketplaceSku_idx" ON "MarketplaceProduct"("marketplaceSku");
-- CreateIndex
CREATE INDEX "MarketplaceProduct_masterVariantId_idx" ON "MarketplaceProduct"("masterVariantId");
-- CreateIndex
CREATE INDEX "MarketplaceProduct_totalRevenue_idx" ON "MarketplaceProduct"("totalRevenue");
-- CreateIndex
CREATE UNIQUE INDEX "MarketplaceProduct_channel_shop_productName_variation_marke_key" ON "MarketplaceProduct"("channel", "shop", "productName", "variation", "marketplaceSku");
-- AddForeignKey
ALTER TABLE "MarketplaceProduct" ADD CONSTRAINT "MarketplaceProduct_masterVariantId_fkey" FOREIGN KEY ("masterVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+75
View File
@@ -112,6 +112,7 @@ model ProductVariant {
productId Int productId Int
product Product @relation(fields: [productId], references: [id]) product Product @relation(fields: [productId], references: [id])
bomItems BomItem[] bomItems BomItem[]
marketplaceProducts MarketplaceProduct[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -497,6 +498,12 @@ model TiktokOrder {
shippingFee Float @default(0) // Shipping Fee After Discount shippingFee Float @default(0) // Shipping Fee After Discount
originalShipping Float @default(0) originalShipping Float @default(0)
taxes Float @default(0) taxes Float @default(0)
// Settlement Financials
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok
affiliateFee Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
netSettlement Float @default(0) // ยอดเงินโอนจริงสุทธิ
settledAt String? // วันที่โอนเงิน
isSettled Boolean @default(false)
// Geography // Geography
province String? province String?
district String? district String?
@@ -537,6 +544,44 @@ model TiktokOrderItem {
weight Float @default(0) weight Float @default(0)
} }
model TiktokSettlement {
id Int @id @default(autoincrement())
orderId String @unique // หมายเลขคำสั่งซื้อ/การปรับ
shop String @default("hom-aroma")
transactionType String? // ประเภทธุรกรรม (คำสั่งซื้อ / การปรับยอด)
orderCreatedTime String? // เวลาที่สร้างคำสั่งซื้อ
orderSettledTime String? // เวลาที่ชำระคำสั่งซื้อ / วันที่โอนเงิน
currency String @default("THB")
// Financial breakdowns
settlementAmount Float @default(0) // ยอดการชำระเงินทั้งหมด (Net Payout)
grossRevenue Float @default(0) // รายได้ทั้งหมด
subtotalAfterSellerDiscount Float @default(0) // ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย
subtotalBeforeDiscount Float @default(0) // ยอดรวมค่าสินค้าก่อนหักส่วนลด
sellerDiscount Float @default(0) // ส่วนลดจากร้านค้า
refundAmount Float @default(0) // ยอดรวมเงินคืน
// Fees
totalFee Float @default(0) // ค่าธรรมเนียมทั้งหมด
orderFee Float @default(0) // ค่าธรรมเนียมคำสั่งซื้อ
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok Shop
affiliateCommission Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
affiliatePartnerFee Float @default(0) // ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต
growthSupportFee Float @default(0) // ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า
shippingFeeActual Float @default(0) // ค่าธรรมเนียมการจัดส่งจริง
platformShippingDiscount Float @default(0) // ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม
customerPayment Float @default(0) // การชำระเงินของลูกค้า
rawData Json? // Full raw row data for audit
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([shop])
@@index([orderSettledTime])
@@index([orderId])
}
// ── Lazada Orders ────────────────────────────────────────────────────────────── // ── Lazada Orders ──────────────────────────────────────────────────────────────
model LazadaOrder { model LazadaOrder {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -715,3 +760,33 @@ model ContactMessage {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
// ── Marketplace Products & SKU Mapping ──────────────────────────────────────────
model MarketplaceProduct {
id Int @id @default(autoincrement())
channel String // "tiktok" | "shopee" | "lazada"
shop String @default("hom-aroma")
marketplaceSku String @default("") // sellerSku / skuRef / skuId
productName String // Item name on marketplace
variation String @default("") // Variation / flavor / size
avgPrice Float @default(0)
totalSoldQty Int @default(0)
totalRevenue Float @default(0)
firstSoldAt String?
lastSoldAt String?
// Link to Master Core Catalog
masterVariantId Int?
masterVariant ProductVariant? @relation(fields: [masterVariantId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([channel, shop, productName, variation, marketplaceSku])
@@index([channel])
@@index([shop])
@@index([marketplaceSku])
@@index([masterVariantId])
@@index([totalRevenue])
}
+3 -1
View File
@@ -29,6 +29,8 @@ import { mediaRoute } from "./routes/media";
import { contactsRoute } from "./routes/contacts"; import { contactsRoute } from "./routes/contacts";
import { productAttributesRoute } from "./routes/product-attributes"; import { productAttributesRoute } from "./routes/product-attributes";
import { backupRoute } from "./routes/backup"; import { backupRoute } from "./routes/backup";
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
import { toolsRoute } from "./routes/tools";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051; const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
@@ -136,7 +138,7 @@ const app = new Elysia()
return file; return file;
}) })
.group("/api", (app) => .group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute) app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
) )
.listen({ port: PORT, hostname: "0.0.0.0" }); .listen({ port: PORT, hostname: "0.0.0.0" });
File diff suppressed because it is too large. Load diff
+216
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import prisma from "../lib/prisma"; import prisma from "../lib/prisma";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => { const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0; if (val === null || val === undefined || val === "") return 0;
@@ -8,6 +9,13 @@ const toNum = (val: any): number => {
return isNaN(n) ? 0 : n; return isNaN(n) ? 0 : n;
}; };
function extractShopeeAddress(order: any): { province: string; district: string } {
const raw = (order.items?.[0]?.rawData as any) || {};
const province = String(raw["จังหวัด"] || raw["Province"] || raw["City"] || "").trim();
const district = String(raw["เขต/อำเภอ"] || raw["อำเภอ/เขต"] || raw["อำเภอ"] || raw["District"] || "").trim();
return { province, district };
}
export const shopeeRoute = new Elysia({ prefix: "/shopee" }) export const shopeeRoute = new Elysia({ prefix: "/shopee" })
// ── Import Orders ───────────────────────────────────────────────────────── // ── Import Orders ─────────────────────────────────────────────────────────
.post( .post(
@@ -340,4 +348,212 @@ export const shopeeRoute = new Elysia({ prefix: "/shopee" })
totalItems: itemAgg._sum.quantity ?? 0, totalItems: itemAgg._sum.quantity ?? 0,
totalItemRevenue: itemAgg._sum.totalPayment ?? 0, totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
}; };
})
// ── GET Thailand Sales Map (Shopee 77 Provinces & Top 20) ─────────────────
.get("/sales-map", async ({ query }) => {
const shop = (query.shop as string) || "hom-aroma";
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const where: any = {};
if (shop && shop !== "all") {
where.shop = shop;
}
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const orders = await prisma.shopeeOrder.findMany({
where,
include: {
items: {
select: {
rawData: true,
},
take: 1,
},
},
});
const provSalesMap = new Map<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: number }>();
for (const p of THAILAND_PROVINCES) {
provSalesMap.set(p.nameTh, {
code: p.code,
nameTh: p.nameTh,
nameEn: p.nameEn,
region: p.region,
regionTh: p.regionTh,
revenue: 0,
orders: 0,
});
}
let totalRevenue = 0;
let totalOrders = orders.length;
let unmappedOrders = 0;
let unmappedRevenue = 0;
for (const o of orders) {
totalRevenue += o.totalAmount;
const { province } = extractShopeeAddress(o);
const matched = findProvince(province);
if (matched) {
const p = provSalesMap.get(matched.nameTh)!;
p.revenue += o.totalAmount;
p.orders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += o.totalAmount;
}
}
const provinceList = Array.from(provSalesMap.values());
const activeProvinces = provinceList.filter(p => p.orders > 0);
const coverageCount = activeProvinces.length;
// Top 20 provinces by revenue
const topProvinces = [...provinceList]
.sort((a, b) => b.revenue - a.revenue)
.slice(0, 20)
.map((p, idx) => ({
rank: idx + 1,
...p,
aov: p.orders > 0 ? p.revenue / p.orders : 0,
}));
// Region Breakdown
const regionMap = new Map<string, { region: string; regionTh: string; revenue: number; orders: number }>();
for (const p of provinceList) {
if (!regionMap.has(p.region)) {
regionMap.set(p.region, { region: p.region, regionTh: p.regionTh, revenue: 0, orders: 0 });
}
const r = regionMap.get(p.region)!;
r.revenue += p.revenue;
r.orders += p.orders;
}
const regionBreakdown = Array.from(regionMap.values())
.map(r => ({
...r,
percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0,
}))
.sort((a, b) => b.revenue - a.revenue);
const topRegion = regionBreakdown[0] || { region: 'Central', regionTh: 'ภาคกลาง', revenue: 0, orders: 0 };
const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0;
return {
kpi: {
totalRevenue,
totalOrders,
coverageCount,
coverageTotal: 77,
coveragePercent: (coverageCount / 77) * 100,
aov,
topRegion: {
name: topRegion.regionTh,
orders: topRegion.orders,
revenue: topRegion.revenue,
},
unmappedOrders,
unmappedRevenue,
},
provinces: provinceList,
topProvinces,
regionBreakdown,
};
})
// ── GET District Level Details & Orders (Shopee) ──────────────────────────
.get("/sales-map/district", async ({ query, set }) => {
const provinceQuery = query.province as string;
if (!provinceQuery) {
set.status = 400;
return { error: "Province is required" };
}
const targetProv = findProvince(provinceQuery);
const provNameTh = targetProv ? targetProv.nameTh : provinceQuery;
const shop = query.shop as string | undefined;
const dateFrom = query.dateFrom as string | undefined;
const dateTo = query.dateTo as string | undefined;
const where: any = {};
if (shop && shop !== "all") {
where.shop = shop;
}
if (dateFrom || dateTo) {
where.orderCreatedAt = {};
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
}
const allOrders = await prisma.shopeeOrder.findMany({
where,
include: {
items: true,
},
orderBy: { orderCreatedAt: "desc" },
});
// Filter in JS by matching extracted address
const matchedOrders = allOrders.filter(o => {
const { province } = extractShopeeAddress(o);
const p = findProvince(province);
return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || ''));
});
const districtMap = new Map<string, { districtName: string; revenue: number; orders: number }>();
let provRevenue = 0;
for (const o of matchedOrders) {
provRevenue += o.totalAmount;
const { district } = extractShopeeAddress(o);
const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 });
}
const d = districtMap.get(dName)!;
d.revenue += o.totalAmount;
d.orders += 1;
}
const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue);
return {
province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: 'Central', regionTh: 'ภาคกลาง', code: '' },
summary: {
totalRevenue: provRevenue,
totalOrders: matchedOrders.length,
districtCount: districts.length,
},
districts,
orders: matchedOrders.map(o => {
const { district, province } = extractShopeeAddress(o);
return {
orderId: o.orderId,
shop: o.shop,
status: o.status,
buyerUsername: o.buyerUsername,
createdTime: o.orderCreatedAt,
orderAmount: o.totalAmount,
shippingFee: o.shippingFee,
province: province || 'ไม่ระบุจังหวัด',
district: cleanDistrictName(district) || district || 'ไม่ระบุอำเภอ',
itemsCount: o.items.reduce((s, i) => s + i.quantity, 0),
items: o.items.map(i => ({
productName: i.productName,
variation: i.variantName,
sellerSku: i.skuRef || i.parentSku,
quantity: i.quantity,
subtotalAfterDiscount: i.totalPayment,
unitOriginalPrice: i.originalPrice || i.dealPrice,
})),
};
}),
};
}); });
+552 -36
View File
@@ -26,9 +26,216 @@ const parseTiktokDate = (val: any): string => {
return d; return d;
}; };
// Helper to enrich TikTok orders with BOM & Profitability
async function enrichOrdersWithBOM(orders: any[], shop: string) {
if (!orders || orders.length === 0) return [];
const orderIds = orders.map((o) => o.orderId).filter(Boolean);
// Fetch all MarketplaceProduct mappings, master variants, and settlements for this batch
const [marketplaceProducts, masterVariants, settlements] = await Promise.all([
prisma.marketplaceProduct.findMany({
where: { channel: "tiktok", shop },
include: {
masterVariant: {
include: {
product: { select: { id: true, name: true } },
bomItems: {
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
},
},
},
},
}),
prisma.productVariant.findMany({
include: {
product: { select: { id: true, name: true } },
bomItems: {
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
},
},
}),
orderIds.length > 0
? prisma.tiktokSettlement.findMany({ where: { orderId: { in: orderIds } } })
: Promise.resolve([]),
]);
// Index Settlements
const settlementMap = new Map<string, any>();
for (const s of settlements) {
settlementMap.set(s.orderId, s);
}
// Index MarketplaceProducts
const mpExactMap = new Map<string, any>(); // shop:::productName:::variation:::sku
const mpSkuMap = new Map<string, any>(); // shop:::sku
const mpNameVarMap = new Map<string, any>(); // shop:::productName:::variation
const variantSkuMap = new Map<string, any>(); // SKU uppercase -> ProductVariant
for (const mp of marketplaceProducts) {
const s = (mp.shop || "hom-aroma").trim().toLowerCase();
const pName = (mp.productName || "").trim().toLowerCase();
const vName = (mp.variation || "").trim().toLowerCase();
const sku = (mp.marketplaceSku || "").trim().toLowerCase();
const exactKey = `${s}:::${pName}:::${vName}:::${sku}`;
mpExactMap.set(exactKey, mp);
if (sku) {
mpSkuMap.set(`${s}:::${sku}`, mp);
}
if (pName) {
mpNameVarMap.set(`${s}:::${pName}:::${vName}`, mp);
}
}
for (const v of masterVariants) {
if (v.sku) {
variantSkuMap.set(v.sku.trim().toLowerCase(), v);
}
}
return orders.map((order) => {
const oShop = (order.shop || "hom-aroma").trim().toLowerCase();
let orderTotalBomCost = 0;
let missingBomCount = 0;
const enrichedItems = (order.items || []).map((item: any) => {
const pName = (item.productName || "").trim().toLowerCase();
const vName = (item.variation || "").trim().toLowerCase();
const sSku = (item.sellerSku || item.skuId || "").trim().toLowerCase();
// Resolve Master Variant
let resolvedVariant: any = null;
let matchedVia = "";
// 1. Exact Match
const exact = mpExactMap.get(`${oShop}:::${pName}:::${vName}:::${sSku}`);
if (exact?.masterVariant) {
resolvedVariant = exact.masterVariant;
matchedVia = "marketplace_exact";
}
// 2. SKU Match in MarketplaceProduct
if (!resolvedVariant && sSku) {
const bySku = mpSkuMap.get(`${oShop}:::${sSku}`);
if (bySku?.masterVariant) {
resolvedVariant = bySku.masterVariant;
matchedVia = "marketplace_sku";
}
}
// 3. Name + Variation Match in MarketplaceProduct
if (!resolvedVariant && pName) {
const byNameVar = mpNameVarMap.get(`${oShop}:::${pName}:::${vName}`);
if (byNameVar?.masterVariant) {
resolvedVariant = byNameVar.masterVariant;
matchedVia = "marketplace_name_variation";
}
}
// 4. Direct Core Variant SKU match
if (!resolvedVariant && sSku) {
const direct = variantSkuMap.get(sSku);
if (direct) {
resolvedVariant = direct;
matchedVia = "direct_sku";
}
}
// Calculate BOM Cost
let unitBomCost = 0;
let bomItemsDetail: any[] = [];
let hasBom = false;
let hasMapping = !!resolvedVariant;
if (resolvedVariant) {
const bItems = resolvedVariant.bomItems || [];
unitBomCost = bItems.reduce((acc: number, b: any) => {
return acc + (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0);
}, 0);
bomItemsDetail = bItems.map((b: any) => ({
rawMaterialId: b.rawMaterialId,
name: b.rawMaterial?.name || "วัตถุดิบ",
unit: b.rawMaterial?.unit || "หน่วย",
pricePerUnit: b.rawMaterial?.pricePerUnit || 0,
quantity: b.quantity || 0,
lineCost: (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0),
}));
hasBom = unitBomCost > 0 && bItems.length > 0;
}
const qty = Number(item.quantity) || 1;
const totalBomCost = unitBomCost * qty;
const itemRevenue = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
const profit = itemRevenue - totalBomCost;
const marginPercent = itemRevenue > 0 ? (profit / itemRevenue) * 100 : 0;
if (!hasBom) {
missingBomCount++;
}
orderTotalBomCost += totalBomCost;
return {
...item,
unitBomCost,
totalBomCost,
profit,
marginPercent,
hasBom,
hasMapping,
matchedVia,
masterProductId: resolvedVariant?.productId || (typeof resolvedVariant?.product?.id === "number" ? resolvedVariant.product.id : null),
masterProductName: typeof resolvedVariant?.product?.name === "string" ? resolvedVariant.product.name : (resolvedVariant?.product?.name as any)?.th || (resolvedVariant?.product?.name as any)?.en || null,
masterSku: resolvedVariant?.sku || null,
masterVariantId: resolvedVariant?.id || null,
bomItemsDetail,
};
});
const settlement = settlementMap.get(order.orderId);
const rawComm = (settlement?.commissionFee ?? order.commissionFee ?? 0);
const commissionFee = Math.abs(rawComm);
const rawAff = settlement
? (settlement.affiliateCommission + settlement.affiliatePartnerFee)
: (order.affiliateFee ?? 0);
const affiliateFee = Math.abs(rawAff);
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (order.orderAmount - commissionFee - affiliateFee);
const isSettled = order.isSettled || !!settlement;
const settledAt = order.settledAt || settlement?.orderSettledTime || null;
const orderEstProfit = order.orderAmount - orderTotalBomCost;
const orderNetProfit = (isSettled && settlement ? settlement.settlementAmount : (order.orderAmount - commissionFee - affiliateFee)) - orderTotalBomCost;
const orderGrossMargin = order.orderAmount > 0 ? (orderEstProfit / order.orderAmount) * 100 : 0;
const allBomComplete = missingBomCount === 0 && enrichedItems.length > 0;
const hasIncompleteBom = missingBomCount > 0;
return {
...order,
items: enrichedItems,
settlement: settlement || null,
orderTotalBomCost,
orderEstProfit,
orderNetProfit,
commissionFee,
affiliateFee,
netSettlement,
isSettled,
settledAt,
orderGrossMargin,
missingBomCount,
allBomComplete,
hasIncompleteBom,
};
});
}
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
// ── Import (CSV rows as JSON) ────────────────────────────────────────────── // ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
.post( .post(
"/import", "/import",
async ({ body, set }) => { async ({ body, set }) => {
@@ -57,6 +264,8 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
const chunk = entries.slice(i, i + CHUNK); const chunk = entries.slice(i, i + CHUNK);
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
for (const [orderId, { meta, items }] of chunk) { for (const [orderId, { meta, items }] of chunk) {
const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } });
// Delete old items first (sync strategy) // Delete old items first (sync strategy)
await tx.tiktokOrderItem.deleteMany({ where: { orderId } }); await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
@@ -104,6 +313,11 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
shippedTime: parseTiktokDate(meta["Shipped Time"]), shippedTime: parseTiktokDate(meta["Shipped Time"]),
deliveredTime: parseTiktokDate(meta["Delivered Time"]), deliveredTime: parseTiktokDate(meta["Delivered Time"]),
cancelledTime: parseTiktokDate(meta["Cancelled Time"]), cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
commissionFee: existingSettlement ? existingSettlement.commissionFee : 0,
affiliateFee: existingSettlement ? (existingSettlement.affiliateCommission + existingSettlement.affiliatePartnerFee) : 0,
netSettlement: existingSettlement ? existingSettlement.settlementAmount : 0,
settledAt: existingSettlement ? existingSettlement.orderSettledTime : null,
isSettled: !!existingSettlement,
}; };
await tx.tiktokOrder.upsert({ await tx.tiktokOrder.upsert({
@@ -114,7 +328,6 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
upserted++; upserted++;
} }
}, { timeout: 30000 }); }, { timeout: 30000 });
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
} }
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`); console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
@@ -128,56 +341,301 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) } { body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
) )
// ── Import Settlement (Income XLSX rows as JSON) ───────────────────────────
.post(
"/import-settlement",
async ({ body, set }) => {
try {
const { rows, shop } = body as { rows: any[]; shop?: string };
const shopName = shop || "hom-aroma";
console.log(`[TiktokSettlement] Received ${rows.length} raw settlement rows for shop: ${shopName}`);
let upsertedCount = 0;
let matchedOrdersCount = 0;
let totalSettlementAmount = 0;
let totalCommission = 0;
let totalAffiliate = 0;
const CHUNK = 50;
for (let i = 0; i < rows.length; i += CHUNK) {
const chunk = rows.slice(i, i + CHUNK);
await prisma.$transaction(async (tx) => {
for (const row of chunk) {
const orderId = str(
row["หมายเลขคำสั่งซื้อ/การปรับ"] ||
row["หมายเลขคำสั่งซื้อ"] ||
row["Order ID"] ||
row["order_id"]
);
if (!orderId) continue;
const transactionType = str(row["ประเภทธุรกรรม"] || row["Transaction Type"]);
const orderCreatedTime = str(row["เวลาที่สร้างคำสั่งซื้อ"] || row["Order Created Time"]);
const orderSettledTime = str(row["เวลาที่ชำระคำสั่งซื้อ"] || row["Order Settled Time"] || row["เวลาที่สำเร็จ"]);
const currency = str(row["สกุลเงิน"] || row["Currency"] || "THB");
const settlementAmount = toNum(row["ยอดการชำระเงินทั้งหมด"] || row["Total Settlement Amount"] || row["จำนวน"]);
const grossRevenue = toNum(row["รายได้ทั้งหมด"] || row["Total Revenue"]);
const subtotalAfterSellerDiscount = toNum(row["ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย"]);
const subtotalBeforeDiscount = toNum(row["ยอดรวมค่าสินค้าก่อนหักส่วนลด"]);
const sellerDiscount = toNum(row["ส่วนลดจากร้านค้า"]);
const refundAmount = toNum(row["ยอดรวมเงินคืนหลังหักส่วนลดจากผู้ขาย"]);
const totalFee = toNum(row["ค่าธรรมเนียมทั้งหมด"]);
const orderFee = toNum(row["ค่าธรรมเนียมคำสั่งซื้อ"]);
const commissionFee = Math.abs(toNum(row["ค่าคอมมิชชั่น TikTok Shop"]));
const affiliateCommission = Math.abs(toNum(row["ค่าคอมมิชชั่นแอฟฟิลิเอต"]));
const affiliatePartnerFee = Math.abs(toNum(row["ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต"]));
const growthSupportFee = toNum(row["ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า"]);
const shippingFeeActual = toNum(row["ค่าธรรมเนียมการจัดส่งจริง"]);
const platformShippingDiscount = toNum(row["ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม"]);
const customerPayment = toNum(row["การชำระเงินของลูกค้า"]);
const totalAff = affiliateCommission + affiliatePartnerFee;
totalSettlementAmount += settlementAmount;
totalCommission += commissionFee;
totalAffiliate += totalAff;
// Upsert into TiktokSettlement
await tx.tiktokSettlement.upsert({
where: { orderId },
update: {
shop: shopName,
transactionType,
orderCreatedTime,
orderSettledTime,
currency,
settlementAmount,
grossRevenue,
subtotalAfterSellerDiscount,
subtotalBeforeDiscount,
sellerDiscount,
refundAmount,
totalFee,
orderFee,
commissionFee,
affiliateCommission,
affiliatePartnerFee,
growthSupportFee,
shippingFeeActual,
platformShippingDiscount,
customerPayment,
rawData: row,
},
create: {
orderId,
shop: shopName,
transactionType,
orderCreatedTime,
orderSettledTime,
currency,
settlementAmount,
grossRevenue,
subtotalAfterSellerDiscount,
subtotalBeforeDiscount,
sellerDiscount,
refundAmount,
totalFee,
orderFee,
commissionFee,
affiliateCommission,
affiliatePartnerFee,
growthSupportFee,
shippingFeeActual,
platformShippingDiscount,
customerPayment,
rawData: row,
},
});
upsertedCount++;
// Update corresponding TiktokOrder if it exists
const matchedOrder = await tx.tiktokOrder.findUnique({ where: { orderId } });
if (matchedOrder) {
await tx.tiktokOrder.update({
where: { orderId },
data: {
commissionFee,
affiliateFee: totalAff,
netSettlement: settlementAmount,
settledAt: orderSettledTime,
isSettled: true,
},
});
matchedOrdersCount++;
}
}
}, { timeout: 30000 });
}
console.log(`[TiktokSettlement] ✅ Done — ${upsertedCount} settlements saved, ${matchedOrdersCount} orders linked`);
return {
success: true,
message: `นำเข้าใบแจ้งยอดสำเร็จ ${upsertedCount} รายการ (เชื่อมโยงคำสั่งซื้อแล้ว ${matchedOrdersCount} ออเดอร์)`,
settlementsCount: upsertedCount,
matchedOrdersCount,
totalSettlementAmount,
totalCommission,
totalAffiliate,
};
} catch (error: any) {
console.error(`[TiktokSettlement] ❌ Error:`, error.message);
set.status = 500;
return { success: false, message: error.message };
}
},
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
)
// ── Clear Shop Data ─────────────────────────────────────────────────────── // ── Clear Shop Data ───────────────────────────────────────────────────────
.delete("/clear", async ({ query, set }) => { .delete("/clear", async ({ query, set }) => {
try { try {
const shop = (query.shop as string) || "hom-aroma"; const shop = (query.shop as string) || "hom-aroma";
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } }); const [orderResult, settlementResult] = await Promise.all([
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count }; prisma.tiktokOrder.deleteMany({ where: { shop } }),
prisma.tiktokSettlement.deleteMany({ where: { shop } }),
]);
return {
success: true,
message: `ลบข้อมูลร้าน ${shop} สำเร็จ (คำสั่งซื้อ: ${orderResult.count}, ใบแจ้งยอด: ${settlementResult.count})`,
count: orderResult.count + settlementResult.count,
};
} catch (error: any) { } catch (error: any) {
console.error(`[TiktokOrder] ❌ Clear Error:`, error.message);
set.status = 500; set.status = 500;
return { success: false, message: error.message }; return { success: false, message: error.message };
} }
}) })
// ── GET Single Order ────────────────────────────────────────────────────── // ── GET Single Order Detail (with BOM Breakdown & Settlement) ──────────────
.get("/orders/:orderId", async ({ params, set }) => { .get("/orders/:orderId", async ({ params, set }) => {
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } }); const order = await prisma.tiktokOrder.findUnique({
if (!order) { set.status = 404; return { error: "Order not found" }; } where: { orderId: params.orderId },
return order; include: { items: true },
});
if (!order) {
set.status = 404;
return { error: "Order not found" };
}
const [enriched] = await enrichOrdersWithBOM([order], order.shop);
return enriched || order;
}) })
// ── GET Orders (paginated) ──────────────────────────────────────────────── // ── GET Orders (paginated & searchable with BOM & Settlement) ──────────────
.get("/orders", async ({ query }) => { .get("/orders", async ({ query }) => {
const page = Number(query.page ?? 1); const page = Number(query.page ?? 1);
const limit = Number(query.limit ?? 30); const limit = Number(query.limit ?? 20);
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const shop = (query.shop as string) || "hom-aroma"; const shop = (query.shop as string) || "hom-aroma";
const where = { shop }; const search = ((query.search as string) || "").trim();
const [items, total] = await Promise.all([ const status = (query.status as string) || "";
prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }), const bomStatus = (query.bomStatus as string) || "all"; // "all" | "missing" | "complete"
const settlementStatus = (query.settlementStatus as string) || "all"; // "all" | "settled" | "pending"
const where: any = { shop };
if (search) {
where.OR = [
{ orderId: { contains: search, mode: "insensitive" as const } },
{ buyerUsername: { contains: search, mode: "insensitive" as const } },
{ trackingId: { contains: search, mode: "insensitive" as const } },
{ province: { contains: search, mode: "insensitive" as const } },
{ district: { contains: search, mode: "insensitive" as const } },
{ items: { some: { productName: { contains: search, mode: "insensitive" as const } } } },
{ items: { some: { sellerSku: { contains: search, mode: "insensitive" as const } } } },
{ items: { some: { variation: { contains: search, mode: "insensitive" as const } } } },
];
}
if (status && status !== "all") {
where.status = { contains: status, mode: "insensitive" as const };
}
if (settlementStatus === "settled") {
where.isSettled = true;
} else if (settlementStatus === "pending") {
where.isSettled = false;
}
const [rawItems, total] = await Promise.all([
prisma.tiktokOrder.findMany({
where,
include: { items: true },
orderBy: { createdTime: "desc" },
skip,
take: limit,
}),
prisma.tiktokOrder.count({ where }), prisma.tiktokOrder.count({ where }),
]); ]);
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
const items = await enrichOrdersWithBOM(rawItems, shop);
let filteredItems = items;
if (bomStatus === "missing") {
filteredItems = items.filter((o) => o.hasIncompleteBom);
} else if (bomStatus === "complete") {
filteredItems = items.filter((o) => o.allBomComplete);
}
return { items: filteredItems, total, page, limit, totalPages: Math.ceil(total / limit) };
}) })
// ── GET Summary ─────────────────────────────────────────────────────────── // ── GET Summary (With BOM Cost, Settlement & Profit Breakdown) ─────────────
.get("/summary", async ({ query }) => { .get("/summary", async ({ query }) => {
const shop = (query.shop as string) || "hom-aroma"; const shop = (query.shop as string) || "hom-aroma";
const where = { shop }; const where = { shop };
const [orderCount, agg, itemAgg] = await Promise.all([ const [orderCount, agg, itemAgg, allOrders, settlementAgg] = await Promise.all([
prisma.tiktokOrder.count({ where }), prisma.tiktokOrder.count({ where }),
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }), prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, commissionFee: true, affiliateFee: true, netSettlement: true } }),
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }), prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
prisma.tiktokOrder.findMany({ where, include: { items: true } }),
prisma.tiktokSettlement.aggregate({ where: { shop }, _sum: { settlementAmount: true, commissionFee: true, affiliateCommission: true, affiliatePartnerFee: true, orderFee: true, totalFee: true } }),
]); ]);
const enrichedOrders = await enrichOrdersWithBOM(allOrders, shop);
let totalBOMCost = 0;
let ordersMissingBomCount = 0;
let ordersWithBOMCount = 0;
let totalNetProfit = 0;
for (const o of enrichedOrders) {
totalBOMCost += o.orderTotalBomCost || 0;
totalNetProfit += o.orderNetProfit || 0;
if (o.hasIncompleteBom) {
ordersMissingBomCount++;
} else {
ordersWithBOMCount++;
}
}
const totalAmount = agg._sum.orderAmount ?? 0;
const totalEstProfit = totalAmount - totalBOMCost;
const grossProfitMargin = totalAmount > 0 ? (totalEstProfit / totalAmount) * 100 : 0;
const totalCommission = settlementAgg._sum.commissionFee ?? (agg._sum.commissionFee ?? 0);
const totalAffiliate = ((settlementAgg._sum.affiliateCommission ?? 0) + (settlementAgg._sum.affiliatePartnerFee ?? 0)) || (agg._sum.affiliateFee ?? 0);
const totalSettlementAmount = settlementAgg._sum.settlementAmount ?? (agg._sum.netSettlement ?? 0);
return { return {
orderCount, orderCount,
totalAmount: agg._sum.orderAmount ?? 0, totalAmount,
totalShipping: agg._sum.shippingFee ?? 0, totalShipping: agg._sum.shippingFee ?? 0,
totalRefund: agg._sum.refundAmount ?? 0, totalRefund: agg._sum.refundAmount ?? 0,
totalTaxes: agg._sum.taxes ?? 0, totalTaxes: agg._sum.taxes ?? 0,
totalItems: itemAgg._sum.quantity ?? 0, totalItems: itemAgg._sum.quantity ?? 0,
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0, totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
totalBOMCost,
totalEstProfit,
grossProfitMargin,
ordersMissingBomCount,
ordersWithBOMCount,
totalCommission,
totalAffiliate,
totalSettlementAmount,
totalNetProfit,
}; };
}) })
@@ -194,28 +652,63 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
if (dateTo) where.createdTime.lte = dateTo + " 23:59"; if (dateTo) where.createdTime.lte = dateTo + " 23:59";
} }
const allOrders = await prisma.tiktokOrder.findMany({ const rawOrders = await prisma.tiktokOrder.findMany({
where, where,
select: { include: {
orderId: true, status: true, subStatus: true, createdTime: true, items: true,
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
paymentMethod: true, province: true,
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
}, },
orderBy: { createdTime: "asc" },
}); });
// Enrich with BOM & Settlement
const allOrders = await enrichOrdersWithBOM(rawOrders, shop);
// Monthly aggregation // Monthly aggregation
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>(); const monthMap = new Map<string, {
month: string;
revenue: number;
orders: number;
cancelled: number;
items: number;
platformDiscount: number;
sellerDiscount: number;
commission: number;
affiliate: number;
bomCost: number;
netProfit: number;
settlement: number;
}>();
for (const o of allOrders) { for (const o of allOrders) {
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown"; const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 }); if (!monthMap.has(month)) {
monthMap.set(month, {
month,
revenue: 0,
orders: 0,
cancelled: 0,
items: 0,
platformDiscount: 0,
sellerDiscount: 0,
commission: 0,
affiliate: 0,
bomCost: 0,
netProfit: 0,
settlement: 0,
});
}
const m = monthMap.get(month)!; const m = monthMap.get(month)!;
m.orders += 1; m.orders += 1;
m.revenue += o.orderAmount; m.revenue += o.orderAmount;
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0); m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0);
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0); m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0);
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1; m.commission += (o.commissionFee || 0);
m.items += o.items.reduce((s, i) => s + i.quantity, 0); m.affiliate += (o.affiliateFee || 0);
m.bomCost += (o.orderTotalBomCost || 0);
m.netProfit += (o.orderNetProfit || 0);
m.settlement += (o.netSettlement || 0);
if (o.status?.includes("ยกเลิก") || o.status?.includes("cancel")) m.cancelled += 1;
m.items += (o.items || []).reduce((s: number, i: any) => s + (i.quantity || 0), 0);
} }
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)); const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
@@ -257,20 +750,43 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0); const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0); const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0); const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0); const totalPlatformDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.platformDiscount || 0), 0);
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0); const totalSellerDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.sellerDiscount || 0), 0);
const totalCommission = allOrders.reduce((s, o) => s + (o.commissionFee || 0), 0);
const totalAffiliate = allOrders.reduce((s, o) => s + (o.affiliateFee || 0), 0);
const totalSettlementAmount = allOrders.reduce((s, o) => s + (o.netSettlement || 0), 0);
const totalBOMCost = allOrders.reduce((s, o) => s + (o.orderTotalBomCost || 0), 0);
const totalNetProfit = allOrders.reduce((s, o) => s + (o.orderNetProfit || 0), 0);
const netProfitMargin = totalRevenue > 0 ? (totalNetProfit / totalRevenue) * 100 : 0;
const totalOrders = allOrders.length; const totalOrders = allOrders.length;
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length; const cancelledOrders = allOrders.filter(o => o.status?.includes("ยกเลิก")).length;
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0; const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
const completedOrders = totalOrders - cancelledOrders; const completedOrders = totalOrders - cancelledOrders;
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0; const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0;
return { return {
monthly, statusBreakdown, paymentBreakdown, topProducts, monthly,
statusBreakdown,
paymentBreakdown,
topProducts,
summary: { summary: {
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc, totalRevenue,
totalShipping,
totalRefund,
totalPlatformDisc,
totalSellerDisc,
netRevenue: totalRevenue - totalShipping, netRevenue: totalRevenue - totalShipping,
totalOrders, completedOrders, cancelledOrders, cancelRate, aov, totalOrders,
completedOrders,
cancelledOrders,
cancelRate,
aov,
totalCommission,
totalAffiliate,
totalSettlementAmount,
totalBOMCost,
totalNetProfit,
netProfitMargin,
}, },
}; };
}) })
+509
View File
@@ -0,0 +1,509 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const toolsRoute = new Elysia({ prefix: "/tools" })
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
.get("/marketplace-products/master-variants", async () => {
const variants = await prisma.productVariant.findMany({
include: {
product: {
select: {
id: true,
name: true,
category: { select: { name: true } },
brand: { select: { name: true } },
},
},
bomItems: {
include: {
rawMaterial: {
select: { pricePerUnit: true, name: true, unit: true },
},
},
},
},
orderBy: [
{ product: { name: "asc" } },
{ sku: "asc" },
],
});
const result = variants.map((v) => {
// Calculate unit BOM cost
const bomCost = (v.bomItems || []).reduce((sum, b) => {
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
}, 0);
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
const displayName = `${v.product?.name || "Unnamed Product"} ${optionsStr ? `(${optionsStr})` : ""} ${v.sku ? `[${v.sku}]` : ""}`.trim();
return {
id: v.id,
sku: v.sku || "",
barcode: v.barcode || "",
price: v.price || 0,
stock: v.stock || 0,
optionsStr,
productName: v.product?.name || "",
categoryName: v.product?.category?.name || "",
brandName: v.product?.brand?.name || "",
bomCost,
displayName,
};
});
return { variants: result };
})
// ── 2. POST Sync & Extract Marketplace Products ──────────────────────────────
.post("/marketplace-products/sync", async () => {
// A. Fetch all existing variants for auto-matching
const masterVariants = await prisma.productVariant.findMany({
select: { id: true, sku: true, barcode: true },
});
const skuToVariantId = new Map<string, number>();
for (const v of masterVariants) {
if (v.sku?.trim()) skuToVariantId.set(v.sku.trim().toLowerCase(), v.id);
if (v.barcode?.trim()) skuToVariantId.set(v.barcode.trim().toLowerCase(), v.id);
}
// Existing marketplace products map to preserve manual mappings
const existingMp = await prisma.marketplaceProduct.findMany({
select: {
id: true,
channel: true,
shop: true,
productName: true,
variation: true,
marketplaceSku: true,
masterVariantId: true,
},
});
const existingMap = new Map<string, { id: number; masterVariantId: number | null }>();
for (const ep of existingMp) {
const key = `${ep.channel}:::${ep.shop}:::${ep.productName}:::${ep.variation}:::${ep.marketplaceSku}`;
existingMap.set(key, { id: ep.id, masterVariantId: ep.masterVariantId });
}
// In-memory aggregation map
interface AggregatedItem {
channel: string;
shop: string;
marketplaceSku: string;
productName: string;
variation: string;
totalSoldQty: number;
totalRevenue: number;
firstSoldAt: string | null;
lastSoldAt: string | null;
}
const aggregated = new Map<string, AggregatedItem>();
const updateAgg = (
channel: string,
shop: string,
sku: string,
name: string,
variation: string,
qty: number,
rev: number,
time: string | null
) => {
const cleanSku = (sku || "").trim();
const cleanName = (name || "").trim() || "ไม่ระบุชื่อสินค้า";
const cleanVar = (variation || "").trim();
const cleanShop = (shop || "").trim() || "hom-aroma";
const key = `${channel}:::${cleanShop}:::${cleanName}:::${cleanVar}:::${cleanSku}`;
let item = aggregated.get(key);
if (!item) {
item = {
channel,
shop: cleanShop,
marketplaceSku: cleanSku,
productName: cleanName,
variation: cleanVar,
totalSoldQty: 0,
totalRevenue: 0,
firstSoldAt: time,
lastSoldAt: time,
};
aggregated.set(key, item);
}
item.totalSoldQty += qty;
item.totalRevenue += rev;
if (time) {
if (!item.firstSoldAt || time < item.firstSoldAt) item.firstSoldAt = time;
if (!item.lastSoldAt || time > item.lastSoldAt) item.lastSoldAt = time;
}
};
// 1. Scan TikTok Order Items
const tiktokItems = await prisma.tiktokOrderItem.findMany({
include: {
order: { select: { shop: true, createdTime: true } },
},
});
for (const it of tiktokItems) {
const shop = it.order?.shop || "hom-aroma";
const sku = it.sellerSku || it.skuId || "";
const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : (it.unitOriginalPrice * (it.quantity || 1));
updateAgg("tiktok", shop, sku, it.productName, it.variation || "", it.quantity || 1, rev, it.order?.createdTime || null);
}
// 2. Scan Shopee Order Items
const shopeeItems = await prisma.shopeeOrderItem.findMany({
include: {
order: { select: { shop: true, orderCreatedAt: true } },
},
});
for (const it of shopeeItems) {
const shop = it.order?.shop || "hom-aroma";
const sku = it.skuRef || it.parentSku || "";
const rev = it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * (it.quantity || 1));
updateAgg("shopee", shop, sku, it.productName, it.variantName || "", it.quantity || 1, rev, it.order?.orderCreatedAt || null);
}
// 3. Scan Lazada Order Items
const lazadaItems = await prisma.lazadaOrderItem.findMany({
include: {
order: { select: { shop: true, createTime: true } },
},
});
for (const it of lazadaItems) {
const shop = it.order?.shop || "hom-aroma";
const sku = it.sellerSku || it.lazadaSku || "";
const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice;
updateAgg("lazada", shop, sku, it.itemName, it.variation || "", 1, rev, it.order?.createTime || null);
}
// Upsert into Database
let syncedCount = 0;
let matchedCount = 0;
const itemsToProcess = Array.from(aggregated.values());
const batchSize = 100;
for (let i = 0; i < itemsToProcess.length; i += batchSize) {
const chunk = itemsToProcess.slice(i, i + batchSize);
await prisma.$transaction(
chunk.map((item) => {
const key = `${item.channel}:::${item.shop}:::${item.productName}:::${item.variation}:::${item.marketplaceSku}`;
const existing = existingMap.get(key);
// Determine masterVariantId
let masterVariantId = existing?.masterVariantId || null;
// If unmapped, try auto-matching by SKU
if (!masterVariantId && item.marketplaceSku) {
const normalized = item.marketplaceSku.trim().toLowerCase();
const autoMatchId = skuToVariantId.get(normalized);
if (autoMatchId) {
masterVariantId = autoMatchId;
matchedCount++;
}
}
const avgPrice = item.totalSoldQty > 0 ? item.totalRevenue / item.totalSoldQty : 0;
return prisma.marketplaceProduct.upsert({
where: {
channel_shop_productName_variation_marketplaceSku: {
channel: item.channel,
shop: item.shop,
productName: item.productName,
variation: item.variation,
marketplaceSku: item.marketplaceSku,
},
},
create: {
channel: item.channel,
shop: item.shop,
marketplaceSku: item.marketplaceSku,
productName: item.productName,
variation: item.variation,
avgPrice,
totalSoldQty: item.totalSoldQty,
totalRevenue: item.totalRevenue,
firstSoldAt: item.firstSoldAt,
lastSoldAt: item.lastSoldAt,
masterVariantId,
},
update: {
avgPrice,
totalSoldQty: item.totalSoldQty,
totalRevenue: item.totalRevenue,
firstSoldAt: item.firstSoldAt,
lastSoldAt: item.lastSoldAt,
// If already mapped manually, preserve it; otherwise use newly matched
...(masterVariantId ? { masterVariantId } : {}),
},
});
})
);
syncedCount += chunk.length;
}
return {
success: true,
syncedCount,
matchedCount,
totalMarketplaceProducts: itemsToProcess.length,
};
})
// ── 3. GET List of Marketplace Products with Profitability ──────────────────
.get("/marketplace-products", async ({ query }) => {
const channel = (query.channel as string) || "all";
const shop = (query.shop as string) || "all";
const status = (query.status as string) || "all"; // 'all' | 'mapped' | 'unmapped'
const search = (query.search as string || "").trim();
const sortBy = (query.sortBy as string) || "revenue"; // 'revenue' | 'sold' | 'name' | 'recent'
const page = parseInt(query.page as string || "1", 10);
const limit = parseInt(query.limit as string || "50", 10);
// Build Where Clause
const where: any = {};
if (channel !== "all") where.channel = channel;
if (shop !== "all") where.shop = shop;
if (status === "mapped") where.masterVariantId = { not: null };
if (status === "unmapped") where.masterVariantId = null;
if (search) {
where.OR = [
{ productName: { contains: search, mode: "insensitive" } },
{ marketplaceSku: { contains: search, mode: "insensitive" } },
{ variation: { contains: search, mode: "insensitive" } },
{ masterVariant: { sku: { contains: search, mode: "insensitive" } } },
{ masterVariant: { product: { name: { contains: search, mode: "insensitive" } } } },
];
}
// Build OrderBy Clause
let orderBy: any = [{ totalRevenue: "desc" }];
if (sortBy === "sold") orderBy = [{ totalSoldQty: "desc" }];
if (sortBy === "name") orderBy = [{ productName: "asc" }];
if (sortBy === "recent") orderBy = [{ lastSoldAt: "desc" }];
// Fetch Global KPIs across all items (without pagination limits)
const allItems = await prisma.marketplaceProduct.findMany({
where: channel !== "all" || shop !== "all" ? { ...(channel !== "all" ? { channel } : {}), ...(shop !== "all" ? { shop } : {}) } : {},
include: {
masterVariant: {
include: {
bomItems: {
include: { rawMaterial: { select: { pricePerUnit: true } } },
},
},
},
},
});
let totalSkus = allItems.length;
let mappedSkus = 0;
let totalSoldQty = 0;
let totalRevenue = 0;
let totalEstCost = 0;
let totalEstProfit = 0;
for (const item of allItems) {
totalSoldQty += item.totalSoldQty;
totalRevenue += item.totalRevenue;
if (item.masterVariantId && item.masterVariant) {
mappedSkus++;
const unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
}, 0);
const cost = unitBomCost * item.totalSoldQty;
totalEstCost += cost;
totalEstProfit += (item.totalRevenue - cost);
}
}
const unmappedSkus = totalSkus - mappedSkus;
const mappedPercent = totalSkus > 0 ? (mappedSkus / totalSkus) * 100 : 0;
const grossProfitMargin = totalRevenue > 0 ? (totalEstProfit / totalRevenue) * 100 : 0;
// Fetch Paginated Items
const [items, totalCount] = await Promise.all([
prisma.marketplaceProduct.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: {
masterVariant: {
include: {
product: {
select: { id: true, name: true, category: { select: { name: true } } },
},
bomItems: {
include: {
rawMaterial: { select: { name: true, pricePerUnit: true, unit: true } },
},
},
},
},
},
}),
prisma.marketplaceProduct.count({ where }),
]);
// Format output with profitability details
const formattedItems = items.map((item) => {
let unitBomCost = 0;
let totalCost = 0;
let profit = 0;
let marginPercent = 0;
if (item.masterVariant) {
unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
}, 0);
totalCost = unitBomCost * item.totalSoldQty;
profit = item.totalRevenue - totalCost;
marginPercent = item.totalRevenue > 0 ? (profit / item.totalRevenue) * 100 : 0;
}
return {
id: item.id,
channel: item.channel,
shop: item.shop,
marketplaceSku: item.marketplaceSku,
productName: item.productName,
variation: item.variation,
avgPrice: item.avgPrice,
totalSoldQty: item.totalSoldQty,
totalRevenue: item.totalRevenue,
firstSoldAt: item.firstSoldAt,
lastSoldAt: item.lastSoldAt,
masterVariantId: item.masterVariantId,
masterVariant: item.masterVariant
? {
id: item.masterVariant.id,
sku: item.masterVariant.sku,
barcode: item.masterVariant.barcode,
productName: item.masterVariant.product?.name,
categoryName: item.masterVariant.product?.category?.name,
option1: item.masterVariant.option1,
option2: item.masterVariant.option2,
option3: item.masterVariant.option3,
unitBomCost,
}
: null,
profitability: item.masterVariant
? {
unitBomCost,
totalCost,
profit,
marginPercent,
}
: null,
};
});
return {
kpi: {
totalSkus,
mappedSkus,
unmappedSkus,
mappedPercent,
totalSoldQty,
totalRevenue,
totalEstCost,
totalEstProfit,
grossProfitMargin,
},
pagination: {
total: totalCount,
page,
limit,
totalPages: Math.ceil(totalCount / limit) || 1,
},
items: formattedItems,
};
})
// ── 4. POST Link / Unlink Master Variant ─────────────────────────────────────
.post(
"/marketplace-products/map",
async ({ body }: { body: { marketplaceProductId: number; masterVariantId: number | null } }) => {
const { marketplaceProductId, masterVariantId } = body;
const updated = await prisma.marketplaceProduct.update({
where: { id: marketplaceProductId },
data: { masterVariantId },
include: {
masterVariant: {
include: {
product: { select: { id: true, name: true } },
bomItems: {
include: { rawMaterial: { select: { pricePerUnit: true } } },
},
},
},
},
});
return { success: true, item: updated };
},
{
body: 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({
select: { id: true, sku: true, barcode: true },
});
const skuMap = new Map<string, number>();
for (const v of masterVariants) {
if (v.sku?.trim()) skuMap.set(v.sku.trim().toLowerCase(), v.id);
if (v.barcode?.trim()) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
}
const unmapped = await prisma.marketplaceProduct.findMany({
where: { masterVariantId: null, marketplaceSku: { not: "" } },
});
let matched = 0;
for (const item of unmapped) {
const norm = item.marketplaceSku.trim().toLowerCase();
const variantId = skuMap.get(norm);
if (variantId) {
await prisma.marketplaceProduct.update({
where: { id: item.id },
data: { masterVariantId: variantId },
});
matched++;
}
}
const remainingUnmapped = await prisma.marketplaceProduct.count({
where: { masterVariantId: null },
});
return {
success: true,
matched,
remainingUnmapped,
};
});
+107
View File
@@ -144,3 +144,110 @@ export function cleanDistrictName(input: string | null | undefined): string {
.replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '') .replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '')
.trim(); .trim();
} }
const POSTCODE_EXCEPTIONS: Record<string, string> = {
'10540': 'สมุทรปราการ',
'10270': 'สมุทรปราการ',
'10280': 'สมุทรปราการ',
'10560': 'สมุทรปราการ',
'13170': 'พระนครศรีอยุธยา',
'13180': 'พระนครศรีอยุธยา',
};
const POSTCODE_PREFIX_TO_PROVINCE_NAME: Record<string, string> = {
'10': 'กรุงเทพมหานคร',
'11': 'นนทบุรี',
'12': 'ปทุมธานี',
'13': 'พระนครศรีอยุธยา',
'14': 'อ่างทอง',
'15': 'ลพบุรี',
'16': 'สิงห์บุรี',
'17': 'ชัยนาท',
'18': 'สระบุรี',
'19': 'สระบุรี',
'20': 'ชลบุรี',
'21': 'ระยอง',
'22': 'จันทบุรี',
'23': 'ตราด',
'24': 'ฉะเชิงเทรา',
'25': 'ปราจีนบุรี',
'26': 'นครนายก',
'27': 'สระแก้ว',
'30': 'นครราชสีมา',
'31': 'บุรีรัมย์',
'32': 'สุรินทร์',
'33': 'ศรีสะเกษ',
'34': 'อุบลราชธานี',
'35': 'ยโสธร',
'36': 'ชัยภูมิ',
'37': 'อำนาจเจริญ',
'38': 'บึงกาฬ',
'39': 'หนองบัวลำภู',
'40': 'ขอนแก่น',
'41': 'อุดรธานี',
'42': 'เลย',
'43': 'หนองคาย',
'44': 'มหาสารคาม',
'45': 'ร้อยเอ็ด',
'46': 'กาฬสินธุ์',
'47': 'สกลนคร',
'48': 'นครพนม',
'49': 'มุกดาหาร',
'50': 'เชียงใหม่',
'51': 'ลำพูน',
'52': 'ลำปาง',
'53': 'อุตรดิตถ์',
'54': 'แพร่',
'55': 'น่าน',
'56': 'พะเยา',
'57': 'เชียงราย',
'58': 'แม่ฮ่องสอน',
'60': 'นครสวรรค์',
'61': 'อุทัยธานี',
'62': 'กำแพงเพชร',
'63': 'ตาก',
'64': 'สุโขทัย',
'65': 'พิษณุโลก',
'66': 'พิจิตร',
'67': 'เพชรบูรณ์',
'70': 'ราชบุรี',
'71': 'กาญจนบุรี',
'72': 'สุพรรณบุรี',
'73': 'นครปฐม',
'74': 'สมุทรสาคร',
'75': 'สมุทรสงคราม',
'76': 'เพชรบุรี',
'77': 'ประจวบคีรีขันธ์',
'80': 'นครศรีธรรมราช',
'81': 'กระบี่',
'82': 'พังงา',
'83': 'ภูเก็ต',
'84': 'สุราษฎร์ธานี',
'85': 'ระนอง',
'86': 'ชุมพร',
'90': 'สงขลา',
'91': 'สตูล',
'92': 'ตรัง',
'93': 'พัทลุง',
'94': 'ปัตตานี',
'95': 'ยะลา',
'96': 'นราธิวาส',
};
export function findProvinceByPostcode(postcode: string | null | undefined): ProvinceMeta | null {
if (!postcode) return null;
const clean = String(postcode).replace(/\D/g, '').trim();
if (clean.length < 2) return null;
if (POSTCODE_EXCEPTIONS[clean]) {
const provName = POSTCODE_EXCEPTIONS[clean];
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
}
const prefix = clean.slice(0, 2);
const provName = POSTCODE_PREFIX_TO_PROVINCE_NAME[prefix];
if (provName) {
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
}
return null;
}
Binary file not shown.