diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 9a83d06..ae8fda5 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -32,9 +32,11 @@ import TiktokPage from './pages/TiktokPage' import TiktokAnalysisPage from './pages/TiktokAnalysisPage' import ShopeePage from './pages/ShopeePage' import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage' +import ShopeeSalesMapPage from './pages/ShopeeSalesMapPage' import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage' import ShopeeOverviewPage from './pages/ShopeeOverviewPage' import TiktokOrderPage from './pages/TiktokOrderPage' +import TiktokOrderDetailPage from './pages/TiktokOrderDetailPage' import TiktokSalesMapPage from './pages/TiktokSalesMapPage' import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage' import TiktokOverviewPage from './pages/TiktokOverviewPage' @@ -54,6 +56,9 @@ import BackupCatalogPage from './pages/backup/BackupCatalogPage' import BackupOrdersPage from './pages/backup/BackupOrdersPage' import BackupCmsPage from './pages/backup/BackupCmsPage' 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() { return ( @@ -103,9 +108,11 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> + } /> } /> } /> } /> @@ -113,6 +120,12 @@ function App() { } /> } /> } /> + {/* Business Analytics */} + } /> + } /> + {/* Tools */} + } /> + } /> {/* Backup & Restore */} } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index 6804e64..3ebeda0 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -54,6 +54,15 @@ const SALES_MENU = [ { 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 = [ { icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' }, { icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' }, @@ -70,6 +79,7 @@ const TIKTOK_LEGACY_MENU = [ const SHOPEE_MENU = [ { 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: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' }, { 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 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 isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') + const isInTools = location.pathname.startsWith('/tools') const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis') const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy const isInShopee = location.pathname.startsWith('/shopee') @@ -139,6 +151,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { const [productOpen, setProductOpen] = useState(isInProduct) const [salesOpen, setSalesOpen] = useState(isInSales) + const [businessAnalyticsOpen, setBusinessAnalyticsOpen] = useState(isInBusinessAnalytics) + const [toolsOpen, setToolsOpen] = useState(isInTools) const [tiktokOpen, setTiktokOpen] = useState(isInTiktok) const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy) const [shopeeOpen, setShopeeOpen] = useState(isInShopee) @@ -260,6 +274,66 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { )} + {/* Business Analytics Group */} +
+ + + {!isCollapsed && businessAnalyticsOpen && ( +
+ {BUSINESS_ANALYTICS_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ + {/* Tools Group */} +
+ + + {!isCollapsed && toolsOpen && ( +
+ {TOOL_MENU.map((item) => ( + + `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` + } + > + + {item.label} + + ))} +
+ )} +
+ {/* TikTok Group */}
+
+ + {/* Modal Body: 3 Columns Layout */} +
+ {/* Col 1: District SVG Map (5 cols) */} +
+
+ + แผนที่รายอำเภอ ({paths.length} อำเภอ) + + คลิกที่อำเภอเพื่อเลือกหลายพื้นที่ +
+ +
setHoveredDistrict(null)} + > + {loadingMap || loadingData ? ( +
+ ⏳ กำลังโหลดแผนที่อำเภอ... +
+ ) : paths.length === 0 ? ( +
+ ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ +
+ ) : ( + + + {paths.map((item: any, idx: number) => ( + 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); + } + }} + /> + ))} + + + )} + + {/* Hover Tooltip */} + {hoveredDistrict && tooltipPos && ( +
+

+ + อ.{hoveredDistrict.districtName} +

+

+ {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.orders} ออเดอร์) +

+ {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( +
+ TT: {fmtBaht(hoveredDistrict.tiktokRevenue)} + • + SP: {fmtBaht(hoveredDistrict.shopeeRevenue)} + • + LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)} +
+ )} +
+ )} +
+ + {/* Map Legend */} +
+
+ + ฿0 +
+
+ + ฿{fmtNum(maxDistrictRevenue)} +
+
+ + เลือกอยู่ +
+
+
+ + {/* Col 2 & 3: Selection Stats & Order Table (7 cols) */} +
+ {/* Filter & Summary Header */} +
+
+
+ + + + +
+ + {selectedDistricts.size > 0 && ( + + )} +
+ + {/* KPI Cards for Selection */} +
+
+

ยอดขายรวม

+

{fmtBaht(selectionSummary.revenue)}

+
+
+

คำสั่งซื้อ

+

{selectionSummary.orders} ออเดอร์

+
+
+

TikTok

+

{fmtBaht(selectionSummary.tiktokRevenue)}

+
+
+

Shopee

+

{fmtBaht(selectionSummary.shopeeRevenue)}

+
+
+

Lazada

+

{fmtBaht(selectionSummary.lazadaRevenue)}

+
+
+ + {/* District Tags */} + {selectedDistricts.size > 0 && ( +
+ กรองเฉพาะ: + {Array.from(selectedDistricts).map((dName) => ( + + อ.{dName} + + + ))} +
+ )} + + {/* Search Bar */} +
+ + 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" + /> +
+
+ + {/* Orders Table */} +
+ {loadingData ? ( +
กำลังโหลดข้อมูลคำสั่งซื้อ...
+ ) : filteredOrders.length === 0 ? ( +
+ ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก +
+ ) : ( +
+ {filteredOrders.map((order: any) => ( +
+
+
+
+ {order.channel === 'tiktok' ? ( + + TikTok + + ) : order.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} + {order.orderId} + + {order.shop} + +
+

+ ผู้ซื้อ: {order.buyerUsername || '-'} • อ.{order.district} • {order.createdTime?.slice(0, 16)} +

+
+
+

{fmtBaht(order.orderAmount)}

+

ค่าส่ง {fmtBaht(order.shippingFee)}

+
+
+ + {/* Items Preview */} + {order.items?.length > 0 && ( +
+ {order.items.map((it: any, iIdx: number) => ( +
+ + • {it.productName} {it.variation ? `(${it.variation})` : ''} + + + x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)}) + +
+ ))} +
+ )} + +
+ + ดูหน้าออเดอร์เต็ม + +
+
+ ))} +
+ )} +
+
+
+ + + ); +} diff --git a/apps/admin/src/components/analytics/ProductDistrictModal.tsx b/apps/admin/src/components/analytics/ProductDistrictModal.tsx new file mode 100644 index 0000000..5704b58 --- /dev/null +++ b/apps/admin/src/components/analytics/ProductDistrictModal.tsx @@ -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(null); + const [loadingMap, setLoadingMap] = useState(true); + const [loadingData, setLoadingData] = useState(true); + const [districtData, setDistrictData] = useState(null); + + const [selectedDistricts, setSelectedDistricts] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(''); + const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all'); + const [hoveredDistrict, setHoveredDistrict] = useState(null); + const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null); + + const containerRef = useRef(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(); + 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 ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+
+

จังหวัด{provinceName}

+ + + {productName} {variantName ? `(${variantName})` : ''} + +
+

+ สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม +

+
+
+ +
+ + {/* Modal Body: 3 Columns Layout */} +
+ {/* Col 1: District SVG Map (5 cols) */} +
+
+ + แผนที่รายอำเภอ ({paths.length} อำเภอ) + + คลิกที่อำเภอเพื่อเลือกหลายพื้นที่ +
+ +
setHoveredDistrict(null)} + > + {loadingMap || loadingData ? ( +
+ ⏳ กำลังโหลดแผนที่อำเภอ... +
+ ) : paths.length === 0 ? ( +
+ ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ +
+ ) : ( + + + {paths.map((item: any, idx: number) => ( + 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); + } + }} + /> + ))} + + + )} + + {/* Hover Tooltip */} + {hoveredDistrict && tooltipPos && ( +
+

+ + อ.{hoveredDistrict.districtName} +

+

+ {fmtBaht(hoveredDistrict.revenue)} ({hoveredDistrict.unitsSold} ชิ้น) +

+ {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( +
+ TT: {fmtBaht(hoveredDistrict.tiktokRevenue)} + • + SP: {fmtBaht(hoveredDistrict.shopeeRevenue)} + • + LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)} +
+ )} +
+ )} +
+ + {/* Map Legend */} +
+
+ + ฿0 +
+
+ + ฿{fmtNum(maxDistrictRevenue)} +
+
+ + เลือกอยู่ +
+
+
+ + {/* Col 2 & 3: Selection Stats & Order Table (7 cols) */} +
+ {/* Filter & Summary Header */} +
+
+
+ + + + +
+ + {selectedDistricts.size > 0 && ( + + )} +
+ + {/* KPI Cards for Selection */} +
+
+

ยอดขายรวม

+

{fmtBaht(selectionSummary.revenue)}

+
+
+

จำนวนชิ้นที่ขาย

+

{selectionSummary.units} ชิ้น

+
+
+

TikTok

+

{fmtBaht(selectionSummary.tiktokRevenue)}

+
+
+

Shopee

+

{fmtBaht(selectionSummary.shopeeRevenue)}

+
+
+

Lazada

+

{fmtBaht(selectionSummary.lazadaRevenue)}

+
+
+ + {/* District Tags */} + {selectedDistricts.size > 0 && ( +
+ กรองเฉพาะ: + {Array.from(selectedDistricts).map((dName) => ( + + อ.{dName} + + + ))} +
+ )} + + {/* Search Bar */} +
+ + 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" + /> +
+
+ + {/* Orders Table */} +
+ {loadingData ? ( +
กำลังโหลดข้อมูลคำสั่งซื้อ...
+ ) : filteredOrders.length === 0 ? ( +
+ ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก +
+ ) : ( +
+ {filteredOrders.map((order: any) => ( +
+
+
+
+ {order.channel === 'tiktok' ? ( + + TikTok + + ) : order.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} + {order.orderId} + + {order.shop} + +
+

+ ผู้ซื้อ: {order.buyerUsername || '-'} • อ.{order.district} • {order.createdTime?.slice(0, 16)} +

+
+
+

{fmtBaht(order.orderAmount)}

+
+
+ + {/* Items Preview */} + {order.items?.length > 0 && ( +
+ {order.items.map((it: any, iIdx: number) => ( +
+ + • {it.productName} {it.variation ? `(${it.variation})` : ''} + + + x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)}) + +
+ ))} +
+ )} + +
+ + ดูหน้าออเดอร์เต็ม + +
+
+ ))} +
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx index 80453a3..18b3d32 100644 --- a/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx +++ b/apps/admin/src/components/tiktok-map/DistrictMapModal.tsx @@ -9,6 +9,8 @@ interface DistrictMapModalProps { shop: string; dateFrom?: string; dateTo?: string; + platformName?: string; + apiEndpoint?: string; onClose: () => void; } @@ -17,6 +19,8 @@ export default function DistrictMapModal({ shop, dateFrom, dateTo, + platformName = 'TikTok', + apiEndpoint = '/tiktok-order/sales-map/district', onClose, }: DistrictMapModalProps) { const [districtGeo, setDistrictGeo] = useState(null); @@ -52,12 +56,16 @@ export default function DistrictMapModal({ if (dateFrom) params.set('dateFrom', dateFrom); 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((data) => setDistrictData(data)) .catch((err) => console.error('Failed to load district data:', err)) .finally(() => setLoadingData(false)); - }, [provinceName, shop, dateFrom, dateTo]); + }, [provinceName, shop, dateFrom, dateTo, apiEndpoint]); // Filter district features matching the current province const provinceFeatures = useMemo(() => { @@ -186,7 +194,7 @@ export default function DistrictMapModal({

- แผนที่อำเภอ & คำสั่งซื้อ TikTok • {provinceName} + แผนที่อำเภอ & คำสั่งซื้อ {platformName} • {provinceName}

คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก) @@ -378,7 +386,7 @@ export default function DistrictMapModal({

- คำสั่งซื้อ TikTok ในพื้นที่ ({filteredOrders.length}) + คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length})
{selectedDistricts.size > 0 && ( diff --git a/apps/admin/src/pages/ProductBomPage.tsx b/apps/admin/src/pages/ProductBomPage.tsx index f41a4ff..4807f2c 100644 --- a/apps/admin/src/pages/ProductBomPage.tsx +++ b/apps/admin/src/pages/ProductBomPage.tsx @@ -1,16 +1,39 @@ import { useState, useEffect } from 'react' 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' +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() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - + const [product, setProduct] = useState(null) const [variants, setVariants] = useState([]) const [rawMaterials, setRawMaterials] = useState([]) - + const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) @@ -28,10 +51,10 @@ export default function ProductBomPage() { if (id) { const prodRes = await fetch(`${API_BASE}/products/${id}?lang=all`) if (!prodRes.ok) throw new Error('Product not found') - + const prodData: Product = await prodRes.json() setProduct(prodData) - setVariants(JSON.parse(JSON.stringify(prodData.variants))) // Deep copy + setVariants(JSON.parse(JSON.stringify(prodData.variants || []))) // Deep copy } } catch (err: any) { setError(err.message) @@ -39,24 +62,40 @@ export default function ProductBomPage() { setLoading(false) } } - + fetchData() }, [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 () => { setSaving(true) setError(null) setSuccess(false) try { - if (!product) throw new Error("Product not loaded") + if (!product) throw new Error('Product not loaded') const payload = { categoryId: product.categoryId, name: product.name, options: product.options, - images: product.images, // Need to pass existing images to avoid deleting them in some backends - variants: variants.map(v => ({ + images: product.images, + variants: variants.map((v) => ({ id: v.id, option1: v.option1, option2: v.option2, @@ -65,11 +104,12 @@ export default function ProductBomPage() { stock: v.stock, sku: v.sku, barcode: v.barcode, - bomItems: v.bomItems?.map((b: any) => ({ - rawMaterialId: b.rawMaterialId, - quantity: Number(b.quantity) || 0 - })) || [] - })) + bomItems: + v.bomItems?.map((b: any) => ({ + rawMaterialId: Number(b.rawMaterialId) || 0, + quantity: Number(b.quantity) || 0, + })) || [], + })), } const response = await fetch(`${API_BASE}/products/${id}`, { @@ -79,9 +119,9 @@ export default function ProductBomPage() { }) if (!response.ok) throw new Error('Failed to save BOM') - + setSuccess(true) - setTimeout(() => setSuccess(false), 3000) + setTimeout(() => setSuccess(false), 3500) } catch (err: any) { setError(err.message) } finally { @@ -91,156 +131,408 @@ export default function ProductBomPage() { if (loading) { return ( -
+
+

กำลังโหลดโครงสร้างต้นทุน (BOM)...

) } if (!product) { - return
Product not found.
+ return
ไม่พบข้อมูลสินค้าที่ต้องการ
} - 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 ( -
+
{/* Header & Actions */}
- Products + + Products + / - BOM + Bill of Materials (BOM)

- BOM {productName} + โครงสร้างต้นทุน BOM: {productName}

+

+ คำนวณและแจกแจงต้นทุนวัตถุดิบ (Raw Materials) ต่อหน่วย พร้อมวิเคราะห์กำไรขั้นต้น (Gross Profit & Margin) +

- +
- - + +
+ {/* Alerts */} {success && ( -
- -

BOM updated successfully!

+
+ +
+

บันทึกโครงสร้างต้นทุน BOM สำเร็จ!

+

+ ระบบจะนำต้นทุน BOM ไปคำนวณกำไรในหน้ารายงานการขายและสินค้า Marketplace โดยอัตโนมัติ +

+
)} {error && ( -
- -

{error}

+
+ +

{error}

)} -
- {variants.length === 0 ? ( -
- No variants found for this product. + {/* ── Summary Overview KPI Cards ────────────────────────────────────────── */} +
+ {/* Total Variants */} +
+
+

จำนวนตัวเลือกสินค้า

+
- ) : variants.map((v, vIdx) => { - const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default' - return ( -
-
-
-
- +
+

+ {totalVariantsCount} ตัวเลือก +

+

+ ผูก BOM แล้ว: {configuredVariantsCount} / {totalVariantsCount} +

+
+
+ + {/* BOM Cost Range */} +
+
+

ช่วงต้นทุน BOM

+ +
+
+

+ {minBomCost === 0 && maxBomCost === 0 + ? 'ยังไม่ระบุ' + : minBomCost === maxBomCost + ? fmtMoney(minBomCost) + : `${fmtMoney(minBomCost)} - ${fmtMoney(maxBomCost)}`} +

+

ต้นทุนวัตถุดิบต่อ 1 ชิ้น

+
+
+ + {/* Master Raw Materials Catalog */} +
+
+

คลังวัตถุดิบในระบบ

+ +
+
+

+ {rawMaterials.length} รายการ +

+ + จัดการราคาวัตถุดิบ → + +
+
+ + {/* Auto Calculation Status */} +
+
+

ระบบคำนวณอัตโนมัติ

+ +
+
+

+ Live Real-time Sync +

+

ปรับสูตร BOM ปุ๊บ ต้นทุนและกำไรจะอัปเดตทันที

+
+
+
+ + {/* ── Variants BOM List ─────────────────────────────────────────────────── */} +
+ {variants.length === 0 ? ( +
+ +

ไม่พบตัวเลือกสินค้า (Variants)

+

กรุณาเพิ่มตัวเลือกสินค้าในหน้ารายละเอียดสินค้าก่อน

+
+ ) : ( + 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 ( +
+ {/* Variant Header & Price Summary */} +
+
+
+ +
+
+
+

{variantName}

+ {v.sku && ( + + SKU: {v.sku} + + )} + {v.barcode && ( + + Barcode: {v.barcode} + + )} +
+

+ สต็อกคงเหลือ: {v.stock ?? 0} ชิ้น +

+
-
-

{variantName}

-

SKU: {v.sku || 'N/A'}

+ + {/* Financial KPI Badges for this Variant */} +
+ {/* Retail Price */} +
+ ราคาขาย + {fmtMoney(retailPrice)} +
+ + {/* Total BOM Cost */} +
+ + ต้นทุน BOM รวม + + {fmtMoney(variantBomCost)} +
+ + {/* Estimated Profit */} +
= 0 + ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' + : 'bg-rose-500/10 border-rose-500/30 text-rose-400' + }`} + > + + กำไรประเมิน / ชิ้น + +
+ {fmtMoney(profit)} + {retailPrice > 0 && ( + ({marginPercent.toFixed(1)}%) + )} +
+
+ + {/* Add Material Button */} +
- -
-
- {(!v.bomItems || v.bomItems.length === 0) ? ( -
- No materials specified for this variant. -
- ) : v.bomItems.map((bom: any, bIdx: number) => ( -
-
- - -
-
- - { - const newVariants = [...variants] - newVariants[vIdx].bomItems[bIdx].quantity = Number(e.target.value) - setVariants(newVariants) - }} - placeholder="0.00" - step="0.01" - 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" - /> -
-
-
+ ) : ( + v.bomItems.map((bom: any, bIdx: number) => { + const selectedMat = getMaterial(bom.rawMaterialId) + const lineCost = getLineCost(bom) + + return ( +
+ {/* Col 1: Material Selector */} +
+ + +
+ + {/* Col 2: Price Per Unit Display */} +
+ +
+ {selectedMat ? ( + + {fmtMoney(selectedMat.pricePerUnit)}{' '} + /{selectedMat.unit} + + ) : ( + - + )} +
+
+ + {/* Col 3: Quantity Input */} +
+ +
+ { + const newVariants = [...variants] + newVariants[vIdx].bomItems[bIdx].quantity = + e.target.value === '' ? '' : Number(e.target.value) + setVariants(newVariants) + }} + placeholder="0.00" + step="any" + 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 && ( + + {selectedMat.unit} + + )} +
+
+ + {/* Col 4: Line Subtotal Cost Display */} +
+ +
+ {fmtMoney(lineCost)} +
+
+ + {/* Col 5: Remove Button */} +
+ +
+
+ ) + }) + )} +
+ + {/* Variant Footer Breakdown */} + {hasBom && ( +
+

+ ใช้วัตถุดิบทั้งหมด {v.bomItems.length} รายการ +

+
+ + ต้นทุนรวม BOM: {fmtMoney(variantBomCost)} + + + กำไรประเมิน:{' '} + = 0 ? 'text-emerald-400 text-sm' : 'text-rose-400 text-sm'}> + {fmtMoney(profit)} + + +
- ))} -
-
- ) - })} + )} +
+ ) + }) + )}
) diff --git a/apps/admin/src/pages/ShopeeOrderDetailPage.tsx b/apps/admin/src/pages/ShopeeOrderDetailPage.tsx index 4b99285..2d3a51b 100644 --- a/apps/admin/src/pages/ShopeeOrderDetailPage.tsx +++ b/apps/admin/src/pages/ShopeeOrderDetailPage.tsx @@ -15,8 +15,7 @@ import { Tag, BadgePercent, } from 'lucide-react' - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '') +import { API_BASE } from '@homaroma/types' function authHeaders() { return { @@ -56,7 +55,7 @@ export default function ShopeeOrderDetailPage() { useEffect(() => { if (!orderId) return 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(data => { if (data.error) throw new Error(data.error) diff --git a/apps/admin/src/pages/ShopeePage.tsx b/apps/admin/src/pages/ShopeePage.tsx index ce8525c..f83e84f 100644 --- a/apps/admin/src/pages/ShopeePage.tsx +++ b/apps/admin/src/pages/ShopeePage.tsx @@ -1,9 +1,12 @@ import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import * as XLSX from 'xlsx'; -import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store } from 'lucide-react'; - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); +import { + ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, + ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store, + FileSpreadsheet, AlertTriangle, CheckCircle2, Files +} from 'lucide-react'; +import { API_BASE } from '@homaroma/types'; // ── Shop Config ─────────────────────────────────────────────────────────────── export const SHOPEE_SHOPS = [ @@ -92,7 +95,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) useEffect(() => { 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(d => { 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 }) { - const [previewRows, setPreviewRows] = useState([]); + const [fileSummaries, setFileSummaries] = useState([]); const [allRows, setAllRows] = useState([]); + const [previewRows, setPreviewRows] = useState([]); + const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0); + const [reading, setReading] = useState(false); const [loading, setLoading] = useState(false); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); - const [sheetInfo, setSheetInfo] = useState(''); const shopCfg = getShop(shop); const readFile = (f: File) => new Promise((resolve, reject) => { @@ -201,37 +214,92 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }); const handleFileChange = async (e: React.ChangeEvent) => { - const f = e.target.files?.[0]; - if (!f) return; - setMsg(null); setAllRows([]); setPreviewRows([]); setSheetInfo(''); + const files = e.target.files ? Array.from(e.target.files) : []; + if (files.length === 0) return; + + setReading(true); + setMsg(null); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); + try { - const wb = await readFile(f); - const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0]; - const ws = wb.Sheets[sheetName]; - setSheetInfo(`Sheet: "${sheetName}" | ${f.name}`); - const rawRows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][]; - if (rawRows.length < 2) { setMsg({ text: 'ไม่พบข้อมูลในไฟล์', type: 'error' }); return; } - const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : ''); - const rows: any[] = []; - for (let i = 1; i < rawRows.length; i++) { - const cells = rawRows[i] as any[]; - if (cells.every(c => c === null || c === undefined || c === '')) continue; - const row: any = {}; - headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; }); - if (row['หมายเลขคำสั่งซื้อ']) rows.push(row); + const summaries: FileSummary[] = []; + const combinedRows: any[] = []; + const orderFileMap = new Map>(); + + for (const file of files) { + const wb = await readFile(file); + const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0]; + const ws = wb.Sheets[sheetName]; + const rawRows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][]; + + if (rawRows.length < 2) continue; + + const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : ''); + const fileRows: any[] = []; + const fileOrderIds = new Set(); + + for (let i = 1; i < rawRows.length; i++) { + const cells = rawRows[i] as any[]; + if (cells.every(c => c === null || c === undefined || c === '')) continue; + const row: any = {}; + headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; }); + const orderId = row['หมายเลขคำสั่งซื้อ'] ? String(row['หมายเลขคำสั่งซื้อ']).trim() : null; + if (orderId) { + fileRows.push(row); + fileOrderIds.add(orderId); + + if (!orderFileMap.has(orderId)) { + orderFileMap.set(orderId, new Set()); + } + orderFileMap.get(orderId)!.add(file.name); + } + } + + summaries.push({ + name: file.name, + size: file.size, + sheetName, + rowsCount: fileRows.length, + ordersCount: fileOrderIds.size, + }); + + combinedRows.push(...fileRows); } - setAllRows(rows); - setPreviewRows(rows.slice(0, 5)); + + 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) { setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); + } finally { + setReading(false); } }; const handleImport = async () => { if (allRows.length === 0) return; - setLoading(true); setMsg(null); + setLoading(true); + setMsg(null); try { - const res = await fetch(`${API_URL}/api/shopee/import/orders`, { + const res = await fetch(`${API_BASE}/shopee/import/orders`, { method: 'POST', headers: authHeaders(), body: JSON.stringify({ rows: allRows, shop }), @@ -239,14 +307,21 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void const result = await res.json(); if (result.success) { setMsg({ text: result.message, type: 'success' }); - setAllRows([]); setPreviewRows([]); setSheetInfo(''); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); const inp = document.getElementById('shopee-file-input') as HTMLInputElement; if (inp) inp.value = ''; onSuccess(); - } else throw new Error(result.message); + } else { + throw new Error(result.message); + } } catch (e: any) { setMsg({ text: e.message, type: 'error' }); - } finally { setLoading(false); } + } finally { + setLoading(false); + } }; const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size; @@ -270,30 +345,81 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void - {sheetInfo && ( -
- 📋 {sheetInfo} - 0 ? 'text-emerald-400' : 'text-amber-400'}`}> - {allRows.length} แถว → {uniqueOrdersCount} คำสั่งซื้อ - + {reading && ( +
+ ⏳ กำลังประมวลผลไฟล์ Excel ทั้งหมด...
)} + {/* Selected Files Summary Cards */} + {fileSummaries.length > 0 && ( +
+
+
+ + ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์) +
+
+ รวมทั้งหมด {allRows.length} แถวสินค้า → {uniqueOrdersCount} คำสั่งซื้อไม่ซ้ำ +
+
+ +
+ {fileSummaries.map((f, idx) => ( +
+
+ +
+

{f.name}

+

+ {(f.size / 1024).toFixed(1)} KB • Sheet: {f.sheetName} +

+
+
+
+ {f.rowsCount} แถว + {f.ordersCount} ออเดอร์ +
+
+ ))} +
+ + {/* Deduplication & Safety Alert */} + {crossFileDuplicateCount > 0 ? ( +
+ +
+

ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ

+

+ ระบบจะทำการรวมข้อมูลและ Upsert (อัปเดตอัตโนมัติ) ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน +

+
+
+ ) : ( +
+ + ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์ +
+ )} +
+ )} + + {/* Preview Table */} {previewRows.length > 0 && (
-

Preview (5 แถวแรก):

+

Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):

@@ -316,13 +442,17 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
-
+
@@ -343,7 +473,7 @@ export default function ShopeePage() { const loadSummary = (s: string) => { 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(setSummary) .catch(() => { }); diff --git a/apps/admin/src/pages/ShopeeSalesMapPage.tsx b/apps/admin/src/pages/ShopeeSalesMapPage.tsx new file mode 100644 index 0000000..2f75f63 --- /dev/null +++ b/apps/admin/src/pages/ShopeeSalesMapPage.tsx @@ -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('map'); + const [shop, setShop] = useState('all'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(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 = {}; + 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 ( +
+ {/* Header */} +
+
+
+ +
+
+
+

+ แผนที่ยอดขายประเทศไทย (Shopee Sales Map) +

+
+

+ วิเคราะห์การกระจายตัวของยอดขาย Shopee และศักยภาพลูกค้าแยกตาม 77 จังหวัด +

+
+
+ + {/* Top Control Bar */} +
+ {/* View Mode Toggle */} +
+ + +
+ + {/* Shop Switcher */} + + + {/* Period Preset */} + + + {/* Custom Date Pickers */} +
+ + { + setDateFrom(e.target.value); + setPeriod('custom'); + }} + className="bg-transparent text-xs text-slate-300 focus:outline-none" + /> + - + { + setDateTo(e.target.value); + setPeriod('custom'); + }} + className="bg-transparent text-xs text-slate-300 focus:outline-none" + /> +
+ + +
+
+ + {/* KPI 5 Cards Bar */} +
+ {/* KPI 1 */} +
+
+ ยอดขายรวม + + Shopee + +
+
+

+ {fmtBaht(mapData?.kpi?.totalRevenue || 0)} +

+

Shopee Total Revenue

+
+
+ + {/* KPI 2 */} +
+
+ คำสั่งซื้อสะสม + +
+
+

+ {fmtNum(mapData?.kpi?.totalOrders || 0)} +

+

รวมทุกคำสั่งซื้อ Shopee

+
+
+ + {/* KPI 3 */} +
+
+ การเข้าถึงจังหวัด + + {(mapData?.kpi?.coveragePercent || 0).toFixed(1)}% + +
+
+

+ {mapData?.kpi?.coverageCount || 0}{' '} + / 77 จังหวัด +

+

ครอบคลุมทั่วประเทศ

+
+
+ + {/* KPI 4 */} +
+
+ ยอดเฉลี่ย/ออเดอร์ + +
+
+

+ {fmtBaht(mapData?.kpi?.aov || 0)} +

+

AOV คำนวณจากทุกคำสั่งซื้อ

+
+
+ + {/* KPI 5 */} +
+
+ ภาคที่มีสัดส่วนสูงสุด + + Top Region + +
+
+

+ {mapData?.kpi?.topRegion?.name || 'ภาคกลาง'} +

+

+ {fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ ( + {fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)}) +

+
+
+
+ + {/* Main Content Area */} + {viewMode === 'map' ? ( +
+ {/* Left/Center: Thailand SVG Map & Province Info Card (8 Cols) */} +
+
+
+

แผนที่ยอดขายประเทศไทย (Shopee)

+

+ สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น +

+
+ + {/* Map & Focus Card Row */} +
+ {/* SVG Map (7 Cols) */} +
+ { + if (selectedProvince === nameTh) { + setSelectedProvince(null); + } else { + setSelectedProvince(nameTh); + } + }} + /> +
+ + {/* Province Focus Card (5 Cols) */} +
+
+ + จังหวัด + + {selectedProvince && ( + + )} +
+ +
+

+ {currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'} +

+

+ {fmtBaht( + currentProvinceInfo + ? currentProvinceInfo.revenue + : mapData?.kpi?.totalRevenue || 0 + )} +

+

+ {fmtNum( + currentProvinceInfo + ? currentProvinceInfo.orders + : mapData?.kpi?.totalOrders || 0 + )}{' '} + คำสั่งซื้อ •{' '} + {currentProvinceInfo + ? currentProvinceInfo.regionTh + : `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`} +

+
+ + {/* Color Scale Legend */} +
+
+ ไม่มียอด + ยอดสูง +
+
+
+ + {/* Map Focus Button */} + {selectedProvince ? ( + + ) : ( +
+ เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง +
+ )} +
+
+
+
+ + {/* Right: Top 20 Provinces Ranking (4 Cols) */} +
+
+
+ +

จังหวัดยอดขายสูง (Shopee)

+
+ Top 20 +
+ +
+ {mapData?.topProvinces?.map((p: any) => { + const isSelected = selectedProvince === p.nameTh; + const isTop3 = p.rank <= 3; + return ( +
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' + }`} + > +
+ + {p.rank} + +
+

+ {p.nameTh} +

+

+ {p.orders} ออเดอร์ • {p.regionTh} +

+
+
+ +
+

+ {fmtBaht(p.revenue)} +

+

+ AOV {fmtBaht(p.aov)} +

+
+
+ ); + })} +
+
+
+ ) : ( + /* List / Table View */ +
+
+

+ ตารางสรุปยอดขาย Shopee แยกราย 77 จังหวัด +

+
+ + 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" + /> +
+
+ +
+ + + + + + + + + + + + + + {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) => ( + + + + + + + + + + ))} + +
รหัสจังหวัดภูมิภาคยอดขายรวม (฿)จำนวนออเดอร์AOV (฿/ออเดอร์)จัดการ
{p.code} + {p.nameTh}{' '} + ({p.nameEn}) + {p.regionTh} + {fmtBaht(p.revenue)} + + {fmtNum(p.orders)} + + {fmtBaht(p.orders > 0 ? p.revenue / p.orders : 0)} + + +
+
+
+ )} + + {/* District Focus Modal Dialog */} + {isModalOpen && selectedProvince && ( + setIsModalOpen(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/TiktokAnalyticsPage.tsx b/apps/admin/src/pages/TiktokAnalyticsPage.tsx index 889cd8a..e1a5f16 100644 --- a/apps/admin/src/pages/TiktokAnalyticsPage.tsx +++ b/apps/admin/src/pages/TiktokAnalyticsPage.tsx @@ -297,15 +297,79 @@ export default function TiktokAnalyticsPage() { )}
- {/* KPI Cards Row 1 */} + {/* KPI Cards Row 1 - Main Financials */}
- - - - + + + +
- {/* KPI Cards Row 2 */} + {/* KPI Cards Row 2 - Fees & Commissions Breakdown */} +
+ + + + +
+ + {/* KPI Cards Row 3 - Operations & Orders */}
@@ -313,14 +377,17 @@ export default function TiktokAnalyticsPage() {
- {/* Monthly Revenue Chart */} + {/* Monthly Revenue & Financial Trend Chart */}
-
- -

ยอดขายรายเดือน

+
+
+ +

ยอดขาย ค่าธรรมเนียม และกำไรสุทธิรายเดือน

+
+ เปรียบเทียบยอดขาย ยอดโอน ค่าคอมมิชชั่น และกำไรสุทธิ
{monthlyChart.length > 0 ? ( -
+
@@ -329,8 +396,10 @@ export default function TiktokAnalyticsPage() { } cursor={{ fill: '#ffffff06' }} /> - - + + + +
@@ -472,33 +541,46 @@ export default function TiktokAnalyticsPage() {
- {/* Cost Waterfall Summary */} + {/* Complete Financial & Cost Waterfall Summary */}
-
- -

สรุปค่าใช้จ่ายและส่วนลด

+
+
+ +

โครงสร้างต้นทุน ค่าธรรมเนียม และกำไรสุทธิ (Financial Breakdown)

+
+ หักลบรายได้ตามลำดับสู่กำไรสุทธิแท้จริง
-
+ +
{[ - { label: 'ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' }, - { label: 'หัก: ค่าจัดส่ง (ผู้ซื้อจ่าย)', value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' }, - { label: 'หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', bar: 'bg-rose-400' }, - { label: 'ยอดสุทธิก่อนหักคอมมิชชั่น', value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true }, + { label: '1. ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' }, + { label: '2. หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' }, + { label: '3. หัก: ค่าคอม TikTok', value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' }, + { 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) => ( -
-

{item.label}

-

{fmtCurrency(Math.abs(item.value))}

+
+
+

{item.label}

+ {item.sign} +
+

{fmtCurrency(Math.abs(item.value))}

{summary.totalRevenue > 0 && ( -

{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด

+

{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอดขาย

)}
))}
-
- -
-

ส่วนลด Platform สนับสนุน (TikTok ออกให้)

-

{fmtCurrency(summary.totalPlatformDisc)}

+ +
+ +
+
+

ส่วนลด Platform สนับสนุน (TikTok ช่วยจ่ายให้ลูกค้า)

+

ยอดเงินที่ TikTok ออกคูปองสนับสนุนให้ผู้ซื้อ โดยไม่หักเงินจากร้านค้า

+
+

{fmtCurrency(summary.totalPlatformDisc)}

diff --git a/apps/admin/src/pages/TiktokOrderDetailPage.tsx b/apps/admin/src/pages/TiktokOrderDetailPage.tsx new file mode 100644 index 0000000..db4579d --- /dev/null +++ b/apps/admin/src/pages/TiktokOrderDetailPage.tsx @@ -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 ( +
+

{label}

+

{value}

+
+ ); +} + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +

กำลังโหลดรายละเอียดคำสั่งซื้อ TikTok และคำนวณต้นทุน BOM...

+
+ ); + } + + if (error || !order) { + return ( +
+ +

{error || 'ไม่พบคำสั่งซื้อนี้'}

+ + กลับหน้ารายการ + +
+ ); + } + + const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0]; + const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0; + + return ( +
+ {/* Header Bar */} +
+
+ + + +
+
+

+ #{order.orderId} +

+ + {order.status} + + {order.subStatus && ( + + {order.subStatus} + + )} + + {/* BOM Status Pill */} + {hasIncompleteBom ? ( + + ขาด BOM ({order.missingBomCount} รายการ) + + ) : ( + + BOM ครบถ้วน + + )} +
+

+ ร้าน: {shopCfg.label} + • + สั่งซื้อเมื่อ: {order.createdTime || '—'} +

+
+
+ +
+ + ดูใน Sales Map + + + TikTok Seller Center + +
+
+ + {/* Missing BOM Alert Banner */} + {hasIncompleteBom && ( +
+
+
+ +
+
+

+ ตรวจพบสินค้าขาดสูตร BOM หรือยังไม่ได้ใส่ราคาต้นทุน ({order.missingBomCount} รายการ) +

+

+ พนักงานสามารถคลิกปุ่มสีแดงที่รายการสินค้าด้านล่าง เพื่อเข้าไปกรอกสูตร BOM หรือเชื่อมโยงสินค้าได้ทันที +

+
+
+ + ตรวจสอบตาราง Mapping + +
+ )} + + {/* Main Layout Grid */} +
+ {/* Left Column (2 Cols): Products Table & Details */} +
+ {/* Order Items Table */} +
+
+
+ +

+ รายการสินค้าในคำสั่งซื้อ ({order.items?.length || 0} SKU) +

+
+
+ ต้นทุนรวม BOM: 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)} +
+
+
+ {order.items?.map((item: any) => { + const isItemBomMissing = !item.hasBom; + return ( +
+
+ {/* Product Name & SKU */} +
+
+

{item.productName}

+ {isItemBomMissing ? ( + + 🔴 ขาด BOM / รอใส่ราคา + + ) : ( + + ✓ มีสูตร BOM + + )} +
+ +
+ {item.variation && ( + + ตัวเลือก: {item.variation} + + )} + {item.sellerSku && ( + + SKU: {item.sellerSku} + + )} + {item.masterProductName && ( + + เชื่อมโยง: {item.masterProductName} ({item.masterSku || 'No SKU'}) + + )} +
+
+ + {/* Pricing, Quantity & Subtotal */} +
+
+

ราคา/หน่วย

+

฿{fmt(item.unitOriginalPrice)}

+
+
+

จำนวน

+

x{item.quantity}

+
+
+

ยอดขาย (SKU)

+

฿{fmt(item.subtotalAfterDiscount)}

+
+
+
+ + {/* BOM & Profit Row */} +
+ {item.hasBom ? ( +
+
+ ต้นทุน BOM/ชิ้น: + ฿{fmt(item.unitBomCost)} +
+
+ ต้นทุนรวม: + ฿{fmt(item.totalBomCost)} +
+
+ กำไรขั้นต้น: + = 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%) + +
+
+ ) : ( +
+ + ไม่พบต้นทุน BOM สำหรับสินค้านี้ (ทำให้คำนวณกำไรไม่ได้) +
+ )} + + {/* Action Links */} +
+ {item.masterProductId ? ( + + + {item.hasBom ? 'แก้ไขสูตร BOM' : '➕ ไปใส่สูตร BOM'} + + + ) : ( + + + 🔗 ไปเชื่อมโยง Master SKU + + + )} +
+
+ + {/* BOM Raw Material Breakdown (if available) */} + {item.bomItemsDetail && item.bomItemsDetail.length > 0 && ( +
+

+ สูตรวัตถุดิบ BOM ({item.bomItemsDetail.length} รายการ): +

+
+ {item.bomItemsDetail.map((bom: any, bIdx: number) => ( +
+ • {bom.name} ({bom.quantity} {bom.unit}) + ฿{fmt(bom.lineCost)} +
+ ))} +
+
+ )} +
+ ); + })} +
+
+ + {/* Shipping & Delivery Info */} +
+
+ +

ข้อมูลการจัดส่งและขนส่ง

+
+
+ + + + + + +
+
+ + {/* Timeline Dates */} +
+
+ +

ไทม์ไลน์คำสั่งซื้อ

+
+
+ + + + + + +
+
+
+ + {/* Right Column (1 Col): Financials & Customer */} +
+ {/* BOM Cost & Profit Summary Card */} +
+
+ +

สรุปต้นทุน & กำไร (BOM)

+
+ +
+
+ ยอดขายสุทธิ (Revenue) + + ฿{fmt(order.orderAmount)} + +
+ +
+ ต้นทุนวัตถุดิบ (Total BOM Cost) + 0 ? 'text-amber-400' : 'text-rose-400'}`}> + ฿{fmt(order.orderTotalBomCost)} + +
+ +
+ ค่าคอมมิชชั่น (Commission) + + {order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'} + +
+ +
+ ค่าพันธมิตร (Affiliate) + + {order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'} + +
+ +
+
+ กำไรสุทธิ (Net Profit) +

Margin: {order.orderGrossMargin?.toFixed(1)}%

+
+ = 0 ? 'text-emerald-400' : 'text-rose-400' + }`}> + ฿{fmt(order.orderNetProfit ?? order.orderEstProfit)} + +
+ + {hasIncompleteBom && ( +
+
+ + คำเตือน: ตัวเลขยังไม่สมบูรณ์ +
+

+ มีสินค้า {order.missingBomCount} รายการ ยังไม่มีสูตร BOM หรือไม่มีราคาต้นทุน กรุณาใส่ราคา BOM เพื่อให้กำไรถูกต้อง +

+
+ )} +
+
+ + {/* Customer & Address */} +
+
+ +

ข้อมูลลูกค้าและพื้นที่

+
+
+ + +
+

+ พิกัดปลายทาง +

+
+ + + {order.district ? `อ.${order.district}, ` : ''}จ.{order.province || 'ไม่ระบุ'} + +
+
+
+
+ + {/* Payment & Financial Summary */} +
+
+ +

สรุปยอดเงิน (Financials)

+
+
+
+ ยอดรวมสินค้า + + ฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))} + +
+
+ ส่วนลดแพลตฟอร์ม + + -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))} + +
+
+ ส่วนลดร้านค้า + + -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))} + +
+
+ ค่าจัดส่ง + ฿{fmt(order.shippingFee)} +
+
+ ภาษี (Taxes) + ฿{fmt(order.taxes)} +
+ {order.refundAmount > 0 && ( +
+ ยอดคืนเงิน (Refund) + -฿{fmt(order.refundAmount)} +
+ )} +
+ ยอดสุทธิ (Order Amount) + + ฿{fmt(order.orderAmount)} + +
+
+
+
+
+
+ ); +} + diff --git a/apps/admin/src/pages/TiktokOrderPage.tsx b/apps/admin/src/pages/TiktokOrderPage.tsx index 3957c79..243edf0 100644 --- a/apps/admin/src/pages/TiktokOrderPage.tsx +++ b/apps/admin/src/pages/TiktokOrderPage.tsx @@ -1,12 +1,14 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import Papa from 'papaparse'; +import * as XLSX from 'xlsx'; import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, - ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, + Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, + FileSpreadsheet, AlertTriangle, CheckCircle2, Files, Search, X, RotateCcw, + BadgePercent, Wallet, Receipt } from 'lucide-react'; - -const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); +import { API_BASE } from '@homaroma/types'; // ── Shop Config ─────────────────────────────────────────────────────────────── export const TIKTOK_SHOPS = [ @@ -19,10 +21,19 @@ function getShop(id: string) { return TIKTOK_SHOPS.find(s => s.id === id) ?? TIK function authHeaders() { return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; } -function fmt(val: number) { +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 statusColor(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'; +} + // ── Shop Switcher ───────────────────────────────────────────────────────────── function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) => void }) { return ( @@ -43,26 +54,58 @@ function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) // ── Summary Cards ───────────────────────────────────────────────────────────── function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor: string }) { if (!summary) return ( -
- {Array.from({ length: 4 }).map((_, i) =>
)} +
+ {Array.from({ length: 5 }).map((_, i) =>
)}
); + + const missingBom = (summary.ordersMissingBomCount || 0) > 0; + const hasSettlement = (summary.totalCommission || 0) > 0 || (summary.totalAffiliate || 0) > 0; + const cards = [ { label: 'คำสั่งซื้อทั้งหมด', value: summary.orderCount.toLocaleString(), sub: `${summary.totalItems?.toLocaleString()} รายการ SKU`, icon: ShoppingBag, color: shopColor }, - { label: 'ยอดรวม (Order Amount)', value: `฿${fmt(summary.totalAmount)}`, sub: `SKU Revenue ฿${fmt(summary.totalSkuRevenue)}`, icon: DollarSign, color: 'text-emerald-400' }, - { label: 'ค่าส่งรวม', value: `฿${fmt(summary.totalShipping)}`, sub: `คืนเงิน ฿${fmt(summary.totalRefund)}`, icon: ReceiptText, color: 'text-amber-400' }, - { label: 'ภาษีรวม', value: `฿${fmt(summary.totalTaxes)}`, sub: 'Tax collected', icon: Package, color: 'text-blue-400' }, + { label: 'ยอดขายรวม (Gross)', value: `฿${fmt(summary.totalAmount)}`, sub: `SKU Revenue ฿${fmt(summary.totalSkuRevenue)}`, icon: DollarSign, color: 'text-emerald-400' }, + { label: 'ต้นทุน BOM รวม', value: `฿${fmt(summary.totalBOMCost || 0)}`, sub: `กำไรประเมิน ฿${fmt(summary.totalEstProfit || 0)} (${(summary.grossProfitMargin || 0).toFixed(1)}%)`, icon: Coins, color: 'text-amber-400' }, + { + label: 'Commission & Affiliate', + value: `฿${fmt((summary.totalCommission || 0) + (summary.totalAffiliate || 0))}`, + sub: hasSettlement + ? `Comm: ฿${fmt(summary.totalCommission || 0)} | Aff: ฿${fmt(summary.totalAffiliate || 0)}` + : 'รอ Import ใบแจ้งยอด Settlement', + icon: BadgePercent, + color: 'text-violet-400', + }, + { + label: 'สถานะ BOM สินค้า', + value: missingBom ? `ขาด ${summary.ordersMissingBomCount} ออเดอร์` : 'BOM ครบ 100%', + sub: missingBom ? '🔴 มีสินค้ายังไม่ได้ใส่ราคา BOM' : '✓ คำนวณต้นทุนครบทุกออเดอร์', + icon: missingBom ? AlertTriangle : CheckCircle2, + color: missingBom ? 'text-rose-400' : 'text-emerald-400', + isAlert: missingBom, + }, ]; + return ( -
- {cards.map((c) => ( -
+
+ {cards.map((c: any) => ( +
-

{c.label}

+

{c.label}

-

{c.value}

- {c.sub &&

{c.sub}

} +
+

{c.value}

+ {c.sub && ( +

+ {c.sub} +

+ )} +
))}
@@ -76,81 +119,372 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) const [page, setPage] = useState(1); const [totalPages, setTotalPages] = useState(1); const [loading, setLoading] = useState(true); + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [bomFilter, setBomFilter] = useState('all'); - useEffect(() => { setPage(1); }, [shop, refreshKey]); + useEffect(() => { + const timer = setTimeout(() => { + setSearch(searchInput); + setPage(1); + }, 300); + return () => clearTimeout(timer); + }, [searchInput]); + + useEffect(() => { + setPage(1); + }, [shop, refreshKey, statusFilter, bomFilter]); useEffect(() => { setLoading(true); - fetch(`${API_URL}/api/tiktok-order/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() }) - .then(r => r.json()) - .then(d => { setItems(d.items || []); setTotal(d.total || 0); setTotalPages(d.totalPages || 1); }) - .finally(() => setLoading(false)); - }, [shop, page, refreshKey]); + const params = new URLSearchParams({ + page: String(page), + limit: '20', + shop, + }); + if (search.trim()) params.set('search', search.trim()); + if (statusFilter && statusFilter !== 'all') params.set('status', statusFilter); + if (bomFilter && bomFilter !== 'all') params.set('bomStatus', bomFilter); - const statusColor = (s: string) => { - if (s.includes('เสร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20'; - if (s.includes('จัดส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20'; - if (s.includes('จะจัดส่ง') || s.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20'; - if (s.includes('ยกเลิก') || s.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20'; - return 'bg-white/5 text-slate-400 border-white/10'; + fetch(`${API_BASE}/tiktok-order/orders?${params.toString()}`, { headers: authHeaders() }) + .then((r) => r.json()) + .then((d) => { + setItems(d.items || []); + setTotal(d.total || 0); + setTotalPages(d.totalPages || 1); + }) + .catch(() => { + setItems([]); + setTotal(0); + }) + .finally(() => setLoading(false)); + }, [shop, page, refreshKey, search, statusFilter, bomFilter]); + + const clearFilters = () => { + setSearchInput(''); + setSearch(''); + setStatusFilter('all'); + setBomFilter('all'); }; return (
-
-

ทั้งหมด {total.toLocaleString()} คำสั่งซื้อ

-
- - {page} / {totalPages} - + {/* Filters Bar: Search & Status Tabs */} +
+ {/* Search Input Box */} +
+ + setSearchInput(e.target.value)} + placeholder="ค้นหา Order ID, ผู้ซื้อ, Tracking, SKU, ชื่อสินค้า..." + className="w-full pl-10 pr-9 py-2 rounded-xl bg-black/30 border border-white/10 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-rose-500/50 transition-colors font-mono" + /> + {searchInput && ( + + )} +
+ + {/* Status & BOM Filter Pills */} +
+ {/* BOM Filter Tabs */} +
+ + + +
+ + {/* Status Filter Tabs */} +
+ + + + + +
+ + {(searchInput || statusFilter !== 'all' || bomFilter !== 'all') && ( + + )}
-
- - + {/* Pagination & Counter Bar */} +
+

+ {search || statusFilter !== 'all' || bomFilter !== 'all' ? ( + <> + พบ {total.toLocaleString()} รายการ{' '} + {search && (จากคำค้นหา "{search}")} + {bomFilter === 'missing' && [กรองเฉพาะที่ขาด BOM]} + + ) : ( + <> + ทั้งหมด {total.toLocaleString()} คำสั่งซื้อ + + )} +

+
+ + + {page} / {totalPages} + + +
+
+ +
+
+ - - - - - - - - + + + + + + + + + + {loading ? ( - - ) : items.length === 0 ? ( - - ) : items.map((order) => ( - - - - - - - - - + - ))} + ) : items.length === 0 ? ( + + + + ) : ( + items.map((order) => { + const hasMissingBom = order.hasIncompleteBom; + return ( + + {/* Order ID & Settlement Badge */} + + + {/* Status */} + + + {/* Buyer */} + + + {/* Date */} + + + {/* SKU & BOM */} + + + {/* Order Amount */} + + + {/* BOM Cost */} + + + {/* Commission (฿) */} + + + {/* Affiliate (฿) */} + + + {/* Profit */} + + + {/* Actions */} + + + ); + }) + )}
Order IDสถานะผู้ซื้อวันที่สร้างSKUsยอดรวม (฿)ค่าส่ง (฿) + Order IDสถานะผู้ซื้อ / พื้นที่วันที่สร้างSKUs & BOMยอดขาย (฿)ต้นทุน BOM (฿)Commission (฿)Affiliate (฿)กำไรสุทธิ
กำลังโหลด...
ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน
{order.orderId} -
- {order.status} - {order.subStatus && {order.subStatus}} -
-
{order.buyerUsername}{order.createdTime?.slice(0, 16)} - - {order.items?.length ?? 0} SKU - - {fmt(order.orderAmount)}{fmt(order.shippingFee)} - - รายละเอียด - +
+
⏳
กำลังประมวลผลข้อมูล...
ยังไม่มีข้อมูล
+
+ + {order.orderId} + + {order.isSettled ? ( + + โอนแล้ว ฿{fmt(order.netSettlement)} + + ) : ( + รอโอนเงิน + )} +
+
+
+ + {order.status} + + {order.subStatus && {order.subStatus}} +
+
+

{order.buyerUsername || '-'}

+ {order.province &&

อ.{order.district || ''} จ.{order.province}

} +
{order.createdTime?.slice(0, 16)} +
+ + + {order.items?.length ?? 0} SKU + + {hasMissingBom ? ( + + + ขาด BOM ({order.missingBomCount}) + + ) : ( + + + BOM ครบ + + )} +
+
+ ฿{fmt(order.orderAmount)} + + {hasMissingBom ? ( +
+ + ยังไม่ระบุ + + {order.orderTotalBomCost > 0 && ( +

มีบางส่วน ฿{fmt(order.orderTotalBomCost)}

+ )} +
+ ) : ( + ฿{fmt(order.orderTotalBomCost)} + )} +
+ {order.commissionFee > 0 ? ( + -฿{fmt(order.commissionFee)} + ) : ( + - + )} + + {order.affiliateFee > 0 ? ( + -฿{fmt(order.affiliateFee)} + ) : ( + - + )} + + {hasMissingBom ? ( + รอใส่ราคา BOM + ) : ( +
+

= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> + ฿{fmt(order.orderNetProfit ?? order.orderEstProfit)} +

+

{order.orderGrossMargin?.toFixed(1)}%

+
+ )} +
+ + ดูออเดอร์ + +
@@ -158,21 +492,35 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) ); } -// ── Import Panel ────────────────────────────────────────────────────────────── +// ── Import Panel (Supports Both Orders CSV & Settlement XLSX) ────────────────── +interface FileSummary { + name: string; + size: number; + rowsCount: number; + ordersCount: number; +} + function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) { + const [importType, setImportType] = useState<'orders' | 'settlement'>('orders'); + + // Orders CSV State + const [fileSummaries, setFileSummaries] = useState([]); const [allRows, setAllRows] = useState([]); const [previewRows, setPreviewRows] = useState([]); + const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0); + + // Settlement XLSX State + const [settlementRows, setSettlementRows] = useState([]); + const [settlementStats, setSettlementStats] = useState(null); + const [settlementFile, setSettlementFile] = useState(null); + + const [reading, setReading] = useState(false); const [loading, setLoading] = useState(false); const [clearing, setClearing] = useState(false); const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null); - const [fileInfo, setFileInfo] = useState(''); const shopCfg = getShop(shop); - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - setMsg(null); setAllRows([]); setPreviewRows([]); setFileInfo(''); - + const parseSingleCsv = (file: File) => new Promise((resolve, reject) => { Papa.parse(file, { header: true, skipEmptyLines: true, @@ -182,41 +530,241 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void const id = String(r['Order ID'] ?? '').replace(/\t/g, '').trim(); return id.length > 0; }); - setAllRows(rows); - setPreviewRows(rows.slice(0, 5)); - setFileInfo(`${file.name} | ${rows.length} แถว → ${new Set(rows.map(r => String(r['Order ID']).replace(/\t/g, '').trim())).size} คำสั่งซื้อ`); + resolve(rows); }, - error: (err) => setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }), + error: reject, }); + }); + + const handleOrdersFileChange = async (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; + if (files.length === 0) return; + + setReading(true); + setMsg(null); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); + + try { + const summaries: FileSummary[] = []; + const combinedRows: any[] = []; + const orderFileMap = new Map>(); + + for (const file of files) { + const rows = await parseSingleCsv(file); + const fileOrderIds = new Set(); + + rows.forEach(r => { + const orderId = String(r['Order ID'] ?? '').replace(/\t/g, '').trim(); + if (orderId) { + fileOrderIds.add(orderId); + if (!orderFileMap.has(orderId)) { + orderFileMap.set(orderId, new Set()); + } + orderFileMap.get(orderId)!.add(file.name); + } + }); + + summaries.push({ + name: file.name, + size: file.size, + rowsCount: rows.length, + ordersCount: fileOrderIds.size, + }); + + combinedRows.push(...rows); + } + + if (combinedRows.length === 0) { + setMsg({ text: 'ไม่พบข้อมูลคำสั่งซื้อในไฟล์ CSV ที่เลือก', type: 'error' }); + setReading(false); + return; + } + + 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) { + setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); + } finally { + setReading(false); + } }; - const handleImport = async () => { + const handleSettlementFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + const file = files[0]; + setSettlementFile(file); + setReading(true); + setMsg(null); + setSettlementRows([]); + setSettlementStats(null); + + const reader = new FileReader(); + reader.onload = (event) => { + try { + const data = event.target?.result; + const workbook = XLSX.read(data, { type: 'binary' }); + + const targetSheetName = workbook.SheetNames.find(n => + n.includes('รายละเอียดคำสั่งซื้อ') || n.includes('คำสั่งซื้อ') || n.includes('Order') + ) || workbook.SheetNames[0]; + + const sheet = workbook.Sheets[targetSheetName]; + + // TikTok Shop XLSX exports often contain a broken / hardcoded !ref (e.g. A1:X2) + // Recalculate true !ref range from all existing cell keys + if (sheet) { + const range = { s: { c: 10000000, r: 10000000 }, e: { c: 0, r: 0 } }; + let hasCells = false; + for (const key of Object.keys(sheet)) { + if (key.startsWith('!')) continue; + try { + const cell = XLSX.utils.decode_cell(key); + range.s.r = Math.min(range.s.r, cell.r); + range.s.c = Math.min(range.s.c, cell.c); + range.e.r = Math.max(range.e.r, cell.r); + range.e.c = Math.max(range.e.c, cell.c); + hasCells = true; + } catch { + // ignore invalid cell keys + } + } + if (hasCells) { + sheet['!ref'] = XLSX.utils.encode_range(range); + } + } + + const rawRows: any[] = XLSX.utils.sheet_to_json(sheet, { defval: '' }); + + const validRows = rawRows.filter(r => { + const id = String(r['หมายเลขคำสั่งซื้อ/การปรับ'] || r['Order ID'] || r['หมายเลขคำสั่งซื้อ'] || '').trim(); + return id.length > 0; + }); + + if (validRows.length === 0) { + throw new Error(`ไม่พบรายการคำสั่งซื้อใน Sheet "${targetSheetName}"`); + } + + let totalSettlement = 0; + let totalRevenue = 0; + let totalCommission = 0; + let totalAffiliate = 0; + let totalFee = 0; + + validRows.forEach(r => { + const toN = (val: any) => { + if (val == null || val === '') return 0; + const s = String(val).replace(/[^0-9.\-]/g, ''); + const n = parseFloat(s); + return isNaN(n) ? 0 : n; + }; + totalSettlement += toN(r['ยอดการชำระเงินทั้งหมด'] || r['Total Settlement Amount']); + totalRevenue += toN(r['รายได้ทั้งหมด'] || r['Total Revenue']); + totalCommission += Math.abs(toN(r['ค่าคอมมิชชั่น TikTok Shop'])); + totalAffiliate += Math.abs(toN(r['ค่าคอมมิชชั่นแอฟฟิลิเอต'])) + Math.abs(toN(r['ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต'])); + totalFee += toN(r['ค่าธรรมเนียมทั้งหมด']); + }); + + setSettlementRows(validRows); + setSettlementStats({ + rowsCount: validRows.length, + sheetName: targetSheetName, + totalSettlement, + totalRevenue, + totalCommission, + totalAffiliate, + totalFee, + }); + } catch (err: any) { + setMsg({ text: `อ่านไฟล์ XLSX ไม่สำเร็จ: ${err.message}`, type: 'error' }); + } finally { + setReading(false); + } + }; + reader.onerror = () => { + setMsg({ text: 'เกิดข้อผิดพลาดในการอ่านไฟล์', type: 'error' }); + setReading(false); + }; + reader.readAsBinaryString(file); + }; + + const handleImportOrders = async () => { if (!allRows.length) return; - setLoading(true); setMsg(null); + setLoading(true); + setMsg(null); try { - const res = await fetch(`${API_URL}/api/tiktok-order/import`, { - method: 'POST', headers: authHeaders(), + const res = await fetch(`${API_BASE}/tiktok-order/import`, { + method: 'POST', + headers: authHeaders(), body: JSON.stringify({ rows: allRows, shop }), }); const result = await res.json(); if (result.success) { setMsg({ text: result.message, type: 'success' }); - setAllRows([]); setPreviewRows([]); setFileInfo(''); + setAllRows([]); + setPreviewRows([]); + setFileSummaries([]); + setCrossFileDuplicateCount(0); const inp = document.getElementById('tiktok-file-input') as HTMLInputElement; if (inp) inp.value = ''; onSuccess(); - } else throw new Error(result.message); + } else { + throw new Error(result.message); + } } catch (e: any) { setMsg({ text: e.message, type: 'error' }); - } finally { setLoading(false); } + } finally { + setLoading(false); + } + }; + + const handleImportSettlement = async () => { + if (!settlementRows.length) return; + setLoading(true); + setMsg(null); + try { + const res = await fetch(`${API_BASE}/tiktok-order/import-settlement`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ rows: settlementRows, shop }), + }); + const result = await res.json(); + if (result.success) { + setMsg({ text: result.message, type: 'success' }); + setSettlementRows([]); + setSettlementStats(null); + setSettlementFile(null); + const inp = document.getElementById('tiktok-settlement-input') as HTMLInputElement; + if (inp) inp.value = ''; + onSuccess(); + } else { + throw new Error(result.message); + } + } catch (e: any) { + setMsg({ text: e.message, type: 'error' }); + } finally { + setLoading(false); + } }; const handleClearData = async () => { - if (!confirm(`ยืนยันการลบข้อมูล TikTok ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; + if (!confirm(`ยืนยันการลบข้อมูล TikTok ทั้งหมด (ทั้งคำสั่งซื้อและใบแจ้งยอด) ของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; setClearing(true); setMsg(null); try { - const res = await fetch(`${API_URL}/api/tiktok-order/clear?shop=${shop}`, { + const res = await fetch(`${API_BASE}/tiktok-order/clear?shop=${shop}`, { method: 'DELETE', headers: authHeaders() }); @@ -238,13 +786,41 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void return (
- {/* Target shop indicator */} -
- -

- Import เข้าร้าน: {shopCfg.label} -

- {shopCfg.id} + {/* Target shop indicator & Sub-Tab Switcher */} +
+
+ +

+ Import เข้าร้าน: {shopCfg.label} +

+ {shopCfg.id} +
+ + {/* Mode Toggle */} +
+ + +
{msg && ( @@ -253,68 +829,246 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
)} - + {/* ───────────────────────────────────────────────────────────── */} + {/* 1. ORDERS CSV IMPORT MODE */} + {/* ───────────────────────────────────────────────────────────── */} + {importType === 'orders' && ( +
+ - {fileInfo && ( -
- 📋 {fileInfo} - 0 ? 'text-emerald-400' : 'text-amber-400'}`}>{uniqueOrders} orders + {reading && ( +
+ ⏳ กำลังประมวลผลไฟล์ CSV ทั้งหมด... +
+ )} + + {/* Selected Files Summary Cards */} + {fileSummaries.length > 0 && ( +
+
+
+ + ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์) +
+
+ รวมทั้งหมด {allRows.length} แถวสินค้า → {uniqueOrders} คำสั่งซื้อไม่ซ้ำ +
+
+ +
+ {fileSummaries.map((f, idx) => ( +
+
+ +
+

{f.name}

+

+ {(f.size / 1024).toFixed(1)} KB • CSV Report +

+
+
+
+ {f.rowsCount} แถว + {f.ordersCount} ออเดอร์ +
+
+ ))} +
+ + {crossFileDuplicateCount > 0 ? ( +
+ +
+

ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ

+

+ ระบบจะทำการรวมข้อมูลและ Upsert (อัปเดตอัตโนมัติ) ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน +

+
+
+ ) : ( +
+ + ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์ +
+ )} +
+ )} + + {/* Preview Table */} + {previewRows.length > 0 && ( +
+

Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):

+
+ + + + {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( + + ))} + + + + {previewRows.map((row, i) => ( + + {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( + + ))} + + ))} + +
{k}
+ {String(row[k] ?? '-').replace(/\t/g, '').slice(0, 30)} +
+
+
+ + + +
+
+ )}
)} - {previewRows.length > 0 && ( -
-

Preview (5 แถวแรก):

-
- - - - {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( - - ))} - - - - {previewRows.map((row, i) => ( - - {['Order ID', 'Order Status', 'Product Name', 'Variation', 'Quantity', 'SKU Subtotal After Discount', 'Order Amount'].map(k => ( - - ))} - - ))} - -
{k}
- {String(row[k] ?? '-').replace(/\t/g, '').slice(0, 30)} -
-
-
- + {/* ───────────────────────────────────────────────────────────── */} + {/* 2. SETTLEMENT XLSX IMPORT MODE */} + {/* ───────────────────────────────────────────────────────────── */} + {importType === 'settlement' && ( +
+ - -
+ {reading && ( +
+ ⏳ กำลังอ่านข้อมูลและแจกแจงค่าคอมมิชชั่นจากไฟล์ Excel... +
+ )} + + {/* Settlement Stats Breakdown Card */} + {settlementStats && ( +
+
+
+
+ +
+

+ สรุปข้อมูลรายงานการเงิน ({settlementFile?.name}) +

+

Sheet: {settlementStats.sheetName}

+
+
+ + พบ {settlementStats.rowsCount} รายการธุรกรรม + +
+ +
+
+

ยอดเงินโอนสุทธิ (Net Payout)

+

฿{fmt(settlementStats.totalSettlement)}

+
+
+

ยอดขายสินค้ารวม (Revenue)

+

฿{fmt(settlementStats.totalRevenue)}

+
+
+

ค่าคอมมิชชั่น TikTok

+

฿{fmt(settlementStats.totalCommission)}

+
+
+

ค่า Affiliate พันธมิตร

+

฿{fmt(settlementStats.totalAffiliate)}

+
+
+
+ + {/* Settlement Preview Table */} +
+

ตัวอย่างรายการใบแจ้งยอด (5 แถวแรก):

+
+ + + + + + + + + + + + + {settlementRows.slice(0, 5).map((r, i) => ( + + + + + + + + + ))} + +
Order IDวันที่ชำระยอดโอนสุทธิCommissionAffiliateค่าธรรมเนียมคำสั่งซื้อ
+ {r['หมายเลขคำสั่งซื้อ/การปรับ'] || r['Order ID']} + {r['เวลาที่ชำระคำสั่งซื้อ'] || r['Order Settled Time'] || '-'} + ฿{fmt(Number(r['ยอดการชำระเงินทั้งหมด'] || r['Total Settlement Amount']))} + + ฿{fmt(Math.abs(Number(r['ค่าคอมมิชชั่น TikTok Shop'] || 0)))} + + ฿{fmt(Math.abs(Number(r['ค่าคอมมิชชั่นแอฟฟิลิเอต'] || 0)) + Math.abs(Number(r['ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต'] || 0)))} + + ฿{fmt(Math.abs(Number(r['ค่าธรรมเนียมคำสั่งซื้อ'] || 0)))} +
+
+
+ +
+ +
+
+ )}
)}
@@ -333,7 +1087,7 @@ export default function TiktokOrderPage() { const loadSummary = (s: string) => { setSummary(null); - fetch(`${API_URL}/api/tiktok-order/summary?shop=${s}`, { headers: authHeaders() }) + fetch(`${API_BASE}/tiktok-order/summary?shop=${s}`, { headers: authHeaders() }) .then(r => r.json()).then(setSummary).catch(() => { }); }; @@ -363,7 +1117,7 @@ export default function TiktokOrderPage() {
diff --git a/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx b/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx new file mode 100644 index 0000000..28c71b8 --- /dev/null +++ b/apps/admin/src/pages/analytics/OmnichannelSalesMapPage.tsx @@ -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('map'); + const [channel, setChannel] = useState('all'); + const [tiktokShop, setTiktokShop] = useState('all'); + const [shopeeShop, setShopeeShop] = useState('all'); + const [lazadaShop, setLazadaShop] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(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 = {}; + 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 ( +
+ {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Omnichannel Sales Map

+ + TikTok + Shopee + Lazada + +
+

+ Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ +

+
+
+ + {/* View Switcher */} +
+ + +
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Selector */} +
+ + + + +
+ + {/* Time Period Filter */} +
+ + + + + +
+
+ + {/* Secondary Filters: Shop selections & Custom Date Pickers */} +
+
+ {/* TikTok Shop Dropdown */} + {(channel === 'all' || channel === 'tiktok') && ( +
+ ร้าน TikTok: + +
+ )} + + {/* Shopee Shop Dropdown */} + {(channel === 'all' || channel === 'shopee') && ( +
+ ร้าน Shopee: + +
+ )} + + {/* Lazada Shop Dropdown */} + {(channel === 'all' || channel === 'lazada') && ( +
+ ร้าน Lazada: + +
+ )} + + {/* Status Filter */} +
+ + สถานะ: + + + + +
+
+ + {/* Custom Date Range */} + {period === 'custom' && ( +
+ ตั้งแต่วันที่: + 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" + /> + ถึง + 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" + /> +
+ )} +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Total Revenue & Channel Split */} +
+
+

ยอดขายรวมทุกช่องทาง

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+ {/* Split bar: TikTok Rose / Shopee Orange / Lazada Blue */} +
+
+
+
+
+
+
+ TT {kpi?.tiktokSharePercent?.toFixed(0)}% + SP {kpi?.shopeeSharePercent?.toFixed(0)}% + LZ {kpi?.lazadaSharePercent?.toFixed(0)}% +
+
+
+
+ + {/* Total Orders & Channel Count */} +
+
+

คำสั่งซื้อทั้งหมด

+ +
+
+

+ {fmtNum(kpi?.totalOrders || 0)} ออเดอร์ +

+
+ TT: {fmtNum(kpi?.totalTiktokOrders || 0)} + SP: {fmtNum(kpi?.totalShopeeOrders || 0)} + LZ: {fmtNum(kpi?.totalLazadaOrders || 0)} +
+
+
+ + {/* Province Coverage */} +
+
+

การเข้าถึงพื้นที่

+ +
+
+

+ {kpi?.coverageCount || 0} / 77 จังหวัด +

+

+ ครอบคลุม {kpi?.coveragePercent?.toFixed(1)}% ของประเทศไทย +

+
+
+ + {/* AOV (Average Order Value) */} +
+
+

ยอดซื้อเฉลี่ย (AOV)

+ +
+
+

+ {fmtBaht(kpi?.aov || 0)} +

+
+ TT: ฿{fmtNum(kpi?.tiktokAov || 0)} + SP: ฿{fmtNum(kpi?.shopeeAov || 0)} + LZ: ฿{fmtNum(kpi?.lazadaAov || 0)} +
+
+
+ + {/* Top Region */} +
+
+

ภูมิภาคยอดขายสูงสุด

+ +
+
+

+ {kpi?.topRegion?.name || '-'} +

+

+ {fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์ +

+
+
+
+ + {/* ── Main Section: Map View or Table View ─────────────────────────────── */} + {viewMode === 'map' ? ( +
+ {/* Map & Focus Card Container (8 cols) */} +
+
+
+
+ +

แผนที่ความหนาแน่นยอดขาย (Thailand Choropleth)

+
+
+
+ + ยอดขายน้อย +
+
+ + ยอดขายสูง +
+
+
+ + {/* Thailand SVG Map */} +
+ {loading ? ( +
+ ⏳ กำลังประมวลผลข้อมูลแผนที่... +
+ ) : ( + setSelectedProvince(provName)} + /> + )} +
+
+ + {/* Selected Province Details Card */} + {selectedProvinceDetail && ( +
+
+
+
+ +

จังหวัด{selectedProvinceDetail.nameTh}

+ ({selectedProvinceDetail.nameEn}) + + {selectedProvinceDetail.regionTh} + +
+
+ ยอดรวม: {fmtBaht(selectedProvinceDetail.revenue)} + คำสั่งซื้อ: {selectedProvinceDetail.orders} รายการ + AOV: {fmtBaht(selectedProvinceDetail.aov)} +
+ + {/* Platform Breakdown Mini Bar */} +
+ สัดส่วนยอดขาย: +
+
+
+
+
+ TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)} + SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)} + LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)} +
+
+ + +
+
+ )} +
+ + {/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */} +
+ {/* Top 20 Provinces Card */} +
+
+
+ +

Top 20 จังหวัดยอดขายสูงสุด

+
+ Omnichannel +
+ +
+ {mapData?.topProvinces?.map((item: any) => { + const isSelected = selectedProvince === item.nameTh; + return ( +
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' + }`} + > +
+
+ + {item.rank} + +
+

{item.nameTh}

+

{item.orders} ออเดอร์

+
+
+
+

{fmtBaht(item.revenue)}

+
+ TT {item.tiktokShare?.toFixed(0)}% + • + SP {item.shopeeShare?.toFixed(0)}% + • + LZ {item.lazadaShare?.toFixed(0)}% +
+
+
+ + {/* Mini platform share bar */} +
+
+
+
+
+
+ ); + })} +
+
+ + {/* Region Breakdown Card */} +
+
+ +

สัดส่วนยอดขายตามภูมิภาค

+
+ +
+ {mapData?.regionBreakdown?.map((reg: any) => ( +
+
+ {reg.regionTh} + + {fmtBaht(reg.revenue)}{' '} + ({reg.percentage?.toFixed(1)}%) + +
+
+
+
+
+
+
+ {reg.orders} ออเดอร์ + + TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)} + +
+
+ ))} +
+
+
+
+ ) : ( + /* ── Table View: 77 Provinces ────────────────────────────────────────── */ +
+
+
+ + 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" + /> +
+

+ แสดง {filteredProvinces.length} จาก 77 จังหวัด +

+
+ +
+ + + + + + + + + + + + + + + + + {filteredProvinces.length === 0 ? ( + + + + ) : ( + filteredProvinces.map((p: any) => ( + + + + + + + + + + + + + + )) + )} + +
รหัสจังหวัดภูมิภาคยอดรวม (฿)ออเดอร์รวมTikTok (฿)Shopee (฿)Lazada (฿)AOV (฿)สัดส่วนช่องทาง +
+ ไม่พบข้อมูลจังหวัดตามคำค้นหา +
{p.code} + {p.nameTh} ({p.nameEn}) + + + {p.regionTh} + + + {p.revenue > 0 ? fmtBaht(p.revenue) : '-'} + + {p.orders > 0 ? `${p.orders} ออเดอร์` : '-'} + + {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} + + {p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'} + + {p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'} + + {p.aov > 0 ? fmtBaht(p.aov) : '-'} + + {p.revenue > 0 ? ( +
+
+
+
+
+
+
+ {p.tiktokShare?.toFixed(0)}% + {p.shopeeShare?.toFixed(0)}% + {p.lazadaShare?.toFixed(0)}% +
+
+ ) : ( + - + )} +
+ +
+
+
+ )} + + {/* ── District Level Modal ─────────────────────────────────────────────── */} + {showDistrictModal && selectedProvince && ( + setShowDistrictModal(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx b/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx new file mode 100644 index 0000000..3d6aa88 --- /dev/null +++ b/apps/admin/src/pages/analytics/ProductSalesMapPage.tsx @@ -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('map'); + const [metric, setMetric] = useState('revenue'); + const [channel, setChannel] = useState('all'); + const [statusFilter, setStatusFilter] = useState('completed'); + const [period, setPeriod] = useState('all'); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); + + // Products list + const [productsList, setProductsList] = useState([]); + const [loadingProducts, setLoadingProducts] = useState(true); + const [selectedProductId, setSelectedProductId] = useState(null); + const [selectedVariantId, setSelectedVariantId] = useState(null); + const [showProductDropdown, setShowProductDropdown] = useState(false); + const [productSearch, setProductSearch] = useState(''); + + // Map Data + const [loading, setLoading] = useState(false); + const [mapData, setMapData] = useState(null); + const [selectedProvince, setSelectedProvince] = useState(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 = {}; + 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 ( +
+ {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Product Sales Map

+ + Geo-Intelligence by Product + +
+

+ Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ +

+
+
+ + {/* View Switcher & Metric Switcher */} +
+ {/* Metric Switcher */} +
+ + +
+ + {/* View Switcher */} +
+ + +
+
+
+ + {/* ── Product & Variant Selector Bar ────────────────────────────────────── */} +
+
+
+ เลือกสินค้า Master: + + {/* Product Selector Dropdown Button */} +
+ + + {/* Dropdown Menu */} + {showProductDropdown && ( +
+
+ + 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 + /> +
+ +
+ {filteredDropdownProducts.map((prod) => { + const isSelected = selectedProductId === prod.id && selectedVariantId === null; + return ( +
{ + 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' + }`} + > +
+

{prod.name}

+
+ {prod.category} + • {prod.variants.length} ตัวเลือก +
+
+
+

{fmtBaht(prod.totalRevenue)}

+

{fmtNum(prod.totalSoldUnits)} ชิ้น

+
+
+ ); + })} +
+
+ )} +
+
+ + {/* Variant Selector Tabs */} + {currentProduct && currentProduct.variants.length > 0 && ( +
+ ขนาด/ตัวเลือก: + + + {currentProduct.variants.map((v) => { + const isSelected = selectedVariantId === v.id; + return ( + + ); + })} +
+ )} +
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Selector */} +
+ + + + +
+ + {/* Time Period Filter */} +
+ + + + + +
+
+ + {/* Secondary Row: Status Filter & Custom Date Range */} +
+ {/* Status Filter */} +
+ + สถานะ: + + + + +
+ + {/* Custom Date Range */} + {period === 'custom' && ( +
+ ตั้งแต่วันที่: + 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" + /> + ถึง + 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" + /> +
+ )} +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Product Total Revenue */} +
+
+

ยอดขายสินค้านี้รวม

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+ {/* Split bar */} +
+
+
+
+
+
+
+ TT {kpi?.tiktokSharePercent?.toFixed(0)}% + SP {kpi?.shopeeSharePercent?.toFixed(0)}% + LZ {kpi?.lazadaSharePercent?.toFixed(0)}% +
+
+
+
+ + {/* Total Units Sold */} +
+
+

จำนวนชิ้นที่ขายได้

+ +
+
+

+ {fmtNum(kpi?.totalUnitsSold || 0)} ชิ้น +

+
+ TT: {fmtNum(kpi?.totalTiktokUnits || 0)} + SP: {fmtNum(kpi?.totalShopeeUnits || 0)} + LZ: {fmtNum(kpi?.totalLazadaUnits || 0)} +
+
+
+ + {/* Province Coverage */} +
+
+

การกระจายตัวในจังหวัด

+ +
+
+

+ {kpi?.coverageCount || 0} / 77 จังหวัด +

+

+ ครอบคลุม {kpi?.coveragePercent?.toFixed(1)}% ของประเทศไทย +

+
+
+ + {/* Top Province */} +
+
+

จังหวัดที่ขายดีที่สุด

+ +
+
+

+ {kpi?.topProvince?.name ? `จ.${kpi.topProvince.name}` : '-'} +

+

+ {fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} ชิ้น +

+
+
+ + {/* Top Region */} +
+
+

ภูมิภาคยอดขายสูงสุด

+ +
+
+

+ {kpi?.topRegion?.name || '-'} +

+

+ {fmtBaht(kpi?.topRegion?.revenue || 0)} +

+
+
+
+ + {/* ── Main Section: Map View or Table View ─────────────────────────────── */} + {viewMode === 'map' ? ( +
+ {/* Map & Focus Card Container (8 cols) */} +
+
+
+
+ +

+ แผนที่ความหนาแน่น {metric === 'quantity' ? 'จำนวนชิ้นที่ขาย (Units)' : 'ยอดขาย (Revenue)'} +

+
+
+
+ + น้อย +
+
+ + มาก +
+
+
+ + {/* Thailand SVG Map */} +
+ {loading ? ( +
+ + กำลังประมวลผลข้อมูลแผนที่สินค้า... +
+ ) : ( + setSelectedProvince(provName)} + /> + )} +
+
+ + {/* Selected Province Details Card */} + {selectedProvinceDetail && ( +
+
+
+
+ +

จังหวัด{selectedProvinceDetail.nameTh}

+ ({selectedProvinceDetail.nameEn}) + + {selectedProvinceDetail.regionTh} + +
+
+ ยอดขาย: {fmtBaht(selectedProvinceDetail.revenue)} + จำนวนที่ขาย: {selectedProvinceDetail.unitsSold} ชิ้น + คำสั่งซื้อ: {selectedProvinceDetail.ordersCount} ออเดอร์ +
+ + {/* Platform Breakdown Mini Bar */} +
+ สัดส่วนช่องทาง: +
+
+
+
+
+ TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)} + SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)} + LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)} +
+
+ + +
+
+ )} +
+ + {/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */} +
+ {/* Top 20 Provinces Card */} +
+
+
+ +

Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด

+
+ By {metric} +
+ +
+ {mapData?.topProvinces?.map((item: any) => { + const isSelected = selectedProvince === item.nameTh; + return ( +
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' + }`} + > +
+
+ + {item.rank} + +
+

{item.nameTh}

+

{item.unitsSold} ชิ้น

+
+
+
+

{fmtBaht(item.revenue)}

+
+ TT {item.tiktokShare?.toFixed(0)}% + • + SP {item.shopeeShare?.toFixed(0)}% + • + LZ {item.lazadaShare?.toFixed(0)}% +
+
+
+ + {/* Mini platform share bar */} +
+
+
+
+
+
+ ); + })} +
+
+ + {/* Region Breakdown Card */} +
+
+ +

สัดส่วนตามภูมิภาค

+
+ +
+ {mapData?.regionBreakdown?.map((reg: any) => ( +
+
+ {reg.regionTh} + + {fmtBaht(reg.revenue)}{' '} + ({reg.unitsSold} ชิ้น) + +
+
+
+
+
+
+
+ ))} +
+
+
+
+ ) : ( + /* ── Table View: 77 Provinces ────────────────────────────────────────── */ +
+
+
+ + 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" + /> +
+

+ แสดง {filteredProvinces.length} จาก 77 จังหวัด +

+
+ +
+ + + + + + + + + + + + + + + + {filteredProvinces.length === 0 ? ( + + + + ) : ( + filteredProvinces.map((p: any) => ( + + + + + + + + + + + + + )) + )} + +
รหัสจังหวัดภูมิภาคยอดรวม (฿)จำนวนที่ขาย (ชิ้น)TikTok (฿)Shopee (฿)Lazada (฿)สัดส่วนช่องทาง +
+ ไม่พบข้อมูลจังหวัดตามคำค้นหา +
{p.code} + {p.nameTh} ({p.nameEn}) + + + {p.regionTh} + + + {p.revenue > 0 ? fmtBaht(p.revenue) : '-'} + + {p.unitsSold > 0 ? `${p.unitsSold} ชิ้น` : '-'} + + {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} + + {p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'} + + {p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'} + + {p.revenue > 0 ? ( +
+
+
+
+
+
+
+ {p.tiktokShare?.toFixed(0)}% + {p.shopeeShare?.toFixed(0)}% + {p.lazadaShare?.toFixed(0)}% +
+
+ ) : ( + - + )} +
+ +
+
+
+ )} + + {/* ── Product District Level Modal ─────────────────────────────────────── */} + {showDistrictModal && selectedProvince && ( + setShowDistrictModal(false)} + /> + )} +
+ ); +} diff --git a/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx new file mode 100644 index 0000000..bcb893d --- /dev/null +++ b/apps/admin/src/pages/tools/MarketplaceProductsPage.tsx @@ -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(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([]); + const [mappingModalItem, setMappingModalItem] = useState(null); + const [variantSearch, setVariantSearch] = useState(''); + const [selectedVariantId, setSelectedVariantId] = useState(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 ( +
+ {/* ── Toast Notification ──────────────────────────────────────────────── */} + {toast && ( +
+ {toast.type === 'success' ? ( + + ) : ( + + )} + {toast.message} +
+ )} + + {/* ── Page Header ──────────────────────────────────────────────────────── */} +
+
+
+ +
+
+
+

Marketplace Products & SKU Manager

+ + Tool • TikTok + Shopee + Lazada + +
+

+ รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร +

+
+
+ + {/* Action Buttons */} +
+ + + +
+
+ + {/* ── KPI Summary Cards ─────────────────────────────────────────────────── */} +
+ {/* Total SKUs */} +
+
+

สินค้าบน Marketplace

+ +
+
+

+ {fmtNum(kpi?.totalSkus || 0)} รายการ/SKU +

+

+ รวมทุกรูปแบบสินค้าที่เคยขายได้ +

+
+
+ + {/* Mapping Progress */} +
+
+

สถานะการผูก Master SKU

+ +
+
+
+

+ {kpi?.mappedSkus || 0} / {kpi?.totalSkus || 0} +

+ + {kpi?.mappedPercent?.toFixed(1)}% + +
+ {/* Progress bar */} +
+
+
+

+ ยังไม่ผูก: {kpi?.unmappedSkus || 0} รายการ +

+
+
+ + {/* Total Sold Units */} +
+
+

จำนวนชิ้นที่ขายได้รวม

+ +
+
+

+ {fmtNum(kpi?.totalSoldQty || 0)} ชิ้น +

+

+ ยอดขายรวมสะสมจากทุกออเดอร์ +

+
+
+ + {/* Total Revenue */} +
+
+

ยอดขายรวม (Gross)

+ +
+
+

+ {fmtBaht(kpi?.totalRevenue || 0)} +

+

+ ยอดเงินที่ได้รับจากลูกค้า +

+
+
+ + {/* Est. Gross Profit (From Linked BOM) */} +
+
+

กำไรขั้นต้นประเมิน (BOM)

+ +
+
+

+ {fmtBaht(kpi?.totalEstProfit || 0)} +

+

+ Margin รวม: {kpi?.grossProfitMargin?.toFixed(1)}% +

+
+
+
+ + {/* ── Filter Bar ────────────────────────────────────────────────────────── */} +
+
+ {/* Channel Tabs */} +
+ + + + +
+ + {/* Shop Selector */} +
+ ร้านค้า: + +
+ + {/* Status Filter */} +
+ + + +
+
+ + {/* Secondary: Search & Sort */} +
+
+ + { + 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" + /> +
+ +
+
+ เรียงตาม: + +
+
+
+
+ + {/* ── Marketplace Products Table ────────────────────────────────────────── */} +
+
+ + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : items.length === 0 ? ( + + + + ) : ( + items.map((item: any) => { + const hasMapping = !!item.masterVariantId && !!item.masterVariant; + const profit = item.profitability; + + return ( + + {/* Platform & Shop */} + + + {/* Product Name & Variation */} + + + {/* Seller SKU */} + + + {/* Sold Qty */} + + + {/* Total Revenue */} + + + {/* Avg Price */} + + + {/* Master Core Catalog Mapping */} + + + {/* BOM Cost */} + + + {/* Estimated Profit */} + + + {/* Actions */} + + + ); + }) + )} + +
ช่องทาง / ร้านค้าชื่อสินค้า & ตัวเลือก (Marketplace)Seller SKUจำนวนที่ขายยอดขายรวม (฿)ราคาเฉลี่ยสินค้า Master (Core Catalog)ต้นทุน BOM (฿)กำไรประเมินจัดการ
+ + กำลังโหลดข้อมูลสินค้า... +
+

ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก

+ +
+
+ {item.channel === 'tiktok' ? ( + + TikTok + + ) : item.channel === 'shopee' ? ( + + Shopee + + ) : ( + + Lazada + + )} +

{item.shop}

+
+
+

+ {item.productName} +

+ {item.variation && ( + + ตัวเลือก: {item.variation} + + )} +
+ {item.marketplaceSku ? ( + + {item.marketplaceSku} + + ) : ( + - + )} + + {fmtNum(item.totalSoldQty)} ชิ้น + + {fmtBaht(item.totalRevenue)} + + {fmtBaht(item.avgPrice)} + + {hasMapping ? ( +
+
+ + + {item.masterVariant?.productName} + +
+
+ SKU: {item.masterVariant?.sku || '-'} + {item.masterVariant?.option1 && • {item.masterVariant.option1}} +
+
+ ) : ( + + ยังไม่ผูก SKU + + )} +
+ {hasMapping && profit?.unitBomCost > 0 ? ( +
+ {fmtBaht(profit.unitBomCost)}/ชิ้น +

รวม {fmtBaht(profit.totalCost)}

+
+ ) : hasMapping ? ( + ยังไม่ตั้ง BOM + ) : ( + - + )} +
+ {hasMapping && profit ? ( +
+ {fmtBaht(profit.profit)} +

{profit.marginPercent.toFixed(1)}% margin

+
+ ) : ( + - + )} +
+ +
+
+ + {/* Pagination Footer */} + {pagination && pagination.totalPages > 1 && ( +
+

+ แสดงหน้าที่ {pagination.page} จาก{' '} + {pagination.totalPages} (ทั้งหมด {fmtNum(pagination.total)} รายการ) +

+
+ + +
+
+ )} +
+ + {/* ── Mapping Modal ────────────────────────────────────────────────────── */} + {mappingModalItem && ( +
+
+ {/* Modal Header */} +
+
+
+ +
+
+

ผูกสินค้า Marketplace เข้า Master Catalog

+

เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้

+
+
+ +
+ + {/* Target Marketplace Item Summary */} +
+
+ สินค้าบน {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}): + {mappingModalItem.marketplaceSku && ( + + SKU: {mappingModalItem.marketplaceSku} + + )} +
+

{mappingModalItem.productName}

+ {mappingModalItem.variation && ( +

ตัวเลือก: {mappingModalItem.variation}

+ )} +
+ ขายแล้ว: {fmtNum(mappingModalItem.totalSoldQty)} ชิ้น + ยอดขาย: {fmtBaht(mappingModalItem.totalRevenue)} + ราคาเฉลี่ย: {fmtBaht(mappingModalItem.avgPrice)} +
+
+ + {/* Search & Variants List */} +
+
+ + 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 + /> +
+ +
+ {filteredMasterVariants.length === 0 ? ( +
+ ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา +
+ ) : ( + filteredMasterVariants.map((variant) => { + const isSelected = selectedVariantId === variant.id; + return ( +
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' + }`} + > +
+

{variant.productName}

+
+ SKU: {variant.sku || '-'} + {variant.optionsStr && • {variant.optionsStr}} + {variant.categoryName && • {variant.categoryName}} +
+
+ +
+

{fmtBaht(variant.price)}

+

+ ต้นทุน BOM: {fmtBaht(variant.bomCost)} +

+
+
+ ); + }) + )} +
+
+ + {/* Modal Footer */} +
+ {mappingModalItem.masterVariantId ? ( + + ) :
} + +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql b/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql new file mode 100644 index 0000000..a5c312a --- /dev/null +++ b/apps/backend/prisma/migrations/20260906094000_add_marketplace_product/migration.sql @@ -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; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0329069..b7efe90 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -112,6 +112,7 @@ model ProductVariant { productId Int product Product @relation(fields: [productId], references: [id]) bomItems BomItem[] + marketplaceProducts MarketplaceProduct[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -497,6 +498,12 @@ model TiktokOrder { shippingFee Float @default(0) // Shipping Fee After Discount originalShipping 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 province String? district String? @@ -509,9 +516,9 @@ model TiktokOrder { deliveredTime String? cancelledTime String? - items TiktokOrderItem[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + items TiktokOrderItem[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt @@index([shop]) @@index([status]) @@ -537,6 +544,44 @@ model TiktokOrderItem { 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 ────────────────────────────────────────────────────────────── model LazadaOrder { id Int @id @default(autoincrement()) @@ -715,3 +760,33 @@ model ContactMessage { createdAt DateTime @default(now()) 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]) +} + diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 14f27bf..85e6d0e 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -29,6 +29,8 @@ import { mediaRoute } from "./routes/media"; import { contactsRoute } from "./routes/contacts"; import { productAttributesRoute } from "./routes/product-attributes"; 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 CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173"; const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand"; @@ -136,7 +138,7 @@ const app = new Elysia() return file; }) .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" }); diff --git a/apps/backend/src/routes/businessAnalytics.ts b/apps/backend/src/routes/businessAnalytics.ts new file mode 100644 index 0000000..e664468 --- /dev/null +++ b/apps/backend/src/routes/businessAnalytics.ts @@ -0,0 +1,1393 @@ +import { Elysia, t } from "elysia"; +import prisma from "../lib/prisma"; +import { THAILAND_PROVINCES, findProvince, findProvinceByPostcode, cleanDistrictName } from "../utils/thaiProvinces"; + +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 }; +} + +function extractLazadaAddress(order: any): { province: string; district: string } { + const reg = String(order.shippingRegion || "").trim(); + const city = String(order.shippingCity || "").trim(); + const postcode = String(order.shippingPostCode || "").trim(); + + // Try matching region or city first as province name + let matchedProv = findProvince(reg) || findProvince(city); + + // Fallback to 5-digit/2-digit Postcode lookup + if (!matchedProv && postcode) { + matchedProv = findProvinceByPostcode(postcode); + } + + const provinceName = matchedProv ? matchedProv.nameTh : (reg || city); + const districtName = cleanDistrictName(city) || cleanDistrictName(reg) || (postcode ? `รหัส ปณ. ${postcode}` : "ไม่ระบุอำเภอ"); + return { province: provinceName, district: districtName }; +} + +// Helper to filter out / include cancelled orders +function buildStatusFilter(statusFilter: string = "completed") { + // 'completed' (default, excludes cancelled) | 'all' (includes all) | 'cancelled' (only cancelled) + if (statusFilter === "all") { + return { + tiktok: {}, + shopee: {}, + lazada: {}, + }; + } + if (statusFilter === "cancelled") { + return { + tiktok: { status: { contains: "ยกเลิก" } }, + shopee: { status: { contains: "ยกเลิก" } }, + lazada: { status: { in: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, + }; + } + // Default 'completed': exclude cancelled + return { + tiktok: { status: { not: { contains: "ยกเลิก" } } }, + shopee: { status: { not: { contains: "ยกเลิก" } } }, + lazada: { status: { notIn: ["canceled", "cancelled", "ยกเลิกแล้ว", "Package Returned", "Lost by 3PL"] } }, + }; +} + +export const businessAnalyticsRoute = new Elysia({ prefix: "/business-analytics" }) + // ── GET Omnichannel Thailand Sales Map (TikTok + Shopee + Lazada) ────────── + .get("/sales-map", async ({ query }) => { + const channel = (query.channel as string) || "all"; // 'all' | 'tiktok' | 'shopee' | 'lazada' + const statusFilter = (query.statusFilter as string) || "completed"; // 'completed' | 'all' | 'cancelled' + const sf = buildStatusFilter(statusFilter); + + const tiktokShop = query.tiktokShop as string | undefined; + const shopeeShop = query.shopeeShop as string | undefined; + const lazadaShop = query.lazadaShop as string | undefined; + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + // 1. Fetch TikTok Orders if applicable + let tiktokOrders: any[] = []; + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (tiktokShop && tiktokShop !== "all") { + where.shop = tiktokShop; + } + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + tiktokOrders = await prisma.tiktokOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + province: true, + district: true, + orderAmount: true, + createdTime: true, + }, + }); + } + + // 2. Fetch Shopee Orders if applicable + let shopeeOrders: any[] = []; + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (shopeeShop && shopeeShop !== "all") { + where.shop = shopeeShop; + } + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + shopeeOrders = await prisma.shopeeOrder.findMany({ + where, + include: { + items: { + take: 1, + select: { rawData: true }, + }, + }, + }); + } + + // 3. Fetch Lazada Orders if applicable + let lazadaOrders: any[] = []; + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (lazadaShop && lazadaShop !== "all") { + where.shop = lazadaShop; + } + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + lazadaOrders = await prisma.lazadaOrder.findMany({ + where, + select: { + orderNumber: true, + shop: true, + shippingRegion: true, + shippingCity: true, + shippingPostCode: true, + orderAmount: true, + createTime: true, + }, + }); + } + + // Initialize 77 provinces + const provSalesMap = new Map(); + + 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, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + + let totalRevenue = 0; + let totalOrders = 0; + let totalTiktokRevenue = 0; + let totalTiktokOrders = 0; + let totalShopeeRevenue = 0; + let totalShopeeOrders = 0; + let totalLazadaRevenue = 0; + let totalLazadaOrders = 0; + let unmappedOrders = 0; + let unmappedRevenue = 0; + + // Aggregate TikTok + for (const o of tiktokOrders) { + const amount = o.orderAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalTiktokRevenue += amount; + totalTiktokOrders += 1; + + const matchedProv = findProvince(o.province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.tiktokRevenue += amount; + p.tiktokOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + // Aggregate Shopee + for (const o of shopeeOrders) { + const amount = o.totalAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalShopeeRevenue += amount; + totalShopeeOrders += 1; + + const { province } = extractShopeeAddress(o); + const matchedProv = findProvince(province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.shopeeRevenue += amount; + p.shopeeOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + // Aggregate Lazada + for (const o of lazadaOrders) { + const amount = o.orderAmount || 0; + totalRevenue += amount; + totalOrders += 1; + totalLazadaRevenue += amount; + totalLazadaOrders += 1; + + const { province } = extractLazadaAddress(o); + const matchedProv = findProvince(province); + if (matchedProv && provSalesMap.has(matchedProv.nameTh)) { + const p = provSalesMap.get(matchedProv.nameTh)!; + p.revenue += amount; + p.orders += 1; + p.lazadaRevenue += amount; + p.lazadaOrders += 1; + } else { + unmappedOrders += 1; + unmappedRevenue += amount; + } + } + + const provinceList = Array.from(provSalesMap.values()).map(p => ({ + ...p, + tiktokShare: p.revenue > 0 ? (p.tiktokRevenue / p.revenue) * 100 : 0, + shopeeShare: p.revenue > 0 ? (p.shopeeRevenue / p.revenue) * 100 : 0, + lazadaShare: p.revenue > 0 ? (p.lazadaRevenue / p.revenue) * 100 : 0, + aov: p.orders > 0 ? p.revenue / p.orders : 0, + })); + + const activeProvinces = provinceList.filter(p => p.orders > 0); + const coverageCount = activeProvinces.length; + + // Top 20 provinces by total revenue + const topProvinces = [...provinceList] + .sort((a, b) => b.revenue - a.revenue) + .slice(0, 20) + .map((p, idx) => ({ + rank: idx + 1, + ...p, + })); + + // Region Breakdown + const regionMap = new Map(); + + for (const p of provinceList) { + if (!regionMap.has(p.region)) { + regionMap.set(p.region, { + region: p.region, + regionTh: p.regionTh, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const r = regionMap.get(p.region)!; + r.revenue += p.revenue; + r.orders += p.orders; + r.tiktokRevenue += p.tiktokRevenue; + r.tiktokOrders += p.tiktokOrders; + r.shopeeRevenue += p.shopeeRevenue; + r.shopeeOrders += p.shopeeOrders; + r.lazadaRevenue += p.lazadaRevenue; + r.lazadaOrders += p.lazadaOrders; + } + + const regionBreakdown = Array.from(regionMap.values()) + .map(r => ({ + ...r, + percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, + tiktokShare: r.revenue > 0 ? (r.tiktokRevenue / r.revenue) * 100 : 0, + shopeeShare: r.revenue > 0 ? (r.shopeeRevenue / r.revenue) * 100 : 0, + lazadaShare: r.revenue > 0 ? (r.lazadaRevenue / r.revenue) * 100 : 0, + })) + .sort((a, b) => b.revenue - a.revenue); + + const topRegion = regionBreakdown[0] || { + region: 'Central', + regionTh: 'ภาคกลาง', + revenue: 0, + orders: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + }; + + return { + kpi: { + totalRevenue, + totalOrders, + totalTiktokRevenue, + totalTiktokOrders, + tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, + totalShopeeRevenue, + totalShopeeOrders, + shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, + totalLazadaRevenue, + totalLazadaOrders, + lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, + coverageCount, + coverageTotal: 77, + coveragePercent: (coverageCount / 77) * 100, + aov: totalOrders > 0 ? totalRevenue / totalOrders : 0, + tiktokAov: totalTiktokOrders > 0 ? totalTiktokRevenue / totalTiktokOrders : 0, + shopeeAov: totalShopeeOrders > 0 ? totalShopeeRevenue / totalShopeeOrders : 0, + lazadaAov: totalLazadaOrders > 0 ? totalLazadaRevenue / totalLazadaOrders : 0, + topRegion: { + name: topRegion.regionTh, + orders: topRegion.orders, + revenue: topRegion.revenue, + tiktokRevenue: topRegion.tiktokRevenue, + shopeeRevenue: topRegion.shopeeRevenue, + lazadaRevenue: topRegion.lazadaRevenue, + }, + unmappedOrders, + unmappedRevenue, + }, + provinces: provinceList, + topProvinces, + regionBreakdown, + }; + }) + + // ── GET District Level Details & Orders (Omnichannel: TT + SP + LAZ) ─────── + .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 channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + + const tiktokShop = query.tiktokShop as string | undefined; + const shopeeShop = query.shopeeShop as string | undefined; + const lazadaShop = query.lazadaShop as string | undefined; + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + // 1. Query TikTok Orders + let tiktokMatched: any[] = []; + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (tiktokShop && tiktokShop !== "all") { + where.shop = tiktokShop; + } + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const allTt = await prisma.tiktokOrder.findMany({ + where, + include: { items: true }, + orderBy: { createdTime: "desc" }, + }); + + tiktokMatched = allTt.filter(o => { + const p = findProvince(o.province); + return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || '')); + }); + } + + // 2. Query Shopee Orders + let shopeeMatched: any[] = []; + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (shopeeShop && shopeeShop !== "all") { + where.shop = shopeeShop; + } + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const allSp = await prisma.shopeeOrder.findMany({ + where, + include: { items: true }, + orderBy: { orderCreatedAt: "desc" }, + }); + + shopeeMatched = allSp.filter(o => { + const { province } = extractShopeeAddress(o); + const p = findProvince(province); + return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); + }); + } + + // 3. Query Lazada Orders + let lazadaMatched: any[] = []; + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (lazadaShop && lazadaShop !== "all") { + where.shop = lazadaShop; + } + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const allLaz = await prisma.lazadaOrder.findMany({ + where, + include: { items: true }, + orderBy: { createTime: "desc" }, + }); + + lazadaMatched = allLaz.filter(o => { + const { province } = extractLazadaAddress(o); + const p = findProvince(province); + return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || '')); + }); + } + + // Aggregate by District + const districtMap = new Map(); + + let provRevenue = 0; + let provTiktokRevenue = 0; + let provShopeeRevenue = 0; + let provLazadaRevenue = 0; + + for (const o of tiktokMatched) { + const amount = o.orderAmount || 0; + provRevenue += amount; + provTiktokRevenue += amount; + + const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.tiktokRevenue += amount; + d.tiktokOrders += 1; + } + + for (const o of shopeeMatched) { + const amount = o.totalAmount || 0; + provRevenue += amount; + provShopeeRevenue += amount; + + const { district } = extractShopeeAddress(o); + const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.shopeeRevenue += amount; + d.shopeeOrders += 1; + } + + for (const o of lazadaMatched) { + const amount = o.orderAmount || 0; + provRevenue += amount; + provLazadaRevenue += amount; + + const { district } = extractLazadaAddress(o); + const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ"; + if (!districtMap.has(dName)) { + districtMap.set(dName, { + districtName: dName, + revenue: 0, + orders: 0, + tiktokRevenue: 0, + tiktokOrders: 0, + shopeeRevenue: 0, + shopeeOrders: 0, + lazadaRevenue: 0, + lazadaOrders: 0, + }); + } + const d = districtMap.get(dName)!; + d.revenue += amount; + d.orders += 1; + d.lazadaRevenue += amount; + d.lazadaOrders += 1; + } + + const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue); + + // Normalize unified orders list + const unifiedOrders = [ + ...tiktokMatched.map(o => ({ + orderId: o.orderId, + channel: "tiktok" as const, + shop: o.shop, + status: o.status, + subStatus: o.subStatus, + buyerUsername: o.buyerUsername, + createdTime: o.createdTime, + orderAmount: o.orderAmount, + shippingFee: o.shippingFee, + province: o.province, + district: cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ", + itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), + items: o.items.map((i: any) => ({ + productName: i.productName, + variation: i.variation, + sellerSku: i.sellerSku, + quantity: i.quantity, + subtotalAfterDiscount: i.subtotalAfterDiscount, + unitOriginalPrice: i.unitOriginalPrice, + })), + })), + ...shopeeMatched.map(o => { + const { district, province } = extractShopeeAddress(o); + return { + orderId: o.orderId, + channel: "shopee" as const, + shop: o.shop, + status: o.status, + subStatus: null, + buyerUsername: o.buyerUsername, + createdTime: o.orderCreatedAt, + orderAmount: o.totalAmount, + shippingFee: o.shippingFee, + province: province || "ไม่ระบุจังหวัด", + district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", + itemsCount: o.items.reduce((s: number, i: any) => s + i.quantity, 0), + items: o.items.map((i: any) => ({ + productName: i.productName, + variation: i.variantName, + sellerSku: i.skuRef || i.parentSku, + quantity: i.quantity, + subtotalAfterDiscount: i.totalPayment, + unitOriginalPrice: i.originalPrice || i.dealPrice, + })), + }; + }), + ...lazadaMatched.map(o => { + const { district, province } = extractLazadaAddress(o); + return { + orderId: o.orderNumber, + channel: "lazada" as const, + shop: o.shop, + status: o.status, + subStatus: null, + buyerUsername: o.customerName || "ลูกค้า Lazada", + createdTime: o.createTime, + orderAmount: o.orderAmount, + shippingFee: o.shippingFee, + province: province || "ไม่ระบุจังหวัด", + district: cleanDistrictName(district) || district || "ไม่ระบุอำเภอ", + itemsCount: o.items.length, + items: o.items.map((i: any) => ({ + productName: i.itemName, + variation: i.variation, + sellerSku: i.sellerSku || i.lazadaSku, + quantity: 1, + subtotalAfterDiscount: i.paidPrice, + unitOriginalPrice: i.unitPrice || i.paidPrice, + })), + }; + }), + ].sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); + + return { + province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, + summary: { + totalRevenue: provRevenue, + totalOrders: unifiedOrders.length, + tiktokRevenue: provTiktokRevenue, + tiktokOrders: tiktokMatched.length, + shopeeRevenue: provShopeeRevenue, + shopeeOrders: shopeeMatched.length, + lazadaRevenue: provLazadaRevenue, + lazadaOrders: lazadaMatched.length, + districtCount: districts.length, + }, + districts, + orders: unifiedOrders, + }; + }) + + // ── 3. GET Master Products & Variants List (For Selection Dropdown) ───────── + .get("/products-list", async () => { + const products = await prisma.product.findMany({ + include: { + category: { select: { id: true, name: true } }, + brand: { select: { id: true, name: true } }, + variants: { + include: { + marketplaceProducts: { + select: { + id: true, + channel: true, + shop: true, + productName: true, + variation: true, + marketplaceSku: true, + totalSoldQty: true, + totalRevenue: true, + }, + }, + }, + }, + }, + orderBy: { name: "asc" }, + }); + + const formatted = products.map((p) => { + let totalMappedMarketplaceCount = 0; + let totalSoldUnits = 0; + let totalRevenue = 0; + + const variants = p.variants.map((v) => { + const mappedCount = v.marketplaceProducts.length; + totalMappedMarketplaceCount += mappedCount; + const vSold = v.marketplaceProducts.reduce((s, mp) => s + mp.totalSoldQty, 0); + const vRev = v.marketplaceProducts.reduce((s, mp) => s + mp.totalRevenue, 0); + totalSoldUnits += vSold; + totalRevenue += vRev; + + const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); + return { + id: v.id, + sku: v.sku || "", + barcode: v.barcode || "", + price: v.price || 0, + stock: v.stock || 0, + optionsStr, + displayName: `${optionsStr || "Default"} ${v.sku ? `[${v.sku}]` : ""}`.trim(), + mappedMarketplaceCount: mappedCount, + soldUnits: vSold, + revenue: vRev, + }; + }); + + return { + id: p.id, + name: p.name || "Unnamed Product", + category: p.category?.name || "", + brand: p.brand?.name || "", + totalMappedMarketplaceCount, + totalSoldUnits, + totalRevenue, + variants, + }; + }); + + return { products: formatted }; + }) + + // ── 4. GET Product Geographical Sales Map (77 Provinces) ──────────────────── + .get("/sales-map-products", async ({ query }) => { + const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; + const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + const metric = (query.metric as string) || "revenue"; + + // 1. Resolve master variants to filter + let targetVariantIds: number[] = []; + let productInfo: any = null; + let variantInfo: any = null; + + if (variantId) { + targetVariantIds = [variantId]; + const v = await prisma.productVariant.findUnique({ + where: { id: variantId }, + include: { product: { include: { category: true } } }, + }); + if (v) { + const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / "); + variantInfo = { + id: v.id, + sku: v.sku, + optionsStr, + productName: v.product?.name, + categoryName: v.product?.category?.name, + }; + productInfo = { + id: v.product?.id, + name: v.product?.name, + }; + } + } else if (productId) { + const p = await prisma.product.findUnique({ + where: { id: productId }, + include: { variants: true, category: true }, + }); + if (p) { + targetVariantIds = p.variants.map((v) => v.id); + productInfo = { + id: p.id, + name: p.name, + categoryName: p.category?.name, + variantsCount: p.variants.length, + }; + } + } + + // 2. Fetch linked MarketplaceProducts + const mappedMp = await prisma.marketplaceProduct.findMany({ + where: { + ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), + ...(channel !== "all" ? { channel } : {}), + }, + }); + + const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); + const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); + const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); + + const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + // 3. Query Orders with Line Items + let matchedLineItems: Array<{ + channel: "tiktok" | "shopee" | "lazada"; + shop: string; + orderId: string; + province: string; + district: string; + quantity: number; + revenue: number; + createdTime: string | null; + }> = []; + + // A. TikTok Items + if ((channel === "all" || channel === "tiktok") && (tiktokKeys.size > 0 || tiktokSkus.size > 0 || tiktokNames.size > 0)) { + const where: any = { ...sf.tiktok }; + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const tiktokOrders = await prisma.tiktokOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + province: true, + district: true, + createdTime: true, + items: true, + }, + }); + + for (const o of tiktokOrders) { + const prov = o.province || ""; + const dist = o.district || ""; + for (const it of o.items) { + const sku = it.sellerSku || it.skuId || ""; + const name = it.productName || ""; + const variation = it.variation || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + tiktokKeys.has(key) || + (sku && tiktokSkus.has(sku.trim().toLowerCase())) || + (name && tiktokNames.has(name.trim().toLowerCase()) && (!variation || tiktokMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = it.quantity || 1; + const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * qty; + matchedLineItems.push({ + channel: "tiktok", + shop, + orderId: o.orderId, + province: prov, + district: dist, + quantity: qty, + revenue: rev, + createdTime: o.createdTime, + }); + } + } + } + } + + // B. Shopee Items + if ((channel === "all" || channel === "shopee") && (shopeeKeys.size > 0 || shopeeSkus.size > 0 || shopeeNames.size > 0)) { + const where: any = { ...sf.shopee }; + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const shopeeOrders = await prisma.shopeeOrder.findMany({ + where, + select: { + orderId: true, + shop: true, + orderCreatedAt: true, + items: true, + }, + }); + + for (const o of shopeeOrders) { + const { province, district } = extractShopeeAddress(o); + for (const it of o.items) { + const sku = it.skuRef || it.parentSku || ""; + const name = it.productName || ""; + const variation = it.variantName || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + shopeeKeys.has(key) || + (sku && shopeeSkus.has(sku.trim().toLowerCase())) || + (name && shopeeNames.has(name.trim().toLowerCase()) && (!variation || shopeeMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = it.quantity || 1; + const rev = it.totalPayment > 0 ? it.totalPayment : it.dealPrice * qty; + matchedLineItems.push({ + channel: "shopee", + shop, + orderId: o.orderId, + province, + district, + quantity: qty, + revenue: rev, + createdTime: o.orderCreatedAt, + }); + } + } + } + } + + // C. Lazada Items + if ((channel === "all" || channel === "lazada") && (lazadaKeys.size > 0 || lazadaSkus.size > 0 || lazadaNames.size > 0)) { + const where: any = { ...sf.lazada }; + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const lazadaOrders = await prisma.lazadaOrder.findMany({ + where, + select: { + orderNumber: true, + shop: true, + createTime: true, + shippingRegion: true, + shippingCity: true, + shippingPostCode: true, + items: true, + }, + }); + + for (const o of lazadaOrders) { + const { province, district } = extractLazadaAddress(o); + for (const it of o.items) { + const sku = it.sellerSku || it.lazadaSku || ""; + const name = it.itemName || ""; + const variation = it.variation || ""; + const shop = o.shop || "hom-aroma"; + const key = `${shop}:::${name}:::${variation}:::${sku}`; + + const isMatch = + lazadaKeys.has(key) || + (sku && lazadaSkus.has(sku.trim().toLowerCase())) || + (name && lazadaNames.has(name.trim().toLowerCase()) && (!variation || lazadaMps.some((m) => m.variation === variation))); + + if (isMatch) { + const qty = 1; + const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice; + matchedLineItems.push({ + channel: "lazada", + shop, + orderId: o.orderNumber, + province, + district, + quantity: qty, + revenue: rev, + createdTime: o.createTime, + }); + } + } + } + } + + // 4. Map & Aggregate by 77 Provinces + const provMap: Record = {}; + + for (const p of THAILAND_PROVINCES) { + provMap[p.nameTh] = { + code: p.code, + nameTh: p.nameTh, + nameEn: p.nameEn, + region: p.region, + regionTh: p.regionTh, + revenue: 0, + unitsSold: 0, + ordersCount: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + tiktokUnits: 0, + shopeeUnits: 0, + lazadaUnits: 0, + }; + } + + let totalRevenue = 0; + let totalUnitsSold = 0; + let totalTiktokRevenue = 0; + let totalShopeeRevenue = 0; + let totalLazadaRevenue = 0; + let totalTiktokUnits = 0; + let totalShopeeUnits = 0; + let totalLazadaUnits = 0; + const uniqueOrderIds = new Set(); + + for (const it of matchedLineItems) { + const provMatch = findProvince(it.province); + const provNameTh = provMatch ? provMatch.nameTh : "กรุงเทพมหานคร"; + + if (provMap[provNameTh]) { + provMap[provNameTh].revenue += it.revenue; + provMap[provNameTh].unitsSold += it.quantity; + provMap[provNameTh].ordersCount += 1; + + if (it.channel === "tiktok") { + provMap[provNameTh].tiktokRevenue += it.revenue; + provMap[provNameTh].tiktokUnits += it.quantity; + totalTiktokRevenue += it.revenue; + totalTiktokUnits += it.quantity; + } else if (it.channel === "shopee") { + provMap[provNameTh].shopeeRevenue += it.revenue; + provMap[provNameTh].shopeeUnits += it.quantity; + totalShopeeRevenue += it.revenue; + totalShopeeUnits += it.quantity; + } else if (it.channel === "lazada") { + provMap[provNameTh].lazadaRevenue += it.revenue; + provMap[provNameTh].lazadaUnits += it.quantity; + totalLazadaRevenue += it.revenue; + totalLazadaUnits += it.quantity; + } + + totalRevenue += it.revenue; + totalUnitsSold += it.quantity; + uniqueOrderIds.add(it.orderId); + } + } + + const provinces = Object.values(provMap).map((p: any) => { + const pRev = p.revenue; + const ttShare = pRev > 0 ? (p.tiktokRevenue / pRev) * 100 : 0; + const spShare = pRev > 0 ? (p.shopeeRevenue / pRev) * 100 : 0; + const lzShare = pRev > 0 ? (p.lazadaRevenue / pRev) * 100 : 0; + + return { + ...p, + tiktokShare: ttShare, + shopeeShare: spShare, + lazadaShare: lzShare, + aov: p.ordersCount > 0 ? p.revenue / p.ordersCount : 0, + }; + }); + + const coveredProvinces = provinces.filter((p) => p.revenue > 0); + const coverageCount = coveredProvinces.length; + const coveragePercent = (coverageCount / 77) * 100; + + const sortedByMetric = [...provinces].sort((a, b) => { + return metric === "quantity" ? b.unitsSold - a.unitsSold : b.revenue - a.revenue; + }); + + const topProvinces = sortedByMetric.slice(0, 20).map((p, idx) => ({ + ...p, + rank: idx + 1, + })); + + const topProvince = + topProvinces.length > 0 && (topProvinces[0].revenue > 0 || topProvinces[0].unitsSold > 0) ? topProvinces[0] : null; + + // Region breakdown + const regionsMap: Record = {}; + + for (const p of provinces) { + if (!regionsMap[p.region]) { + regionsMap[p.region] = { + region: p.region, + regionTh: p.regionTh, + revenue: 0, + unitsSold: 0, + tiktokRevenue: 0, + shopeeRevenue: 0, + lazadaRevenue: 0, + orders: 0, + }; + } + regionsMap[p.region].revenue += p.revenue; + regionsMap[p.region].unitsSold += p.unitsSold; + regionsMap[p.region].tiktokRevenue += p.tiktokRevenue; + regionsMap[p.region].shopeeRevenue += p.shopeeRevenue; + regionsMap[p.region].lazadaRevenue += p.lazadaRevenue; + regionsMap[p.region].orders += p.ordersCount; + } + + const regionBreakdown = Object.values(regionsMap) + .map((r: any) => ({ + ...r, + percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0, + })) + .sort((a: any, b: any) => b.revenue - a.revenue); + + return { + product: productInfo, + variant: variantInfo, + mappedMarketplaceCount: mappedMp.length, + kpi: { + totalRevenue, + totalUnitsSold, + totalOrders: uniqueOrderIds.size, + aov: uniqueOrderIds.size > 0 ? totalRevenue / uniqueOrderIds.size : 0, + coverageCount, + coveragePercent, + topProvince: topProvince + ? { name: topProvince.nameTh, revenue: topProvince.revenue, unitsSold: topProvince.unitsSold } + : null, + topRegion: regionBreakdown.length > 0 ? { name: regionBreakdown[0].regionTh, revenue: regionBreakdown[0].revenue } : null, + totalTiktokRevenue, + totalShopeeRevenue, + totalLazadaRevenue, + totalTiktokUnits, + totalShopeeUnits, + totalLazadaUnits, + tiktokSharePercent: totalRevenue > 0 ? (totalTiktokRevenue / totalRevenue) * 100 : 0, + shopeeSharePercent: totalRevenue > 0 ? (totalShopeeRevenue / totalRevenue) * 100 : 0, + lazadaSharePercent: totalRevenue > 0 ? (totalLazadaRevenue / totalRevenue) * 100 : 0, + }, + provinces, + topProvinces, + regionBreakdown, + }; + }) + + // ── 5. GET Product District Level Sales Map & Orders ─────────────────────── + .get("/sales-map-products/district", async ({ query }) => { + const provinceName = (query.province as string) || "กรุงเทพมหานคร"; + const productId = query.productId ? parseInt(query.productId as string, 10) : undefined; + const variantId = query.variantId ? parseInt(query.variantId as string, 10) : undefined; + const channel = (query.channel as string) || "all"; + const statusFilter = (query.statusFilter as string) || "completed"; + const sf = buildStatusFilter(statusFilter); + + const dateFrom = query.dateFrom as string | undefined; + const dateTo = query.dateTo as string | undefined; + + let targetVariantIds: number[] = []; + if (variantId) { + targetVariantIds = [variantId]; + } else if (productId) { + const p = await prisma.product.findUnique({ + where: { id: productId }, + include: { variants: true }, + }); + if (p) targetVariantIds = p.variants.map((v) => v.id); + } + + const mappedMp = await prisma.marketplaceProduct.findMany({ + where: { + ...(targetVariantIds.length > 0 ? { masterVariantId: { in: targetVariantIds } } : {}), + ...(channel !== "all" ? { channel } : {}), + }, + }); + + const tiktokMps = mappedMp.filter((m) => m.channel === "tiktok"); + const shopeeMps = mappedMp.filter((m) => m.channel === "shopee"); + const lazadaMps = mappedMp.filter((m) => m.channel === "lazada"); + + const tiktokKeys = new Set(tiktokMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const tiktokSkus = new Set(tiktokMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const tiktokNames = new Set(tiktokMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const shopeeKeys = new Set(shopeeMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const shopeeSkus = new Set(shopeeMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const shopeeNames = new Set(shopeeMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const lazadaKeys = new Set(lazadaMps.map((m) => `${m.shop}:::${m.productName}:::${m.variation}:::${m.marketplaceSku}`)); + const lazadaSkus = new Set(lazadaMps.map((m) => m.marketplaceSku.trim().toLowerCase()).filter(Boolean)); + const lazadaNames = new Set(lazadaMps.map((m) => m.productName.trim().toLowerCase()).filter(Boolean)); + + const targetProv = findProvince(provinceName); + const provNameTh = targetProv ? targetProv.nameTh : provinceName; + + const districtMap: Record = {}; + const matchedOrders: any[] = []; + + // A. TikTok + if (channel === "all" || channel === "tiktok") { + const where: any = { ...sf.tiktok }; + if (dateFrom || dateTo) { + where.createdTime = {}; + if (dateFrom) where.createdTime.gte = dateFrom; + if (dateTo) where.createdTime.lte = dateTo + " 23:59"; + } + + const ttOrders = await prisma.tiktokOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of ttOrders) { + const p = findProvince(o.province || ""); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.sellerSku || it.skuId || ""; + const name = it.productName || ""; + const variation = it.variation || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return tiktokKeys.has(key) || (sku && tiktokSkus.has(sku.trim().toLowerCase())) || (name && tiktokNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce( + (s, it) => s + (it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : it.unitOriginalPrice * it.quantity), + 0 + ); + const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); + const dName = cleanDistrictName(o.district) || o.district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].tiktokRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderId, + channel: "tiktok", + shop: o.shop, + buyerUsername: o.buyerUsername || "ลูกค้า TikTok", + createdTime: o.createdTime, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.productName, + variation: it.variation, + sellerSku: it.sellerSku || it.skuId, + quantity: it.quantity, + subtotalAfterDiscount: it.subtotalAfterDiscount || it.unitOriginalPrice * it.quantity, + })), + }); + } + } + } + } + + // B. Shopee + if (channel === "all" || channel === "shopee") { + const where: any = { ...sf.shopee }; + if (dateFrom || dateTo) { + where.orderCreatedAt = {}; + if (dateFrom) where.orderCreatedAt.gte = dateFrom; + if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59"; + } + + const spOrders = await prisma.shopeeOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of spOrders) { + const { province, district } = extractShopeeAddress(o); + const p = findProvince(province); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.skuRef || it.parentSku || ""; + const name = it.productName || ""; + const variation = it.variantName || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return shopeeKeys.has(key) || (sku && shopeeSkus.has(sku.trim().toLowerCase())) || (name && shopeeNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce( + (s, it) => s + (it.totalPayment > 0 ? it.totalPayment : it.dealPrice * it.quantity), + 0 + ); + const itemQty = matchingItems.reduce((s, it) => s + it.quantity, 0); + const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].shopeeRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderId, + channel: "shopee", + shop: o.shop, + buyerUsername: o.buyerUsername || "ลูกค้า Shopee", + createdTime: o.orderCreatedAt, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.productName, + variation: it.variantName, + sellerSku: it.skuRef || it.parentSku, + quantity: it.quantity, + subtotalAfterDiscount: it.totalPayment || it.dealPrice * it.quantity, + })), + }); + } + } + } + } + + // C. Lazada + if (channel === "all" || channel === "lazada") { + const where: any = { ...sf.lazada }; + if (dateFrom || dateTo) { + where.createTime = {}; + if (dateFrom) where.createTime.gte = dateFrom; + if (dateTo) where.createTime.lte = dateTo + " 23:59"; + } + + const lzOrders = await prisma.lazadaOrder.findMany({ + where, + include: { items: true }, + }); + + for (const o of lzOrders) { + const { province, district } = extractLazadaAddress(o); + const p = findProvince(province); + if (p && p.nameTh === provNameTh) { + const matchingItems = o.items.filter((it) => { + const sku = it.sellerSku || it.lazadaSku || ""; + const name = it.itemName || ""; + const variation = it.variation || ""; + const key = `${o.shop}:::${name}:::${variation}:::${sku}`; + return lazadaKeys.has(key) || (sku && lazadaSkus.has(sku.trim().toLowerCase())) || (name && lazadaNames.has(name.trim().toLowerCase())); + }); + + if (matchingItems.length > 0) { + const itemRev = matchingItems.reduce((s, it) => s + (it.paidPrice > 0 ? it.paidPrice : it.unitPrice), 0); + const itemQty = matchingItems.length; + const dName = cleanDistrictName(district) || district || "ไม่ระบุอำเภอ"; + + if (!districtMap[dName]) { + districtMap[dName] = { districtName: dName, revenue: 0, unitsSold: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 }; + } + districtMap[dName].revenue += itemRev; + districtMap[dName].unitsSold += itemQty; + districtMap[dName].orders += 1; + districtMap[dName].lazadaRevenue += itemRev; + + matchedOrders.push({ + orderId: o.orderNumber, + channel: "lazada", + shop: o.shop, + buyerUsername: o.customerName || "ลูกค้า Lazada", + createdTime: o.createTime, + orderAmount: itemRev, + shippingFee: o.shippingFee, + province: provNameTh, + district: dName, + items: matchingItems.map((it) => ({ + productName: it.itemName, + variation: it.variation, + sellerSku: it.sellerSku || it.lazadaSku, + quantity: 1, + subtotalAfterDiscount: it.paidPrice || it.unitPrice, + })), + }); + } + } + } + } + + const districts = Object.values(districtMap).sort((a: any, b: any) => b.revenue - a.revenue); + matchedOrders.sort((a, b) => String(b.createdTime || "").localeCompare(String(a.createdTime || ""))); + + return { + province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: "Central", regionTh: "ภาคกลาง", code: "" }, + summary: { + totalRevenue: districts.reduce((s: number, d: any) => s + d.revenue, 0), + totalUnitsSold: districts.reduce((s: number, d: any) => s + d.unitsSold, 0), + totalOrders: matchedOrders.length, + districtCount: districts.length, + }, + districts, + orders: matchedOrders, + }; + }); + diff --git a/apps/backend/src/routes/shopee.ts b/apps/backend/src/routes/shopee.ts index 7585a68..1895d6a 100644 --- a/apps/backend/src/routes/shopee.ts +++ b/apps/backend/src/routes/shopee.ts @@ -1,5 +1,6 @@ import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; +import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces"; const toNum = (val: any): number => { if (val === null || val === undefined || val === "") return 0; @@ -8,6 +9,13 @@ const toNum = (val: any): number => { 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" }) // ── Import Orders ───────────────────────────────────────────────────────── .post( @@ -340,4 +348,212 @@ export const shopeeRoute = new Elysia({ prefix: "/shopee" }) totalItems: itemAgg._sum.quantity ?? 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(); + + 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(); + 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(); + 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, + })), + }; + }), + }; }); diff --git a/apps/backend/src/routes/tiktokOrder.ts b/apps/backend/src/routes/tiktokOrder.ts index e6b6796..3f273d4 100644 --- a/apps/backend/src/routes/tiktokOrder.ts +++ b/apps/backend/src/routes/tiktokOrder.ts @@ -7,7 +7,7 @@ const toNum = (val: any): number => { const s = String(val).replace(/[^0-9.\-]/g, ""); const n = parseFloat(s); return isNaN(n) ? 0 : n; -}; + }; // TikTok CSV values can have trailing \t — trim everything const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim(); @@ -26,9 +26,216 @@ const parseTiktokDate = (val: any): string => { 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(); + for (const s of settlements) { + settlementMap.set(s.orderId, s); + } + + // Index MarketplaceProducts + const mpExactMap = new Map(); // shop:::productName:::variation:::sku + const mpSkuMap = new Map(); // shop:::sku + const mpNameVarMap = new Map(); // shop:::productName:::variation + const variantSkuMap = new Map(); // 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" }) - // ── Import (CSV rows as JSON) ────────────────────────────────────────────── + // ── Import Orders (CSV rows as JSON) ─────────────────────────────────────── .post( "/import", async ({ body, set }) => { @@ -57,6 +264,8 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) const chunk = entries.slice(i, i + CHUNK); await prisma.$transaction(async (tx) => { for (const [orderId, { meta, items }] of chunk) { + const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } }); + // Delete old items first (sync strategy) await tx.tiktokOrderItem.deleteMany({ where: { orderId } }); @@ -104,6 +313,11 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) shippedTime: parseTiktokDate(meta["Shipped Time"]), deliveredTime: parseTiktokDate(meta["Delivered 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({ @@ -114,7 +328,6 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) upserted++; } }, { timeout: 30000 }); - console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`); } 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()) }) } ) + // ── 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 ─────────────────────────────────────────────────────── .delete("/clear", async ({ query, set }) => { try { const shop = (query.shop as string) || "hom-aroma"; - const result = await prisma.tiktokOrder.deleteMany({ where: { shop } }); - return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count }; + const [orderResult, settlementResult] = await Promise.all([ + 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) { + console.error(`[TiktokOrder] ❌ Clear Error:`, error.message); set.status = 500; return { success: false, message: error.message }; } }) - // ── GET Single Order ────────────────────────────────────────────────────── + // ── GET Single Order Detail (with BOM Breakdown & Settlement) ────────────── .get("/orders/:orderId", async ({ params, set }) => { - const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } }); - if (!order) { set.status = 404; return { error: "Order not found" }; } - return order; + const order = await prisma.tiktokOrder.findUnique({ + where: { orderId: params.orderId }, + 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 }) => { - const page = Number(query.page ?? 1); - const limit = Number(query.limit ?? 30); - const skip = (page - 1) * limit; - const shop = (query.shop as string) || "hom-aroma"; - const where = { shop }; - const [items, total] = await Promise.all([ - prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }), + const page = Number(query.page ?? 1); + const limit = Number(query.limit ?? 20); + const skip = (page - 1) * limit; + const shop = (query.shop as string) || "hom-aroma"; + const search = ((query.search as string) || "").trim(); + const status = (query.status as string) || ""; + 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 }), ]); - 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 }) => { const shop = (query.shop as string) || "hom-aroma"; 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.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.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 { orderCount, - totalAmount: agg._sum.orderAmount ?? 0, + totalAmount, totalShipping: agg._sum.shippingFee ?? 0, totalRefund: agg._sum.refundAmount ?? 0, totalTaxes: agg._sum.taxes ?? 0, totalItems: itemAgg._sum.quantity ?? 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"; } - const allOrders = await prisma.tiktokOrder.findMany({ + const rawOrders = await prisma.tiktokOrder.findMany({ where, - select: { - orderId: true, status: true, subStatus: true, createdTime: true, - orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, - paymentMethod: true, province: true, - items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } }, + include: { + items: true, }, + orderBy: { createdTime: "asc" }, }); + // Enrich with BOM & Settlement + const allOrders = await enrichOrdersWithBOM(rawOrders, shop); + // Monthly aggregation - const monthMap = new Map(); + const monthMap = new Map(); + for (const o of allOrders) { 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)!; m.orders += 1; m.revenue += o.orderAmount; - m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0); - m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0); - if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1; - m.items += o.items.reduce((s, i) => s + i.quantity, 0); + m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0); + m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0); + m.commission += (o.commissionFee || 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)); @@ -254,23 +747,46 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" }) const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10); // Summary - const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0); - const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 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 totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0); - const totalOrders = allOrders.length; - const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length; - const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0; - const completedOrders = totalOrders - cancelledOrders; - const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0; + const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0); + const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 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), 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 cancelledOrders = allOrders.filter(o => o.status?.includes("ยกเลิก")).length; + const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0; + const completedOrders = totalOrders - cancelledOrders; + const aov = completedOrders > 0 ? totalRevenue / completedOrders : 0; return { - monthly, statusBreakdown, paymentBreakdown, topProducts, + monthly, + statusBreakdown, + paymentBreakdown, + topProducts, summary: { - totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc, + totalRevenue, + totalShipping, + totalRefund, + totalPlatformDisc, + totalSellerDisc, netRevenue: totalRevenue - totalShipping, - totalOrders, completedOrders, cancelledOrders, cancelRate, aov, + totalOrders, + completedOrders, + cancelledOrders, + cancelRate, + aov, + totalCommission, + totalAffiliate, + totalSettlementAmount, + totalBOMCost, + totalNetProfit, + netProfitMargin, }, }; }) diff --git a/apps/backend/src/routes/tools.ts b/apps/backend/src/routes/tools.ts new file mode 100644 index 0000000..33e55d4 --- /dev/null +++ b/apps/backend/src/routes/tools.ts @@ -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(); + 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(); + 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(); + + 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(); + 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, + }; + }); diff --git a/apps/backend/src/utils/thaiProvinces.ts b/apps/backend/src/utils/thaiProvinces.ts index a0d1b8f..c294d39 100644 --- a/apps/backend/src/utils/thaiProvinces.ts +++ b/apps/backend/src/utils/thaiProvinces.ts @@ -144,3 +144,110 @@ export function cleanDistrictName(input: string | null | undefined): string { .replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '') .trim(); } + +const POSTCODE_EXCEPTIONS: Record = { + '10540': 'สมุทรปราการ', + '10270': 'สมุทรปราการ', + '10280': 'สมุทรปราการ', + '10560': 'สมุทรปราการ', + '13170': 'พระนครศรีอยุธยา', + '13180': 'พระนครศรีอยุธยา', +}; + +const POSTCODE_PREFIX_TO_PROVINCE_NAME: Record = { + '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; +} diff --git a/income_20260906105930(UTC+7).xlsx b/income_20260906105930(UTC+7).xlsx new file mode 100644 index 0000000..16411f5 Binary files /dev/null and b/income_20260906105930(UTC+7).xlsx differ