x
This commit is contained in:
1 parent
abee5249ca
commit
2c8da0ae2a
24 files changed
+9406
-407
No files matched your search
@@ -32,9 +32,11 @@ import TiktokPage from './pages/TiktokPage'
|
|||||||
import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
|
import TiktokAnalysisPage from './pages/TiktokAnalysisPage'
|
||||||
import ShopeePage from './pages/ShopeePage'
|
import ShopeePage from './pages/ShopeePage'
|
||||||
import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage'
|
import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage'
|
||||||
|
import ShopeeSalesMapPage from './pages/ShopeeSalesMapPage'
|
||||||
import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage'
|
import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage'
|
||||||
import ShopeeOverviewPage from './pages/ShopeeOverviewPage'
|
import ShopeeOverviewPage from './pages/ShopeeOverviewPage'
|
||||||
import TiktokOrderPage from './pages/TiktokOrderPage'
|
import TiktokOrderPage from './pages/TiktokOrderPage'
|
||||||
|
import TiktokOrderDetailPage from './pages/TiktokOrderDetailPage'
|
||||||
import TiktokSalesMapPage from './pages/TiktokSalesMapPage'
|
import TiktokSalesMapPage from './pages/TiktokSalesMapPage'
|
||||||
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
|
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
|
||||||
import TiktokOverviewPage from './pages/TiktokOverviewPage'
|
import TiktokOverviewPage from './pages/TiktokOverviewPage'
|
||||||
@@ -54,6 +56,9 @@ import BackupCatalogPage from './pages/backup/BackupCatalogPage'
|
|||||||
import BackupOrdersPage from './pages/backup/BackupOrdersPage'
|
import BackupOrdersPage from './pages/backup/BackupOrdersPage'
|
||||||
import BackupCmsPage from './pages/backup/BackupCmsPage'
|
import BackupCmsPage from './pages/backup/BackupCmsPage'
|
||||||
import BackupMediaPage from './pages/backup/BackupMediaPage'
|
import BackupMediaPage from './pages/backup/BackupMediaPage'
|
||||||
|
import OmnichannelSalesMapPage from './pages/analytics/OmnichannelSalesMapPage'
|
||||||
|
import ProductSalesMapPage from './pages/analytics/ProductSalesMapPage'
|
||||||
|
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -103,9 +108,11 @@ function App() {
|
|||||||
<Route path="/shopee-shop-case" element={<ShopeeShopCasePage />} />
|
<Route path="/shopee-shop-case" element={<ShopeeShopCasePage />} />
|
||||||
<Route path="/shopee" element={<ShopeePage />} />
|
<Route path="/shopee" element={<ShopeePage />} />
|
||||||
<Route path="/shopee/:orderId" element={<ShopeeOrderDetailPage />} />
|
<Route path="/shopee/:orderId" element={<ShopeeOrderDetailPage />} />
|
||||||
|
<Route path="/shopee-sales-map" element={<ShopeeSalesMapPage />} />
|
||||||
<Route path="/shopee-analytics" element={<ShopeeAnalyticsPage />} />
|
<Route path="/shopee-analytics" element={<ShopeeAnalyticsPage />} />
|
||||||
<Route path="/shopee-overview" element={<ShopeeOverviewPage />} />
|
<Route path="/shopee-overview" element={<ShopeeOverviewPage />} />
|
||||||
<Route path="/tiktok-order" element={<TiktokOrderPage />} />
|
<Route path="/tiktok-order" element={<TiktokOrderPage />} />
|
||||||
|
<Route path="/tiktok-order/:orderId" element={<TiktokOrderDetailPage />} />
|
||||||
<Route path="/tiktok-sales-map" element={<TiktokSalesMapPage />} />
|
<Route path="/tiktok-sales-map" element={<TiktokSalesMapPage />} />
|
||||||
<Route path="/tiktok-analytics" element={<TiktokAnalyticsPage />} />
|
<Route path="/tiktok-analytics" element={<TiktokAnalyticsPage />} />
|
||||||
<Route path="/tiktok-overview" element={<TiktokOverviewPage />} />
|
<Route path="/tiktok-overview" element={<TiktokOverviewPage />} />
|
||||||
@@ -113,6 +120,12 @@ function App() {
|
|||||||
<Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} />
|
<Route path="/lazada-analytics" element={<LazadaAnalyticsPage />} />
|
||||||
<Route path="/lazada-overview" element={<LazadaOverviewPage />} />
|
<Route path="/lazada-overview" element={<LazadaOverviewPage />} />
|
||||||
<Route path="/global-analytics" element={<GlobalAnalyticsPage />} />
|
<Route path="/global-analytics" element={<GlobalAnalyticsPage />} />
|
||||||
|
{/* Business Analytics */}
|
||||||
|
<Route path="/business-analytics/sales-map" element={<OmnichannelSalesMapPage />} />
|
||||||
|
<Route path="/business-analytics/sales-map-products" element={<ProductSalesMapPage />} />
|
||||||
|
{/* Tools */}
|
||||||
|
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
|
||||||
|
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
|
||||||
{/* Backup & Restore */}
|
{/* Backup & Restore */}
|
||||||
<Route path="/backup" element={<BackupDashboardPage />} />
|
<Route path="/backup" element={<BackupDashboardPage />} />
|
||||||
<Route path="/backup/database" element={<BackupDatabasePage />} />
|
<Route path="/backup/database" element={<BackupDatabasePage />} />
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ const SALES_MENU = [
|
|||||||
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
|
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const BUSINESS_ANALYTICS_MENU = [
|
||||||
|
{ icon: Globe2, label: 'Omnichannel Sales Map', to: '/business-analytics/sales-map' },
|
||||||
|
{ icon: Package, label: 'Product Sales Map', to: '/business-analytics/sales-map-products' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TOOL_MENU = [
|
||||||
|
{ icon: Package, label: 'Marketplace Products', to: '/tools/marketplace-products' },
|
||||||
|
]
|
||||||
|
|
||||||
const TIKTOK_MENU = [
|
const TIKTOK_MENU = [
|
||||||
{ icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' },
|
{ icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' },
|
||||||
{ icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' },
|
{ icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' },
|
||||||
@@ -70,6 +79,7 @@ const TIKTOK_LEGACY_MENU = [
|
|||||||
|
|
||||||
const SHOPEE_MENU = [
|
const SHOPEE_MENU = [
|
||||||
{ icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' },
|
{ icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' },
|
||||||
|
{ icon: Map, label: 'Thailand Sales Map', to: '/shopee-sales-map' },
|
||||||
{ icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' },
|
{ icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' },
|
||||||
{ icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' },
|
{ icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' },
|
||||||
{ icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' },
|
{ icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' },
|
||||||
@@ -127,6 +137,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
|
||||||
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
|
||||||
|
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics')
|
||||||
|
const isInTools = location.pathname.startsWith('/tools')
|
||||||
const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis')
|
const isInTiktokLegacy = location.pathname === '/tiktok' || location.pathname.startsWith('/tiktok-analysis')
|
||||||
const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy
|
const isInTiktok = location.pathname.startsWith('/tiktok') && !isInTiktokLegacy
|
||||||
const isInShopee = location.pathname.startsWith('/shopee')
|
const isInShopee = location.pathname.startsWith('/shopee')
|
||||||
@@ -139,6 +151,8 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
|
|
||||||
const [productOpen, setProductOpen] = useState(isInProduct)
|
const [productOpen, setProductOpen] = useState(isInProduct)
|
||||||
const [salesOpen, setSalesOpen] = useState(isInSales)
|
const [salesOpen, setSalesOpen] = useState(isInSales)
|
||||||
|
const [businessAnalyticsOpen, setBusinessAnalyticsOpen] = useState(isInBusinessAnalytics)
|
||||||
|
const [toolsOpen, setToolsOpen] = useState(isInTools)
|
||||||
const [tiktokOpen, setTiktokOpen] = useState(isInTiktok)
|
const [tiktokOpen, setTiktokOpen] = useState(isInTiktok)
|
||||||
const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy)
|
const [tiktokLegacyOpen, setTiktokLegacyOpen] = useState(isInTiktokLegacy)
|
||||||
const [shopeeOpen, setShopeeOpen] = useState(isInShopee)
|
const [shopeeOpen, setShopeeOpen] = useState(isInShopee)
|
||||||
@@ -260,6 +274,66 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Business Analytics Group */}
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGroupClick(setBusinessAnalyticsOpen)}
|
||||||
|
title={isCollapsed ? 'Business Analytics' : undefined}
|
||||||
|
className={`sidebar-link sidebar-link-inactive w-full ${isInBusinessAnalytics ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
|
||||||
|
>
|
||||||
|
<BarChart3 className="w-5 h-5 shrink-0 text-orange-400" />
|
||||||
|
{!isCollapsed && <span className="flex-1 text-left font-bold text-orange-300">Business Analytics</span>}
|
||||||
|
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${businessAnalyticsOpen ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && businessAnalyticsOpen && (
|
||||||
|
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
|
||||||
|
{BUSINESS_ANALYTICS_MENU.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 shrink-0 text-orange-400" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tools Group */}
|
||||||
|
<div className="pt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => handleGroupClick(setToolsOpen)}
|
||||||
|
title={isCollapsed ? 'Tool' : undefined}
|
||||||
|
className={`sidebar-link sidebar-link-inactive w-full ${isInTools ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
|
||||||
|
>
|
||||||
|
<Wrench className="w-5 h-5 shrink-0 text-amber-400" />
|
||||||
|
{!isCollapsed && <span className="flex-1 text-left font-bold text-amber-300">Tool</span>}
|
||||||
|
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${toolsOpen ? 'rotate-180' : ''}`} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && toolsOpen && (
|
||||||
|
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
|
||||||
|
{TOOL_MENU.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<item.icon className="w-4 h-4 shrink-0 text-amber-400" />
|
||||||
|
<span className="flex-1">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* TikTok Group */}
|
{/* TikTok Group */}
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import * as d3Geo from 'd3-geo';
|
||||||
|
import { X, Search, RotateCcw, MapPin, ExternalLink } from 'lucide-react';
|
||||||
|
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface OmnichannelDistrictModalProps {
|
||||||
|
provinceName: string;
|
||||||
|
channel: string;
|
||||||
|
tiktokShop?: string;
|
||||||
|
shopeeShop?: string;
|
||||||
|
lazadaShop?: string;
|
||||||
|
statusFilter?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OmnichannelDistrictModal({
|
||||||
|
provinceName,
|
||||||
|
channel,
|
||||||
|
tiktokShop,
|
||||||
|
shopeeShop,
|
||||||
|
lazadaShop,
|
||||||
|
statusFilter,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
onClose,
|
||||||
|
}: OmnichannelDistrictModalProps) {
|
||||||
|
const [districtGeo, setDistrictGeo] = useState<any>(null);
|
||||||
|
const [loadingMap, setLoadingMap] = useState(true);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [districtData, setDistrictData] = useState<any>(null);
|
||||||
|
|
||||||
|
// Selected districts set (multi-select)
|
||||||
|
const [selectedDistricts, setSelectedDistricts] = useState<Set<string>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
|
||||||
|
const [hoveredDistrict, setHoveredDistrict] = useState<any>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const width = 450;
|
||||||
|
const height = 450;
|
||||||
|
|
||||||
|
// Load district GeoJSON
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/maps/thailand-districts.json')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setDistrictGeo(data))
|
||||||
|
.catch((err) => console.error('Failed to load districts GeoJSON:', err))
|
||||||
|
.finally(() => setLoadingMap(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch backend API for district level stats & orders
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingData(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('province', provinceName);
|
||||||
|
if (channel && channel !== 'all') params.set('channel', channel);
|
||||||
|
if (tiktokShop && tiktokShop !== 'all') params.set('tiktokShop', tiktokShop);
|
||||||
|
if (shopeeShop && shopeeShop !== 'all') params.set('shopeeShop', shopeeShop);
|
||||||
|
if (lazadaShop && lazadaShop !== 'all') params.set('lazadaShop', lazadaShop);
|
||||||
|
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||||
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/business-analytics/sales-map/district?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setDistrictData(data))
|
||||||
|
.catch((err) => console.error('Failed to load district data:', err))
|
||||||
|
.finally(() => setLoadingData(false));
|
||||||
|
}, [provinceName, channel, tiktokShop, shopeeShop, lazadaShop, statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
// Filter district GeoJSON for this province
|
||||||
|
const provinceDistrictsGeo = useMemo(() => {
|
||||||
|
if (!districtGeo?.features) return [];
|
||||||
|
return districtGeo.features.filter((f: any) => {
|
||||||
|
const pName = f.properties.pro_th || f.properties.pro_en || '';
|
||||||
|
return pName.includes(provinceName) || provinceName.includes(pName);
|
||||||
|
});
|
||||||
|
}, [districtGeo, provinceName]);
|
||||||
|
|
||||||
|
// Calculate max district revenue for color scale
|
||||||
|
const maxDistrictRevenue = useMemo(() => {
|
||||||
|
if (!districtData?.districts?.length) return 1;
|
||||||
|
return Math.max(...districtData.districts.map((d: any) => d.revenue), 1);
|
||||||
|
}, [districtData]);
|
||||||
|
|
||||||
|
// District sales map lookup
|
||||||
|
const districtSalesMap = useMemo(() => {
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
if (districtData?.districts) {
|
||||||
|
for (const d of districtData.districts) {
|
||||||
|
map.set(d.districtName, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [districtData]);
|
||||||
|
|
||||||
|
// Projection tailored to the province districts
|
||||||
|
const paths = useMemo(() => {
|
||||||
|
if (provinceDistrictsGeo.length === 0) return [];
|
||||||
|
|
||||||
|
const featureCollection: any = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: provinceDistrictsGeo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projection = d3Geo.geoMercator().fitSize([width, height], featureCollection);
|
||||||
|
const generator = d3Geo.geoPath().projection(projection);
|
||||||
|
|
||||||
|
return provinceDistrictsGeo.map((feature: any) => {
|
||||||
|
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
|
||||||
|
const pathData = generator(feature);
|
||||||
|
const sales = districtSalesMap.get(dName);
|
||||||
|
const revenue = sales?.revenue || 0;
|
||||||
|
const orders = sales?.orders || 0;
|
||||||
|
const tiktokRevenue = sales?.tiktokRevenue || 0;
|
||||||
|
const shopeeRevenue = sales?.shopeeRevenue || 0;
|
||||||
|
const lazadaRevenue = sales?.lazadaRevenue || 0;
|
||||||
|
const isSelected = selectedDistricts.has(dName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
feature,
|
||||||
|
pathData,
|
||||||
|
districtName: dName,
|
||||||
|
revenue,
|
||||||
|
orders,
|
||||||
|
tiktokRevenue,
|
||||||
|
shopeeRevenue,
|
||||||
|
lazadaRevenue,
|
||||||
|
isSelected,
|
||||||
|
color: getDistrictColor(revenue, maxDistrictRevenue, isSelected),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [provinceDistrictsGeo, districtSalesMap, maxDistrictRevenue, selectedDistricts]);
|
||||||
|
|
||||||
|
// Toggle district selection
|
||||||
|
const toggleDistrict = (dName: string) => {
|
||||||
|
setSelectedDistricts((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(dName)) next.delete(dName);
|
||||||
|
else next.add(dName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedDistricts = () => setSelectedDistricts(new Set());
|
||||||
|
|
||||||
|
// Filter orders by selected districts, channel filter, and search query
|
||||||
|
const filteredOrders = useMemo(() => {
|
||||||
|
if (!districtData?.orders) return [];
|
||||||
|
return districtData.orders.filter((o: any) => {
|
||||||
|
// District filter
|
||||||
|
if (selectedDistricts.size > 0 && !selectedDistricts.has(o.district)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Channel filter
|
||||||
|
if (channelFilter !== 'all' && o.channel !== channelFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Search query
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const inId = o.orderId.toLowerCase().includes(q);
|
||||||
|
const inUser = o.buyerUsername?.toLowerCase().includes(q);
|
||||||
|
const inDist = o.district?.toLowerCase().includes(q);
|
||||||
|
const inSku = o.items?.some((i: any) =>
|
||||||
|
i.productName?.toLowerCase().includes(q) || i.sellerSku?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
return inId || inUser || inDist || inSku;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [districtData, selectedDistricts, channelFilter, searchQuery]);
|
||||||
|
|
||||||
|
// Summary stats for selected districts
|
||||||
|
const selectionSummary = useMemo(() => {
|
||||||
|
if (!districtData?.orders) return { revenue: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
|
||||||
|
const orders = filteredOrders;
|
||||||
|
const rev = orders.reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const ttRev = orders.filter((o: any) => o.channel === 'tiktok').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const spRev = orders.filter((o: any) => o.channel === 'shopee').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const lzRev = orders.filter((o: any) => o.channel === 'lazada').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
return {
|
||||||
|
revenue: rev,
|
||||||
|
orders: orders.length,
|
||||||
|
tiktokRevenue: ttRev,
|
||||||
|
shopeeRevenue: spRev,
|
||||||
|
lazadaRevenue: lzRev,
|
||||||
|
};
|
||||||
|
}, [districtData, filteredOrders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-6xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.03]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-rose-500/20 via-orange-500/20 to-blue-500/20 border border-orange-500/30 flex items-center justify-center">
|
||||||
|
<MapPin className="w-5 h-5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
|
||||||
|
Omnichannel (TikTok + Shopee + Lazada)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body: 3 Columns Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0 flex-1 overflow-hidden">
|
||||||
|
{/* Col 1: District SVG Map (5 cols) */}
|
||||||
|
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-orange-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ)
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 flex items-center justify-center min-h-[320px] bg-slate-900/60 rounded-xl border border-white/5 relative overflow-hidden"
|
||||||
|
onMouseLeave={() => setHoveredDistrict(null)}
|
||||||
|
>
|
||||||
|
{loadingMap || loadingData ? (
|
||||||
|
<div className="text-center text-slate-400 text-sm">
|
||||||
|
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ...
|
||||||
|
</div>
|
||||||
|
) : paths.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-500 text-xs p-6">
|
||||||
|
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
|
||||||
|
<g>
|
||||||
|
{paths.map((item: any, idx: number) => (
|
||||||
|
<path
|
||||||
|
key={idx}
|
||||||
|
d={item.pathData}
|
||||||
|
fill={item.color}
|
||||||
|
stroke="#0f172a"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="cursor-pointer transition-all duration-150 hover:opacity-90 hover:stroke-orange-400 hover:stroke-2"
|
||||||
|
onClick={() => toggleDistrict(item.districtName)}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setHoveredDistrict(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover Tooltip */}
|
||||||
|
{hoveredDistrict && tooltipPos && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none z-30 px-3 py-2 rounded-xl bg-slate-900/95 border border-white/20 shadow-2xl text-xs backdrop-blur-md"
|
||||||
|
style={{ left: Math.min(tooltipPos.x + 12, width - 180), top: Math.max(tooltipPos.y - 65, 10) }}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-white flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
|
||||||
|
อ.{hoveredDistrict.districtName}
|
||||||
|
</p>
|
||||||
|
<p className="text-emerald-400 font-mono font-bold mt-1">
|
||||||
|
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.orders} ออเดอร์)</span>
|
||||||
|
</p>
|
||||||
|
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
|
||||||
|
<span className="text-rose-400">TT: {fmtBaht(hoveredDistrict.tiktokRevenue)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-orange-400">SP: {fmtBaht(hoveredDistrict.shopeeRevenue)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-blue-400">LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Legend */}
|
||||||
|
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
|
||||||
|
<span>฿0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
|
||||||
|
<span>฿{fmtNum(maxDistrictRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-orange-500" />
|
||||||
|
<span>เลือกอยู่</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2 & 3: Selection Stats & Order Table (7 cols) */}
|
||||||
|
<div className="lg:col-span-7 flex flex-col h-full overflow-hidden bg-slate-900/30">
|
||||||
|
{/* Filter & Summary Header */}
|
||||||
|
<div className="p-4 border-b border-white/10 space-y-3 bg-white/[0.01]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('all')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'all'
|
||||||
|
? 'bg-white/15 text-white border-white/30'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทุกช่องทาง
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('tiktok')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'tiktok'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔴 TikTok
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('shopee')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'shopee'
|
||||||
|
? 'bg-orange-500/20 text-orange-300 border-orange-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🟠 Shopee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('lazada')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'lazada'
|
||||||
|
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔵 Lazada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDistricts.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearSelectedDistricts}
|
||||||
|
className="flex items-center gap-1 text-xs text-orange-400 hover:text-orange-300 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards for Selection */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p>
|
||||||
|
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-slate-400 uppercase tracking-wider">คำสั่งซื้อ</p>
|
||||||
|
<p className="text-base font-bold text-white font-mono">{selectionSummary.orders} ออเดอร์</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
|
||||||
|
<p className="text-sm font-bold text-rose-400 font-mono">{fmtBaht(selectionSummary.tiktokRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-500/10 border border-orange-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-orange-300 uppercase tracking-wider">Shopee</p>
|
||||||
|
<p className="text-sm font-bold text-orange-400 font-mono">{fmtBaht(selectionSummary.shopeeRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-blue-300 uppercase tracking-wider">Lazada</p>
|
||||||
|
<p className="text-sm font-bold text-blue-400 font-mono">{fmtBaht(selectionSummary.lazadaRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* District Tags */}
|
||||||
|
{selectedDistricts.size > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-400">กรองเฉพาะ:</span>
|
||||||
|
{Array.from(selectedDistricts).map((dName) => (
|
||||||
|
<span
|
||||||
|
key={dName}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-orange-500/20 text-orange-300 border border-orange-500/30"
|
||||||
|
>
|
||||||
|
อ.{dName}
|
||||||
|
<button onClick={() => toggleDistrict(dName)} className="hover:text-white">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือชื่อสินค้า..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders Table */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 text-xs">
|
||||||
|
ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{filteredOrders.map((order: any) => (
|
||||||
|
<div
|
||||||
|
key={`${order.channel}-${order.orderId}`}
|
||||||
|
className="p-3 bg-white/5 hover:bg-white/[0.08] border border-white/10 rounded-xl transition-colors space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{order.channel === 'tiktok' ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
|
||||||
|
TikTok
|
||||||
|
</span>
|
||||||
|
) : order.channel === 'shopee' ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||||
|
Shopee
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
Lazada
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-xs font-bold text-white">{order.orderId}</span>
|
||||||
|
<span className="px-1.5 py-0.2 rounded text-[9px] bg-white/10 text-slate-300 font-mono">
|
||||||
|
{order.shop}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
ผู้ซื้อ: <span className="text-slate-200">{order.buyerUsername || '-'}</span> • อ.{order.district} • {order.createdTime?.slice(0, 16)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm font-bold text-emerald-400 font-mono">{fmtBaht(order.orderAmount)}</p>
|
||||||
|
<p className="text-[10px] text-slate-500 font-mono">ค่าส่ง {fmtBaht(order.shippingFee)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Preview */}
|
||||||
|
{order.items?.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-white/5 space-y-1">
|
||||||
|
{order.items.map((it: any, iIdx: number) => (
|
||||||
|
<div key={iIdx} className="flex items-center justify-between text-xs text-slate-300">
|
||||||
|
<span className="truncate max-w-[280px] text-slate-400" title={it.productName}>
|
||||||
|
• {it.productName} {it.variation ? `(${it.variation})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-400 shrink-0 text-[11px]">
|
||||||
|
x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
order.channel === 'tiktok'
|
||||||
|
? `/tiktok-order/${order.orderId}`
|
||||||
|
: order.channel === 'shopee'
|
||||||
|
? `/shopee/${order.orderId}`
|
||||||
|
: `/lazada-order`
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-orange-300 transition-colors"
|
||||||
|
>
|
||||||
|
ดูหน้าออเดอร์เต็ม <ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
import { useEffect, useState, useMemo, useRef } from 'react';
|
||||||
|
import * as d3Geo from 'd3-geo';
|
||||||
|
import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react';
|
||||||
|
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface ProductDistrictModalProps {
|
||||||
|
provinceName: string;
|
||||||
|
productId?: number | null;
|
||||||
|
variantId?: number | null;
|
||||||
|
productName?: string;
|
||||||
|
variantName?: string;
|
||||||
|
channel: string;
|
||||||
|
statusFilter?: string;
|
||||||
|
dateFrom?: string;
|
||||||
|
dateTo?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDistrictModal({
|
||||||
|
provinceName,
|
||||||
|
productId,
|
||||||
|
variantId,
|
||||||
|
productName,
|
||||||
|
variantName,
|
||||||
|
channel,
|
||||||
|
statusFilter,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
onClose,
|
||||||
|
}: ProductDistrictModalProps) {
|
||||||
|
const [districtGeo, setDistrictGeo] = useState<any>(null);
|
||||||
|
const [loadingMap, setLoadingMap] = useState(true);
|
||||||
|
const [loadingData, setLoadingData] = useState(true);
|
||||||
|
const [districtData, setDistrictData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [selectedDistricts, setSelectedDistricts] = useState<Set<string>>(new Set());
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [channelFilter, setChannelFilter] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
|
||||||
|
const [hoveredDistrict, setHoveredDistrict] = useState<any>(null);
|
||||||
|
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const width = 450;
|
||||||
|
const height = 450;
|
||||||
|
|
||||||
|
// Load district GeoJSON
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('/maps/thailand-districts.json')
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setDistrictGeo(data))
|
||||||
|
.catch((err) => console.error('Failed to load districts GeoJSON:', err))
|
||||||
|
.finally(() => setLoadingMap(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch backend API for product district level stats & orders
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingData(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('province', provinceName);
|
||||||
|
if (variantId) params.set('variantId', variantId.toString());
|
||||||
|
else if (productId) params.set('productId', productId.toString());
|
||||||
|
|
||||||
|
if (channel && channel !== 'all') params.set('channel', channel);
|
||||||
|
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||||
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/business-analytics/sales-map-products/district?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setDistrictData(data))
|
||||||
|
.catch((err) => console.error('Failed to load product district data:', err))
|
||||||
|
.finally(() => setLoadingData(false));
|
||||||
|
}, [provinceName, productId, variantId, channel, statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
// Filter district GeoJSON for this province
|
||||||
|
const provinceDistrictsGeo = useMemo(() => {
|
||||||
|
if (!districtGeo?.features) return [];
|
||||||
|
return districtGeo.features.filter((f: any) => {
|
||||||
|
const pName = f.properties.pro_th || f.properties.pro_en || '';
|
||||||
|
return pName.includes(provinceName) || provinceName.includes(pName);
|
||||||
|
});
|
||||||
|
}, [districtGeo, provinceName]);
|
||||||
|
|
||||||
|
// Calculate max district revenue for color scale
|
||||||
|
const maxDistrictRevenue = useMemo(() => {
|
||||||
|
if (!districtData?.districts?.length) return 1;
|
||||||
|
return Math.max(...districtData.districts.map((d: any) => d.revenue), 1);
|
||||||
|
}, [districtData]);
|
||||||
|
|
||||||
|
// District sales map lookup
|
||||||
|
const districtSalesMap = useMemo(() => {
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
if (districtData?.districts) {
|
||||||
|
for (const d of districtData.districts) {
|
||||||
|
map.set(d.districtName, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [districtData]);
|
||||||
|
|
||||||
|
// Projection tailored to the province districts
|
||||||
|
const paths = useMemo(() => {
|
||||||
|
if (provinceDistrictsGeo.length === 0) return [];
|
||||||
|
|
||||||
|
const featureCollection: any = {
|
||||||
|
type: 'FeatureCollection',
|
||||||
|
features: provinceDistrictsGeo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const projection = d3Geo.geoMercator().fitSize([width, height], featureCollection);
|
||||||
|
const generator = d3Geo.geoPath().projection(projection);
|
||||||
|
|
||||||
|
return provinceDistrictsGeo.map((feature: any) => {
|
||||||
|
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
|
||||||
|
const pathData = generator(feature);
|
||||||
|
const sales = districtSalesMap.get(dName);
|
||||||
|
const revenue = sales?.revenue || 0;
|
||||||
|
const unitsSold = sales?.unitsSold || 0;
|
||||||
|
const orders = sales?.orders || 0;
|
||||||
|
const tiktokRevenue = sales?.tiktokRevenue || 0;
|
||||||
|
const shopeeRevenue = sales?.shopeeRevenue || 0;
|
||||||
|
const lazadaRevenue = sales?.lazadaRevenue || 0;
|
||||||
|
const isSelected = selectedDistricts.has(dName);
|
||||||
|
|
||||||
|
return {
|
||||||
|
feature,
|
||||||
|
pathData,
|
||||||
|
districtName: dName,
|
||||||
|
revenue,
|
||||||
|
unitsSold,
|
||||||
|
orders,
|
||||||
|
tiktokRevenue,
|
||||||
|
shopeeRevenue,
|
||||||
|
lazadaRevenue,
|
||||||
|
isSelected,
|
||||||
|
color: getDistrictColor(revenue, maxDistrictRevenue, isSelected),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [provinceDistrictsGeo, districtSalesMap, maxDistrictRevenue, selectedDistricts]);
|
||||||
|
|
||||||
|
const toggleDistrict = (dName: string) => {
|
||||||
|
setSelectedDistricts((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(dName)) next.delete(dName);
|
||||||
|
else next.add(dName);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSelectedDistricts = () => setSelectedDistricts(new Set());
|
||||||
|
|
||||||
|
// Filter orders by selected districts, channel filter, and search query
|
||||||
|
const filteredOrders = useMemo(() => {
|
||||||
|
if (!districtData?.orders) return [];
|
||||||
|
return districtData.orders.filter((o: any) => {
|
||||||
|
if (selectedDistricts.size > 0 && !selectedDistricts.has(o.district)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (channelFilter !== 'all' && o.channel !== channelFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const q = searchQuery.toLowerCase();
|
||||||
|
const inId = o.orderId.toLowerCase().includes(q);
|
||||||
|
const inUser = o.buyerUsername?.toLowerCase().includes(q);
|
||||||
|
const inDist = o.district?.toLowerCase().includes(q);
|
||||||
|
const inSku = o.items?.some((i: any) =>
|
||||||
|
i.productName?.toLowerCase().includes(q) || i.sellerSku?.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
return inId || inUser || inDist || inSku;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [districtData, selectedDistricts, channelFilter, searchQuery]);
|
||||||
|
|
||||||
|
// Summary stats for selection
|
||||||
|
const selectionSummary = useMemo(() => {
|
||||||
|
if (!districtData?.orders) return { revenue: 0, units: 0, orders: 0, tiktokRevenue: 0, shopeeRevenue: 0, lazadaRevenue: 0 };
|
||||||
|
const orders = filteredOrders;
|
||||||
|
const rev = orders.reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const units = orders.reduce((sum: number, o: any) => sum + (o.items?.reduce((is: number, it: any) => is + it.quantity, 0) || 0), 0);
|
||||||
|
const ttRev = orders.filter((o: any) => o.channel === 'tiktok').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const spRev = orders.filter((o: any) => o.channel === 'shopee').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
const lzRev = orders.filter((o: any) => o.channel === 'lazada').reduce((sum: number, o: any) => sum + (o.orderAmount || 0), 0);
|
||||||
|
return {
|
||||||
|
revenue: rev,
|
||||||
|
units,
|
||||||
|
orders: orders.length,
|
||||||
|
tiktokRevenue: ttRev,
|
||||||
|
shopeeRevenue: spRev,
|
||||||
|
lazadaRevenue: lzRev,
|
||||||
|
};
|
||||||
|
}, [districtData, filteredOrders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-6xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.03]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 via-purple-500/20 to-pink-500/20 border border-indigo-500/30 flex items-center justify-center">
|
||||||
|
<MapPin className="w-5 h-5 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
|
||||||
|
<Package className="w-3 h-3" />
|
||||||
|
{productName} {variantName ? `(${variantName})` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body: 3 Columns Layout */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-0 flex-1 overflow-hidden">
|
||||||
|
{/* Col 1: District SVG Map (5 cols) */}
|
||||||
|
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-indigo-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ)
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 flex items-center justify-center min-h-[320px] bg-slate-900/60 rounded-xl border border-white/5 relative overflow-hidden"
|
||||||
|
onMouseLeave={() => setHoveredDistrict(null)}
|
||||||
|
>
|
||||||
|
{loadingMap || loadingData ? (
|
||||||
|
<div className="text-center text-slate-400 text-sm">
|
||||||
|
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ...
|
||||||
|
</div>
|
||||||
|
) : paths.length === 0 ? (
|
||||||
|
<div className="text-center text-slate-500 text-xs p-6">
|
||||||
|
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
|
||||||
|
<g>
|
||||||
|
{paths.map((item: any, idx: number) => (
|
||||||
|
<path
|
||||||
|
key={idx}
|
||||||
|
d={item.pathData}
|
||||||
|
fill={item.color}
|
||||||
|
stroke="#0f172a"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className="cursor-pointer transition-all duration-150 hover:opacity-90 hover:stroke-indigo-400 hover:stroke-2"
|
||||||
|
onClick={() => toggleDistrict(item.districtName)}
|
||||||
|
onMouseMove={(e) => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
if (rect) {
|
||||||
|
setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||||
|
setHoveredDistrict(item);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hover Tooltip */}
|
||||||
|
{hoveredDistrict && tooltipPos && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none z-30 px-3 py-2 rounded-xl bg-slate-900/95 border border-white/20 shadow-2xl text-xs backdrop-blur-md"
|
||||||
|
style={{ left: Math.min(tooltipPos.x + 12, width - 180), top: Math.max(tooltipPos.y - 65, 10) }}
|
||||||
|
>
|
||||||
|
<p className="font-bold text-white flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
|
||||||
|
อ.{hoveredDistrict.districtName}
|
||||||
|
</p>
|
||||||
|
<p className="text-emerald-400 font-mono font-bold mt-1">
|
||||||
|
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.unitsSold} ชิ้น)</span>
|
||||||
|
</p>
|
||||||
|
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
|
||||||
|
<span className="text-rose-400">TT: {fmtBaht(hoveredDistrict.tiktokRevenue)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-orange-400">SP: {fmtBaht(hoveredDistrict.shopeeRevenue)}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="text-blue-400">LZ: {fmtBaht(hoveredDistrict.lazadaRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Legend */}
|
||||||
|
<div className="mt-3 flex items-center justify-between text-[11px] text-slate-400 px-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
|
||||||
|
<span>฿0</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
|
||||||
|
<span>฿{fmtNum(maxDistrictRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-indigo-500" />
|
||||||
|
<span>เลือกอยู่</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2 & 3: Selection Stats & Order Table (7 cols) */}
|
||||||
|
<div className="lg:col-span-7 flex flex-col h-full overflow-hidden bg-slate-900/30">
|
||||||
|
{/* Filter & Summary Header */}
|
||||||
|
<div className="p-4 border-b border-white/10 space-y-3 bg-white/[0.01]">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('all')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'all'
|
||||||
|
? 'bg-white/15 text-white border-white/30'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทุกช่องทาง
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('tiktok')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'tiktok'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔴 TikTok
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('shopee')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'shopee'
|
||||||
|
? 'bg-orange-500/20 text-orange-300 border-orange-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🟠 Shopee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannelFilter('lazada')}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-bold border transition-all ${
|
||||||
|
channelFilter === 'lazada'
|
||||||
|
? 'bg-blue-500/20 text-blue-300 border-blue-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔵 Lazada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedDistricts.size > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={clearSelectedDistricts}
|
||||||
|
className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards for Selection */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p>
|
||||||
|
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-slate-400 uppercase tracking-wider">จำนวนชิ้นที่ขาย</p>
|
||||||
|
<p className="text-base font-bold text-amber-400 font-mono">{selectionSummary.units} ชิ้น</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
|
||||||
|
<p className="text-sm font-bold text-rose-400 font-mono">{fmtBaht(selectionSummary.tiktokRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-orange-500/10 border border-orange-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-orange-300 uppercase tracking-wider">Shopee</p>
|
||||||
|
<p className="text-sm font-bold text-orange-400 font-mono">{fmtBaht(selectionSummary.shopeeRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/20 rounded-xl p-2.5">
|
||||||
|
<p className="text-[10px] text-blue-300 uppercase tracking-wider">Lazada</p>
|
||||||
|
<p className="text-sm font-bold text-blue-400 font-mono">{fmtBaht(selectionSummary.lazadaRevenue)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* District Tags */}
|
||||||
|
{selectedDistricts.size > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<span className="text-xs text-slate-400">กรองเฉพาะ:</span>
|
||||||
|
{Array.from(selectedDistricts).map((dName) => (
|
||||||
|
<span
|
||||||
|
key={dName}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-indigo-500/20 text-indigo-300 border border-indigo-500/30"
|
||||||
|
>
|
||||||
|
อ.{dName}
|
||||||
|
<button onClick={() => toggleDistrict(dName)} className="hover:text-white">
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search Bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือ SKU..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders Table */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
{loadingData ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div>
|
||||||
|
) : filteredOrders.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-slate-500 text-xs">
|
||||||
|
ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{filteredOrders.map((order: any) => (
|
||||||
|
<div
|
||||||
|
key={`${order.channel}-${order.orderId}`}
|
||||||
|
className="p-3 bg-white/5 hover:bg-white/[0.08] border border-white/10 rounded-xl transition-colors space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{order.channel === 'tiktok' ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30">
|
||||||
|
TikTok
|
||||||
|
</span>
|
||||||
|
) : order.channel === 'shopee' ? (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-orange-500/20 text-orange-400 border border-orange-500/30">
|
||||||
|
Shopee
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-blue-500/20 text-blue-400 border border-blue-500/30">
|
||||||
|
Lazada
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-xs font-bold text-white">{order.orderId}</span>
|
||||||
|
<span className="px-1.5 py-0.2 rounded text-[9px] bg-white/10 text-slate-300 font-mono">
|
||||||
|
{order.shop}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
ผู้ซื้อ: <span className="text-slate-200">{order.buyerUsername || '-'}</span> • อ.{order.district} • {order.createdTime?.slice(0, 16)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<p className="text-sm font-bold text-emerald-400 font-mono">{fmtBaht(order.orderAmount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items Preview */}
|
||||||
|
{order.items?.length > 0 && (
|
||||||
|
<div className="pt-2 border-t border-white/5 space-y-1">
|
||||||
|
{order.items.map((it: any, iIdx: number) => (
|
||||||
|
<div key={iIdx} className="flex items-center justify-between text-xs text-slate-300">
|
||||||
|
<span className="truncate max-w-[280px] text-slate-400" title={it.productName}>
|
||||||
|
• {it.productName} {it.variation ? `(${it.variation})` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-slate-400 shrink-0 text-[11px]">
|
||||||
|
x{it.quantity} ({fmtBaht(it.subtotalAfterDiscount)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-1">
|
||||||
|
<Link
|
||||||
|
to={
|
||||||
|
order.channel === 'tiktok'
|
||||||
|
? `/tiktok-order/${order.orderId}`
|
||||||
|
: order.channel === 'shopee'
|
||||||
|
? `/shopee/${order.orderId}`
|
||||||
|
: `/lazada-order`
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-indigo-300 transition-colors"
|
||||||
|
>
|
||||||
|
ดูหน้าออเดอร์เต็ม <ExternalLink className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ interface DistrictMapModalProps {
|
|||||||
shop: string;
|
shop: string;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
|
platformName?: string;
|
||||||
|
apiEndpoint?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +19,8 @@ export default function DistrictMapModal({
|
|||||||
shop,
|
shop,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
|
platformName = 'TikTok',
|
||||||
|
apiEndpoint = '/tiktok-order/sales-map/district',
|
||||||
onClose,
|
onClose,
|
||||||
}: DistrictMapModalProps) {
|
}: DistrictMapModalProps) {
|
||||||
const [districtGeo, setDistrictGeo] = useState<any>(null);
|
const [districtGeo, setDistrictGeo] = useState<any>(null);
|
||||||
@@ -52,12 +56,16 @@ export default function DistrictMapModal({
|
|||||||
if (dateFrom) params.set('dateFrom', dateFrom);
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
if (dateTo) params.set('dateTo', dateTo);
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
|
||||||
fetch(`${API_BASE}/tiktok-order/sales-map/district?${params.toString()}`)
|
const fullUrl = apiEndpoint.startsWith('http')
|
||||||
|
? `${apiEndpoint}?${params.toString()}`
|
||||||
|
: `${API_BASE}${apiEndpoint.startsWith('/') ? '' : '/'}${apiEndpoint}?${params.toString()}`;
|
||||||
|
|
||||||
|
fetch(fullUrl)
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => setDistrictData(data))
|
.then((data) => setDistrictData(data))
|
||||||
.catch((err) => console.error('Failed to load district data:', err))
|
.catch((err) => console.error('Failed to load district data:', err))
|
||||||
.finally(() => setLoadingData(false));
|
.finally(() => setLoadingData(false));
|
||||||
}, [provinceName, shop, dateFrom, dateTo]);
|
}, [provinceName, shop, dateFrom, dateTo, apiEndpoint]);
|
||||||
|
|
||||||
// Filter district features matching the current province
|
// Filter district features matching the current province
|
||||||
const provinceFeatures = useMemo(() => {
|
const provinceFeatures = useMemo(() => {
|
||||||
@@ -186,7 +194,7 @@ export default function DistrictMapModal({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5">
|
<h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5">
|
||||||
แผนที่อำเภอ & คำสั่งซื้อ TikTok • <span className="text-emerald-400">{provinceName}</span>
|
แผนที่อำเภอ & คำสั่งซื้อ {platformName} • <span className="text-emerald-400">{provinceName}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก)
|
คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก)
|
||||||
@@ -378,7 +386,7 @@ export default function DistrictMapModal({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingBag className="w-4 h-4 text-emerald-400" />
|
<ShoppingBag className="w-4 h-4 text-emerald-400" />
|
||||||
<span className="text-sm font-bold text-white">
|
<span className="text-sm font-bold text-white">
|
||||||
คำสั่งซื้อ TikTok ในพื้นที่ ({filteredOrders.length})
|
คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedDistricts.size > 0 && (
|
{selectedDistricts.size > 0 && (
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom'
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
import { Save, Trash2, Plus, Loader2, CheckCircle2, AlertCircle, Package } from 'lucide-react'
|
import {
|
||||||
|
Save,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Package,
|
||||||
|
ArrowLeft,
|
||||||
|
Coins,
|
||||||
|
Layers,
|
||||||
|
Calculator
|
||||||
|
} from 'lucide-react'
|
||||||
import { API_BASE, type Product } from '@homaroma/types'
|
import { API_BASE, type Product } from '@homaroma/types'
|
||||||
|
|
||||||
|
function fmtMoney(val: number | null | undefined): string {
|
||||||
|
if (val == null || isNaN(val)) return '฿0.00'
|
||||||
|
return (
|
||||||
|
'฿' +
|
||||||
|
new Intl.NumberFormat('th-TH', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(val)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductBomPage() {
|
export default function ProductBomPage() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -31,7 +54,7 @@ export default function ProductBomPage() {
|
|||||||
|
|
||||||
const prodData: Product = await prodRes.json()
|
const prodData: Product = await prodRes.json()
|
||||||
setProduct(prodData)
|
setProduct(prodData)
|
||||||
setVariants(JSON.parse(JSON.stringify(prodData.variants))) // Deep copy
|
setVariants(JSON.parse(JSON.stringify(prodData.variants || []))) // Deep copy
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
@@ -43,20 +66,36 @@ export default function ProductBomPage() {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [id])
|
}, [id])
|
||||||
|
|
||||||
|
const getMaterial = (rawMaterialId: number) => {
|
||||||
|
return rawMaterials.find((rm) => rm.id === Number(rawMaterialId))
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLineCost = (bomItem: any): number => {
|
||||||
|
const rm = getMaterial(bomItem.rawMaterialId)
|
||||||
|
if (!rm) return 0
|
||||||
|
const qty = Number(bomItem.quantity) || 0
|
||||||
|
return (rm.pricePerUnit || 0) * qty
|
||||||
|
}
|
||||||
|
|
||||||
|
const getVariantBomCost = (variant: any): number => {
|
||||||
|
if (!variant.bomItems || variant.bomItems.length === 0) return 0
|
||||||
|
return variant.bomItems.reduce((acc: number, item: any) => acc + getLineCost(item), 0)
|
||||||
|
}
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(false)
|
setSuccess(false)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!product) throw new Error("Product not loaded")
|
if (!product) throw new Error('Product not loaded')
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
categoryId: product.categoryId,
|
categoryId: product.categoryId,
|
||||||
name: product.name,
|
name: product.name,
|
||||||
options: product.options,
|
options: product.options,
|
||||||
images: product.images, // Need to pass existing images to avoid deleting them in some backends
|
images: product.images,
|
||||||
variants: variants.map(v => ({
|
variants: variants.map((v) => ({
|
||||||
id: v.id,
|
id: v.id,
|
||||||
option1: v.option1,
|
option1: v.option1,
|
||||||
option2: v.option2,
|
option2: v.option2,
|
||||||
@@ -65,11 +104,12 @@ export default function ProductBomPage() {
|
|||||||
stock: v.stock,
|
stock: v.stock,
|
||||||
sku: v.sku,
|
sku: v.sku,
|
||||||
barcode: v.barcode,
|
barcode: v.barcode,
|
||||||
bomItems: v.bomItems?.map((b: any) => ({
|
bomItems:
|
||||||
rawMaterialId: b.rawMaterialId,
|
v.bomItems?.map((b: any) => ({
|
||||||
quantity: Number(b.quantity) || 0
|
rawMaterialId: Number(b.rawMaterialId) || 0,
|
||||||
})) || []
|
quantity: Number(b.quantity) || 0,
|
||||||
}))
|
})) || [],
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/products/${id}`, {
|
const response = await fetch(`${API_BASE}/products/${id}`, {
|
||||||
@@ -81,7 +121,7 @@ export default function ProductBomPage() {
|
|||||||
if (!response.ok) throw new Error('Failed to save BOM')
|
if (!response.ok) throw new Error('Failed to save BOM')
|
||||||
|
|
||||||
setSuccess(true)
|
setSuccess(true)
|
||||||
setTimeout(() => setSuccess(false), 3000)
|
setTimeout(() => setSuccess(false), 3500)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -91,156 +131,408 @@ export default function ProductBomPage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[50vh]">
|
<div className="flex flex-col items-center justify-center min-h-[50vh] space-y-3">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
|
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
|
||||||
|
<p className="text-xs text-slate-400">กำลังโหลดโครงสร้างต้นทุน (BOM)...</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!product) {
|
if (!product) {
|
||||||
return <div className="text-center py-12 text-slate-400">Product not found.</div>
|
return <div className="text-center py-12 text-slate-400">ไม่พบข้อมูลสินค้าที่ต้องการ</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
const productName = typeof product.name === 'string' ? product.name : (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
|
const productName =
|
||||||
|
typeof product.name === 'string'
|
||||||
|
? product.name
|
||||||
|
: (product.name as any)?.th || (product.name as any)?.en || 'Unknown Product'
|
||||||
|
|
||||||
|
// Summary Metrics Across All Variants
|
||||||
|
const totalVariantsCount = variants.length
|
||||||
|
const configuredVariantsCount = variants.filter(
|
||||||
|
(v) => v.bomItems && v.bomItems.some((b: any) => b.rawMaterialId > 0 && Number(b.quantity) > 0)
|
||||||
|
).length
|
||||||
|
const bomCosts = variants.map((v) => getVariantBomCost(v)).filter((c) => c > 0)
|
||||||
|
const minBomCost = bomCosts.length > 0 ? Math.min(...bomCosts) : 0
|
||||||
|
const maxBomCost = bomCosts.length > 0 ? Math.max(...bomCosts) : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-8 animate-fade-in max-w-5xl mx-auto">
|
<div className="w-full space-y-8 animate-fade-in max-w-6xl mx-auto pb-16">
|
||||||
{/* Header & Actions */}
|
{/* Header & Actions */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
|
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
|
||||||
<Link to="/products" className="hover:text-cyan-glow transition-colors">Products</Link>
|
<Link to="/products" className="hover:text-cyan-glow transition-colors flex items-center gap-1">
|
||||||
|
<ArrowLeft className="w-3.5 h-3.5 inline" /> Products
|
||||||
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-white">BOM</span>
|
<span className="text-white">Bill of Materials (BOM)</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">
|
<h1 className="text-3xl font-bold tracking-tight">
|
||||||
BOM <span className="text-cyan-glow">{productName}</span>
|
โครงสร้างต้นทุน BOM: <span className="text-cyan-glow">{productName}</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
คำนวณและแจกแจงต้นทุนวัตถุดิบ (Raw Materials) ต่อหน่วย พร้อมวิเคราะห์กำไรขั้นต้น (Gross Profit & Margin)
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/products')}
|
onClick={() => navigate('/products')}
|
||||||
className="px-6 py-2.5 rounded-xl border border-white/5 bg-white/5 hover:bg-white/10 text-sm font-bold transition-all"
|
className="px-5 py-2.5 rounded-xl border border-white/10 bg-white/5 hover:bg-white/10 text-sm font-bold text-slate-300 transition-all"
|
||||||
>
|
>
|
||||||
Cancel
|
ยกเลิก
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="btn-primary py-2.5 px-8 flex items-center gap-2"
|
className="btn-primary py-2.5 px-7 flex items-center gap-2 shadow-lg shadow-cyan-500/20"
|
||||||
>
|
>
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||||
{saving ? 'Saving...' : 'Save BOM'}
|
{saving ? 'กำลังบันทึก...' : 'บันทึก BOM'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts */}
|
||||||
{success && (
|
{success && (
|
||||||
<div className="flex items-center gap-3 p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-xl text-emerald-500 text-sm animate-fade-in">
|
<div className="flex items-center gap-3 p-4 bg-emerald-500/15 border border-emerald-500/30 rounded-2xl text-emerald-400 text-sm animate-fade-in shadow-lg">
|
||||||
<CheckCircle2 className="w-5 h-5 shrink-0" />
|
<CheckCircle2 className="w-5 h-5 shrink-0 text-emerald-400" />
|
||||||
<p>BOM updated successfully!</p>
|
<div>
|
||||||
|
<p className="font-bold">บันทึกโครงสร้างต้นทุน BOM สำเร็จ!</p>
|
||||||
|
<p className="text-xs text-emerald-300/80">
|
||||||
|
ระบบจะนำต้นทุน BOM ไปคำนวณกำไรในหน้ารายงานการขายและสินค้า Marketplace โดยอัตโนมัติ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-3 p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-500 text-sm animate-fade-in">
|
<div className="flex items-center gap-3 p-4 bg-rose-500/15 border border-rose-500/30 rounded-2xl text-rose-400 text-sm animate-fade-in shadow-lg">
|
||||||
<AlertCircle className="w-5 h-5 shrink-0" />
|
<AlertCircle className="w-5 h-5 shrink-0 text-rose-400" />
|
||||||
<p>{error}</p>
|
<p className="font-medium">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-8">
|
{/* ── Summary Overview KPI Cards ────────────────────────────────────────── */}
|
||||||
{variants.length === 0 ? (
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="admin-card p-8 text-center text-slate-400">
|
{/* Total Variants */}
|
||||||
No variants found for this product.
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนตัวเลือกสินค้า</p>
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
</div>
|
</div>
|
||||||
) : variants.map((v, vIdx) => {
|
<div>
|
||||||
const variantName = [v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default'
|
<p className="text-2xl font-bold text-white font-mono">
|
||||||
return (
|
{totalVariantsCount} <span className="text-xs font-normal text-slate-400">ตัวเลือก</span>
|
||||||
<section key={vIdx} className="admin-card space-y-4 relative">
|
</p>
|
||||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
<div className="flex items-center gap-3">
|
ผูก BOM แล้ว: <span className="text-emerald-400 font-bold">{configuredVariantsCount}</span> / {totalVariantsCount}
|
||||||
<div className="w-10 h-10 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center">
|
</p>
|
||||||
<Package className="w-5 h-5 text-cyan-glow" />
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM Cost Range */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ช่วงต้นทุน BOM</p>
|
||||||
|
<Coins className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-amber-300 font-mono">
|
||||||
|
{minBomCost === 0 && maxBomCost === 0
|
||||||
|
? 'ยังไม่ระบุ'
|
||||||
|
: minBomCost === maxBomCost
|
||||||
|
? fmtMoney(minBomCost)
|
||||||
|
: `${fmtMoney(minBomCost)} - ${fmtMoney(maxBomCost)}`}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">ต้นทุนวัตถุดิบต่อ 1 ชิ้น</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Master Raw Materials Catalog */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">คลังวัตถุดิบในระบบ</p>
|
||||||
|
<Package className="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-indigo-300 font-mono">
|
||||||
|
{rawMaterials.length} <span className="text-xs font-normal text-slate-400">รายการ</span>
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/raw-materials"
|
||||||
|
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors inline-flex items-center gap-1 mt-1"
|
||||||
|
>
|
||||||
|
<span>จัดการราคาวัตถุดิบ</span> →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Auto Calculation Status */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ระบบคำนวณอัตโนมัติ</p>
|
||||||
|
<Calculator className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-emerald-400 flex items-center gap-1.5">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" /> Live Real-time Sync
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1">ปรับสูตร BOM ปุ๊บ ต้นทุนและกำไรจะอัปเดตทันที</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Variants BOM List ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{variants.length === 0 ? (
|
||||||
|
<div className="admin-card p-12 text-center text-slate-400 space-y-2">
|
||||||
|
<Package className="w-10 h-10 mx-auto text-slate-600 mb-2" />
|
||||||
|
<p className="text-base font-bold text-white">ไม่พบตัวเลือกสินค้า (Variants)</p>
|
||||||
|
<p className="text-xs text-slate-500">กรุณาเพิ่มตัวเลือกสินค้าในหน้ารายละเอียดสินค้าก่อน</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
variants.map((v, vIdx) => {
|
||||||
|
const variantName =
|
||||||
|
[v.option1, v.option2, v.option3].filter(Boolean).join(' - ') || 'Default'
|
||||||
|
const variantBomCost = getVariantBomCost(v)
|
||||||
|
const retailPrice = Number(v.price) || 0
|
||||||
|
const profit = retailPrice - variantBomCost
|
||||||
|
const marginPercent = retailPrice > 0 ? (profit / retailPrice) * 100 : 0
|
||||||
|
const hasBom = v.bomItems && v.bomItems.length > 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={v.id || vIdx}
|
||||||
|
className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm space-y-5 shadow-xl transition-all"
|
||||||
|
>
|
||||||
|
{/* Variant Header & Price Summary */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between pb-4 border-b border-white/10 gap-4">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-cyan-500/10 border border-cyan-500/20 flex items-center justify-center shrink-0">
|
||||||
|
<Package className="w-6 h-6 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-bold text-lg text-white">{variantName}</h3>
|
||||||
|
{v.sku && (
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-black/40 text-[11px] font-mono text-cyan-300 border border-white/5">
|
||||||
|
SKU: {v.sku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.barcode && (
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-black/40 text-[11px] font-mono text-slate-400 border border-white/5">
|
||||||
|
Barcode: {v.barcode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
สต็อกคงเหลือ: <span className="text-white font-mono">{v.stock ?? 0}</span> ชิ้น
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<h3 className="font-bold text-lg text-white">{variantName}</h3>
|
{/* Financial KPI Badges for this Variant */}
|
||||||
<p className="text-xs text-slate-500 uppercase tracking-widest">SKU: {v.sku || 'N/A'}</p>
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* Retail Price */}
|
||||||
|
<div className="px-3.5 py-2 rounded-xl bg-black/40 border border-white/10 text-right min-w-[120px]">
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-wider block">ราคาขาย</span>
|
||||||
|
<span className="text-sm font-bold text-white font-mono">{fmtMoney(retailPrice)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total BOM Cost */}
|
||||||
|
<div className="px-3.5 py-2 rounded-xl bg-amber-500/10 border border-amber-500/30 text-right min-w-[130px]">
|
||||||
|
<span className="text-[10px] text-amber-300 uppercase tracking-wider block font-semibold">
|
||||||
|
ต้นทุน BOM รวม
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-amber-400 font-mono">{fmtMoney(variantBomCost)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Estimated Profit */}
|
||||||
|
<div
|
||||||
|
className={`px-3.5 py-2 rounded-xl border text-right min-w-[140px] ${
|
||||||
|
profit >= 0
|
||||||
|
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400'
|
||||||
|
: 'bg-rose-500/10 border-rose-500/30 text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider block font-semibold opacity-80">
|
||||||
|
กำไรประเมิน / ชิ้น
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-end gap-1 font-mono">
|
||||||
|
<span className="text-sm font-bold">{fmtMoney(profit)}</span>
|
||||||
|
{retailPrice > 0 && (
|
||||||
|
<span className="text-[10px] font-bold opacity-90">({marginPercent.toFixed(1)}%)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Material Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newVariants = [...variants]
|
||||||
|
if (!newVariants[vIdx].bomItems) newVariants[vIdx].bomItems = []
|
||||||
|
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
|
||||||
|
setVariants(newVariants)
|
||||||
|
}}
|
||||||
|
className="px-3.5 py-2.5 bg-white/10 hover:bg-cyan-500/20 hover:text-cyan-300 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 border border-white/10"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>เพิ่มวัตถุดิบ</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const newVariants = [...variants]
|
|
||||||
if (!newVariants[vIdx].bomItems) newVariants[vIdx].bomItems = []
|
|
||||||
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
|
|
||||||
setVariants(newVariants)
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-white rounded-lg text-sm font-bold transition-all flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
Add Material
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3 pt-2">
|
{/* BOM Items Table / Cards */}
|
||||||
{(!v.bomItems || v.bomItems.length === 0) ? (
|
<div className="space-y-2.5 pt-1">
|
||||||
<div className="text-center py-6 text-slate-500 text-sm italic bg-white/[0.02] rounded-xl border border-white/5 border-dashed">
|
{!hasBom ? (
|
||||||
No materials specified for this variant.
|
<div className="text-center py-8 text-slate-400 text-xs bg-white/[0.02] rounded-2xl border border-dashed border-white/10 space-y-2">
|
||||||
</div>
|
<p>ยังไม่มีการกำหนดส่วนผสม / วัตถุดิบสำหรับตัวเลือกนี้</p>
|
||||||
) : v.bomItems.map((bom: any, bIdx: number) => (
|
|
||||||
<div key={bIdx} className="flex items-center gap-4 bg-night-950/50 p-4 rounded-xl border border-white/5">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Raw Material</label>
|
|
||||||
<select
|
|
||||||
value={bom.rawMaterialId || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newVariants = [...variants]
|
|
||||||
newVariants[vIdx].bomItems[bIdx].rawMaterialId = Number(e.target.value)
|
|
||||||
setVariants(newVariants)
|
|
||||||
}}
|
|
||||||
className="w-full px-4 py-2.5 bg-night-950 border border-white/10 focus:border-cyan-glow/30 rounded-lg outline-none text-sm text-white transition-all shadow-inner"
|
|
||||||
>
|
|
||||||
<option value="" disabled>Select Raw Material</option>
|
|
||||||
{rawMaterials.map(rm => (
|
|
||||||
<option key={rm.id} value={rm.id}>{rm.name} ({rm.unit})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-48">
|
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1 mb-1 block">Quantity</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={bom.quantity || ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="pt-5">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newVariants = [...variants]
|
const newVariants = [...variants]
|
||||||
newVariants[vIdx].bomItems.splice(bIdx, 1)
|
if (!newVariants[vIdx].bomItems) newVariants[vIdx].bomItems = []
|
||||||
|
newVariants[vIdx].bomItems.push({ rawMaterialId: 0, quantity: 1 })
|
||||||
setVariants(newVariants)
|
setVariants(newVariants)
|
||||||
}}
|
}}
|
||||||
className="p-2.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-lg transition-colors"
|
className="px-4 py-1.5 rounded-xl bg-cyan-500/20 text-cyan-300 border border-cyan-500/30 text-xs font-semibold hover:bg-cyan-500/30 transition-all inline-flex items-center gap-1"
|
||||||
title="Remove Material"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-5 h-5" />
|
<Plus className="w-3.5 h-3.5" /> + เพิ่มวัตถุดิบแรก
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
v.bomItems.map((bom: any, bIdx: number) => {
|
||||||
|
const selectedMat = getMaterial(bom.rawMaterialId)
|
||||||
|
const lineCost = getLineCost(bom)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={bIdx}
|
||||||
|
className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-black/30 p-3.5 rounded-xl border border-white/10 hover:border-white/20 transition-all"
|
||||||
|
>
|
||||||
|
{/* Col 1: Material Selector */}
|
||||||
|
<div className="flex-1 min-w-[260px]">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
|
||||||
|
วัตถุดิบ (Raw Material)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={bom.rawMaterialId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newVariants = [...variants]
|
||||||
|
newVariants[vIdx].bomItems[bIdx].rawMaterialId = Number(e.target.value)
|
||||||
|
setVariants(newVariants)
|
||||||
|
}}
|
||||||
|
className="w-full px-3.5 py-2 bg-slate-900 border border-white/10 focus:border-cyan-400/40 rounded-xl outline-none text-xs text-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
-- เลือกวัตถุดิบจากระบบ --
|
||||||
|
</option>
|
||||||
|
{rawMaterials.map((rm) => (
|
||||||
|
<option key={rm.id} value={rm.id}>
|
||||||
|
{rm.name} ({rm.unit}) — {fmtMoney(rm.pricePerUnit)}/{rm.unit}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2: Price Per Unit Display */}
|
||||||
|
<div className="w-full md:w-36">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
|
||||||
|
ราคาต่อหน่วย
|
||||||
|
</label>
|
||||||
|
<div className="px-3.5 py-2 bg-white/5 border border-white/5 rounded-xl text-xs font-mono text-slate-300">
|
||||||
|
{selectedMat ? (
|
||||||
|
<span>
|
||||||
|
{fmtMoney(selectedMat.pricePerUnit)}{' '}
|
||||||
|
<span className="text-[10px] text-slate-500">/{selectedMat.unit}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3: Quantity Input */}
|
||||||
|
<div className="w-full md:w-36">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 ml-1 mb-1 block">
|
||||||
|
ปริมาณที่ใช้ {selectedMat ? `(${selectedMat.unit})` : ''}
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={bom.quantity === '' ? '' : bom.quantity}
|
||||||
|
onChange={(e) => {
|
||||||
|
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 && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-[10px] text-slate-500 font-mono pointer-events-none">
|
||||||
|
{selectedMat.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 4: Line Subtotal Cost Display */}
|
||||||
|
<div className="w-full md:w-36 text-right">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-wider text-slate-400 mr-1 mb-1 block">
|
||||||
|
ต้นทุนรวมรายการนี้
|
||||||
|
</label>
|
||||||
|
<div className="px-3.5 py-2 bg-amber-500/10 border border-amber-500/20 rounded-xl text-xs font-mono font-bold text-amber-400 text-right">
|
||||||
|
{fmtMoney(lineCost)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Col 5: Remove Button */}
|
||||||
|
<div className="flex items-center justify-end pt-2 md:pt-5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newVariants = [...variants]
|
||||||
|
newVariants[vIdx].bomItems.splice(bIdx, 1)
|
||||||
|
setVariants(newVariants)
|
||||||
|
}}
|
||||||
|
className="p-2 text-slate-500 hover:text-rose-400 hover:bg-rose-500/10 rounded-xl transition-colors"
|
||||||
|
title="ลบวัตถุดิบนี้"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variant Footer Breakdown */}
|
||||||
|
{hasBom && (
|
||||||
|
<div className="pt-3 border-t border-white/5 flex flex-col sm:flex-row sm:items-center justify-between text-xs text-slate-400 gap-2">
|
||||||
|
<p>
|
||||||
|
ใช้วัตถุดิบทั้งหมด <span className="text-white font-bold">{v.bomItems.length}</span> รายการ
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4 font-mono">
|
||||||
|
<span>
|
||||||
|
ต้นทุนรวม BOM: <strong className="text-amber-400 text-sm">{fmtMoney(variantBomCost)}</strong>
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
กำไรประเมิน:{' '}
|
||||||
|
<strong className={profit >= 0 ? 'text-emerald-400 text-sm' : 'text-rose-400 text-sm'}>
|
||||||
|
{fmtMoney(profit)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</section>
|
)
|
||||||
)
|
})
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
BadgePercent,
|
BadgePercent,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { API_BASE } from '@homaroma/types'
|
||||||
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '')
|
|
||||||
|
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
return {
|
return {
|
||||||
@@ -56,7 +55,7 @@ export default function ShopeeOrderDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!orderId) return
|
if (!orderId) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
fetch(`${API_URL}/api/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() })
|
fetch(`${API_BASE}/shopee/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error) throw new Error(data.error)
|
if (data.error) throw new Error(data.error)
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store } from 'lucide-react';
|
import {
|
||||||
|
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
|
||||||
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '');
|
ReceiptText, ChevronLeft, ChevronRight, ExternalLink, Store,
|
||||||
|
FileSpreadsheet, AlertTriangle, CheckCircle2, Files
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
|
||||||
// ── Shop Config ───────────────────────────────────────────────────────────────
|
// ── Shop Config ───────────────────────────────────────────────────────────────
|
||||||
export const SHOPEE_SHOPS = [
|
export const SHOPEE_SHOPS = [
|
||||||
@@ -92,7 +95,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch(`${API_URL}/api/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() })
|
fetch(`${API_BASE}/shopee/orders?page=${page}&limit=20&shop=${shop}`, { headers: authHeaders() })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(d => {
|
.then(d => {
|
||||||
setItems(d.items || []);
|
setItems(d.items || []);
|
||||||
@@ -181,13 +184,23 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import Panel ──────────────────────────────────────────────────────────────
|
// ── Import Panel (Supports Multiple Excel Files & Duplicate Detection) ────────
|
||||||
|
interface FileSummary {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
sheetName: string;
|
||||||
|
rowsCount: number;
|
||||||
|
ordersCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) {
|
function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) {
|
||||||
const [previewRows, setPreviewRows] = useState<any[]>([]);
|
const [fileSummaries, setFileSummaries] = useState<FileSummary[]>([]);
|
||||||
const [allRows, setAllRows] = useState<any[]>([]);
|
const [allRows, setAllRows] = useState<any[]>([]);
|
||||||
|
const [previewRows, setPreviewRows] = useState<any[]>([]);
|
||||||
|
const [crossFileDuplicateCount, setCrossFileDuplicateCount] = useState(0);
|
||||||
|
const [reading, setReading] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
const [msg, setMsg] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
|
||||||
const [sheetInfo, setSheetInfo] = useState('');
|
|
||||||
const shopCfg = getShop(shop);
|
const shopCfg = getShop(shop);
|
||||||
|
|
||||||
const readFile = (f: File) => new Promise<XLSX.WorkBook>((resolve, reject) => {
|
const readFile = (f: File) => new Promise<XLSX.WorkBook>((resolve, reject) => {
|
||||||
@@ -201,37 +214,92 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const f = e.target.files?.[0];
|
const files = e.target.files ? Array.from(e.target.files) : [];
|
||||||
if (!f) return;
|
if (files.length === 0) return;
|
||||||
setMsg(null); setAllRows([]); setPreviewRows([]); setSheetInfo('');
|
|
||||||
|
setReading(true);
|
||||||
|
setMsg(null);
|
||||||
|
setAllRows([]);
|
||||||
|
setPreviewRows([]);
|
||||||
|
setFileSummaries([]);
|
||||||
|
setCrossFileDuplicateCount(0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const wb = await readFile(f);
|
const summaries: FileSummary[] = [];
|
||||||
const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0];
|
const combinedRows: any[] = [];
|
||||||
const ws = wb.Sheets[sheetName];
|
const orderFileMap = new Map<string, Set<string>>();
|
||||||
setSheetInfo(`Sheet: "${sheetName}" | ${f.name}`);
|
|
||||||
const rawRows = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][];
|
for (const file of files) {
|
||||||
if (rawRows.length < 2) { setMsg({ text: 'ไม่พบข้อมูลในไฟล์', type: 'error' }); return; }
|
const wb = await readFile(file);
|
||||||
const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : '');
|
const sheetName = wb.SheetNames.find(n => n.toLowerCase().includes('order')) ?? wb.SheetNames[0];
|
||||||
const rows: any[] = [];
|
const ws = wb.Sheets[sheetName];
|
||||||
for (let i = 1; i < rawRows.length; i++) {
|
const rawRows = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1, defval: null, blankrows: false, raw: false }) as any[][];
|
||||||
const cells = rawRows[i] as any[];
|
|
||||||
if (cells.every(c => c === null || c === undefined || c === '')) continue;
|
if (rawRows.length < 2) continue;
|
||||||
const row: any = {};
|
|
||||||
headers.forEach((h, idx) => { row[h] = cells[idx] ?? null; });
|
const headers = (rawRows[0] as any[]).map(h => h != null ? String(h).trim() : '');
|
||||||
if (row['หมายเลขคำสั่งซื้อ']) rows.push(row);
|
const fileRows: any[] = [];
|
||||||
|
const fileOrderIds = new Set<string>();
|
||||||
|
|
||||||
|
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) {
|
} catch (err: any) {
|
||||||
setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' });
|
setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setReading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleImport = async () => {
|
const handleImport = async () => {
|
||||||
if (allRows.length === 0) return;
|
if (allRows.length === 0) return;
|
||||||
setLoading(true); setMsg(null);
|
setLoading(true);
|
||||||
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_URL}/api/shopee/import/orders`, {
|
const res = await fetch(`${API_BASE}/shopee/import/orders`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ rows: allRows, shop }),
|
body: JSON.stringify({ rows: allRows, shop }),
|
||||||
@@ -239,14 +307,21 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
|
|||||||
const result = await res.json();
|
const result = await res.json();
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setMsg({ text: result.message, type: 'success' });
|
setMsg({ text: result.message, type: 'success' });
|
||||||
setAllRows([]); setPreviewRows([]); setSheetInfo('');
|
setAllRows([]);
|
||||||
|
setPreviewRows([]);
|
||||||
|
setFileSummaries([]);
|
||||||
|
setCrossFileDuplicateCount(0);
|
||||||
const inp = document.getElementById('shopee-file-input') as HTMLInputElement;
|
const inp = document.getElementById('shopee-file-input') as HTMLInputElement;
|
||||||
if (inp) inp.value = '';
|
if (inp) inp.value = '';
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else throw new Error(result.message);
|
} else {
|
||||||
|
throw new Error(result.message);
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setMsg({ text: e.message, type: 'error' });
|
setMsg({ text: e.message, type: 'error' });
|
||||||
} finally { setLoading(false); }
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size;
|
const uniqueOrdersCount = new Set(allRows.map(r => r['หมายเลขคำสั่งซื้อ'])).size;
|
||||||
@@ -270,30 +345,81 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
|
|||||||
|
|
||||||
<label
|
<label
|
||||||
htmlFor="shopee-file-input"
|
htmlFor="shopee-file-input"
|
||||||
className="flex flex-col items-center justify-center border-2 border-dashed border-white/10 hover:border-orange-500/30 rounded-2xl p-12 bg-black/20 cursor-pointer transition-all group gap-4"
|
className="flex flex-col items-center justify-center border-2 border-dashed border-white/10 hover:border-orange-500/30 rounded-2xl p-10 bg-black/20 cursor-pointer transition-all group gap-4"
|
||||||
>
|
>
|
||||||
<div className={`w-16 h-16 rounded-2xl ${shopCfg.bg} border ${shopCfg.border} flex items-center justify-center group-hover:opacity-80 transition-opacity`}>
|
<div className={`w-16 h-16 rounded-2xl ${shopCfg.bg} border ${shopCfg.border} flex items-center justify-center group-hover:opacity-80 transition-opacity`}>
|
||||||
<Upload className={`w-8 h-8 ${shopCfg.color}`} />
|
<Upload className={`w-8 h-8 ${shopCfg.color}`} />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-white font-bold">คลิกเพื่อเลือกไฟล์ Excel</p>
|
<p className="text-white font-bold text-base">คลิกเพื่อเลือกไฟล์ Excel (เลือกได้หลายไฟล์พร้อมกัน)</p>
|
||||||
<p className="text-slate-500 text-sm mt-1">รองรับ .xlsx จาก Shopee Seller Center (Orders Report)</p>
|
<p className="text-slate-400 text-xs mt-1">กด <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Ctrl</kbd> หรือ <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Shift</kbd> ค้างไว้เพื่อเลือกหลายไฟล์ (.xlsx, .xls จาก Shopee Seller Center)</p>
|
||||||
</div>
|
</div>
|
||||||
<input id="shopee-file-input" type="file" accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
|
<input id="shopee-file-input" type="file" multiple accept=".xlsx,.xls" onChange={handleFileChange} className="hidden" />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{sheetInfo && (
|
{reading && (
|
||||||
<div className="flex items-center justify-between p-3 bg-white/5 rounded-xl border border-white/10 text-xs font-mono text-slate-400">
|
<div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2">
|
||||||
<span>📋 {sheetInfo}</span>
|
<span className="animate-spin">⏳</span> กำลังประมวลผลไฟล์ Excel ทั้งหมด...
|
||||||
<span className={`font-bold ${allRows.length > 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
|
|
||||||
{allRows.length} แถว → {uniqueOrdersCount} คำสั่งซื้อ
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Selected Files Summary Cards */}
|
||||||
|
{fileSummaries.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-bold text-white">
|
||||||
|
<Files className="w-4 h-4 text-orange-400" />
|
||||||
|
ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์)
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400">
|
||||||
|
รวมทั้งหมด <span className="text-white font-bold">{allRows.length}</span> แถวสินค้า → <span className="text-emerald-400 font-bold">{uniqueOrdersCount}</span> คำสั่งซื้อไม่ซ้ำ
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{fileSummaries.map((f, idx) => (
|
||||||
|
<div key={idx} className="bg-white/5 border border-white/10 rounded-xl p-3.5 flex flex-col gap-2">
|
||||||
|
<div className="flex items-start gap-2.5">
|
||||||
|
<FileSpreadsheet className="w-4 h-4 text-orange-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs font-semibold text-white truncate" title={f.name}>{f.name}</p>
|
||||||
|
<p className="text-[11px] text-slate-400 font-mono mt-0.5">
|
||||||
|
{(f.size / 1024).toFixed(1)} KB • Sheet: {f.sheetName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs pt-2 border-t border-white/5 text-slate-400">
|
||||||
|
<span>{f.rowsCount} แถว</span>
|
||||||
|
<span className="font-bold text-orange-300">{f.ordersCount} ออเดอร์</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deduplication & Safety Alert */}
|
||||||
|
{crossFileDuplicateCount > 0 ? (
|
||||||
|
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-xs space-y-1">
|
||||||
|
<p className="font-bold text-amber-300">ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ</p>
|
||||||
|
<p className="text-slate-300">
|
||||||
|
ระบบจะทำการรวมข้อมูลและ <strong>Upsert (อัปเดตอัตโนมัติ)</strong> ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2.5 text-xs text-emerald-300">
|
||||||
|
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
|
||||||
|
<span>ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview Table */}
|
||||||
{previewRows.length > 0 && (
|
{previewRows.length > 0 && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<p className="text-sm text-slate-400 font-medium">Preview (5 แถวแรก):</p>
|
<p className="text-sm text-slate-400 font-medium">Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):</p>
|
||||||
<div className="overflow-x-auto border border-white/10 rounded-xl">
|
<div className="overflow-x-auto border border-white/10 rounded-xl">
|
||||||
<table className="w-full text-xs text-left">
|
<table className="w-full text-xs text-left">
|
||||||
<thead className="bg-white/5 text-slate-400 border-b border-white/10">
|
<thead className="bg-white/5 text-slate-400 border-b border-white/10">
|
||||||
@@ -316,13 +442,17 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={loading}
|
disabled={loading || reading}
|
||||||
className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`}
|
className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`}
|
||||||
>
|
>
|
||||||
{loading ? <><span className="animate-spin">⏳</span> กำลัง Import...</> : <><Upload className="w-4 h-4" /> Import {uniqueOrdersCount} คำสั่งซื้อ ({allRows.length} แถว) → {shopCfg.label}</>}
|
{loading ? (
|
||||||
|
<><span className="animate-spin">⏳</span> กำลัง Import {fileSummaries.length} ไฟล์...</>
|
||||||
|
) : (
|
||||||
|
<><Upload className="w-4 h-4" /> Import {uniqueOrdersCount} คำสั่งซื้อ ({allRows.length} แถวสินค้า) → {shopCfg.label}</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +473,7 @@ export default function ShopeePage() {
|
|||||||
|
|
||||||
const loadSummary = (s: string) => {
|
const loadSummary = (s: string) => {
|
||||||
setSummary(null);
|
setSummary(null);
|
||||||
fetch(`${API_URL}/api/shopee/summary?shop=${s}`, { headers: authHeaders() })
|
fetch(`${API_BASE}/shopee/summary?shop=${s}`, { headers: authHeaders() })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(setSummary)
|
.then(setSummary)
|
||||||
.catch(() => { });
|
.catch(() => { });
|
||||||
|
|||||||
@@ -0,0 +1,525 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Map, ShoppingBag, DollarSign, Award,
|
||||||
|
Filter, Calendar, Table2, Search,
|
||||||
|
MapPin,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg';
|
||||||
|
import DistrictMapModal from '../components/tiktok-map/DistrictMapModal';
|
||||||
|
import { fmtBaht, fmtNum } from '../utils/thaiGeoUtils';
|
||||||
|
import { SHOPEE_SHOPS } from './ShopeePage';
|
||||||
|
|
||||||
|
type ViewMode = 'map' | 'list';
|
||||||
|
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
|
||||||
|
|
||||||
|
export default function ShopeeSalesMapPage() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||||
|
const [shop, setShop] = useState('all');
|
||||||
|
const [period, setPeriod] = useState<TimePeriod>('all');
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [mapData, setMapData] = useState<any>(null);
|
||||||
|
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [listSearch, setListSearch] = useState('');
|
||||||
|
|
||||||
|
// Fetch sales map data
|
||||||
|
const loadData = () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (shop && shop !== 'all') params.set('shop', shop);
|
||||||
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/shopee/sales-map?${params.toString()}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setMapData(data))
|
||||||
|
.catch((err) => console.error('Failed to load Shopee sales map data:', err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [shop, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
// Handle preset period change
|
||||||
|
const handlePeriodChange = (p: TimePeriod) => {
|
||||||
|
setPeriod(p);
|
||||||
|
const now = new Date();
|
||||||
|
if (p === 'all') {
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
} else if (p === '30days') {
|
||||||
|
const past = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
setDateFrom(past.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
} else if (p === 'this_year') {
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
setDateFrom(startOfYear.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provinces lookup map by nameTh
|
||||||
|
const provincesLookup = useMemo(() => {
|
||||||
|
const map: Record<string, any> = {};
|
||||||
|
if (mapData?.provinces) {
|
||||||
|
for (const p of mapData.provinces) {
|
||||||
|
map[p.nameTh] = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [mapData]);
|
||||||
|
|
||||||
|
// Selected province detail
|
||||||
|
const currentProvinceInfo = useMemo(() => {
|
||||||
|
if (!selectedProvince || !provincesLookup[selectedProvince]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return provincesLookup[selectedProvince];
|
||||||
|
}, [selectedProvince, provincesLookup]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in text-white pb-10">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col xl:flex-row xl:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3.5">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-orange-500/15 border border-orange-500/30 flex items-center justify-center text-orange-400">
|
||||||
|
<Map className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white">
|
||||||
|
แผนที่ยอดขายประเทศไทย (Shopee Sales Map)
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
วิเคราะห์การกระจายตัวของยอดขาย Shopee และศักยภาพลูกค้าแยกตาม 77 จังหวัด
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Control Bar */}
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
{/* View Mode Toggle */}
|
||||||
|
<div className="flex items-center p-1 bg-slate-900 border border-white/10 rounded-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'map'
|
||||||
|
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="w-3.5 h-3.5" /> มุมมองแผนที่
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Table2 className="w-3.5 h-3.5" /> มุมมองรายการ
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shop Switcher */}
|
||||||
|
<select
|
||||||
|
value={shop}
|
||||||
|
onChange={(e) => setShop(e.target.value)}
|
||||||
|
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50"
|
||||||
|
>
|
||||||
|
<option value="all">ทั้งหมด (ทุกร้าน)</option>
|
||||||
|
{SHOPEE_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Period Preset */}
|
||||||
|
<select
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => handlePeriodChange(e.target.value as TimePeriod)}
|
||||||
|
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50"
|
||||||
|
>
|
||||||
|
<option value="all">ทั้งหมด (All Time)</option>
|
||||||
|
<option value="30days">30 วันล่าสุด</option>
|
||||||
|
<option value="this_year">ปีนี้</option>
|
||||||
|
<option value="custom">กำหนดเอง</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Custom Date Pickers */}
|
||||||
|
<div className="flex items-center gap-1.5 bg-slate-900 border border-white/10 rounded-xl px-2 py-1">
|
||||||
|
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateFrom(e.target.value);
|
||||||
|
setPeriod('custom');
|
||||||
|
}}
|
||||||
|
className="bg-transparent text-xs text-slate-300 focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-600 text-xs">-</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDateTo(e.target.value);
|
||||||
|
setPeriod('custom');
|
||||||
|
}}
|
||||||
|
className="bg-transparent text-xs text-slate-300 focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
{loading ? <span className="animate-spin">⏳</span> : <Filter className="w-3.5 h-3.5" />}
|
||||||
|
{loading ? 'กำลังโหลด...' : 'กรอง'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 5 Cards Bar */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3.5">
|
||||||
|
{/* KPI 1 */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>ยอดขายรวม</span>
|
||||||
|
<span className="text-orange-400 font-bold text-[11px] bg-orange-500/10 px-1.5 py-0.5 rounded-full border border-orange-500/20">
|
||||||
|
Shopee
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-white font-mono">
|
||||||
|
{fmtBaht(mapData?.kpi?.totalRevenue || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">Shopee Total Revenue</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 2 */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>คำสั่งซื้อสะสม</span>
|
||||||
|
<ShoppingBag className="w-3.5 h-3.5 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-white font-mono">
|
||||||
|
{fmtNum(mapData?.kpi?.totalOrders || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">รวมทุกคำสั่งซื้อ Shopee</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 3 */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>การเข้าถึงจังหวัด</span>
|
||||||
|
<span className="text-emerald-400 font-bold text-[11px] font-mono">
|
||||||
|
{(mapData?.kpi?.coveragePercent || 0).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono">
|
||||||
|
{mapData?.kpi?.coverageCount || 0}{' '}
|
||||||
|
<span className="text-xs text-slate-500 font-normal">/ 77 จังหวัด</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">ครอบคลุมทั่วประเทศ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 4 */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>ยอดเฉลี่ย/ออเดอร์</span>
|
||||||
|
<DollarSign className="w-3.5 h-3.5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-amber-400 font-mono">
|
||||||
|
{fmtBaht(mapData?.kpi?.aov || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">AOV คำนวณจากทุกคำสั่งซื้อ</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI 5 */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between col-span-2 md:col-span-1">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>ภาคที่มีสัดส่วนสูงสุด</span>
|
||||||
|
<span className="text-orange-400 font-bold text-[10px] bg-orange-500/10 px-2 py-0.5 rounded-full border border-orange-500/20">
|
||||||
|
Top Region
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-xl md:text-2xl font-bold text-white tracking-tight">
|
||||||
|
{mapData?.kpi?.topRegion?.name || 'ภาคกลาง'}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||||
|
{fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ (
|
||||||
|
{fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
{viewMode === 'map' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
||||||
|
{/* Left/Center: Thailand SVG Map & Province Info Card (8 Cols) */}
|
||||||
|
<div className="lg:col-span-8 flex flex-col gap-4">
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6 flex flex-col">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-white">แผนที่ยอดขายประเทศไทย (Shopee)</h2>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map & Focus Card Row */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-5 items-center">
|
||||||
|
{/* SVG Map (7 Cols) */}
|
||||||
|
<div className="md:col-span-7">
|
||||||
|
<ThailandMapSvg
|
||||||
|
provincesData={provincesLookup}
|
||||||
|
selectedProvince={selectedProvince}
|
||||||
|
onSelectProvince={(nameTh) => {
|
||||||
|
if (selectedProvince === nameTh) {
|
||||||
|
setSelectedProvince(null);
|
||||||
|
} else {
|
||||||
|
setSelectedProvince(nameTh);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Province Focus Card (5 Cols) */}
|
||||||
|
<div className="md:col-span-5 flex flex-col bg-slate-950/70 border border-white/10 rounded-2xl p-5 space-y-4">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-slate-500 uppercase tracking-wider font-bold">
|
||||||
|
จังหวัด
|
||||||
|
</span>
|
||||||
|
{selectedProvince && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedProvince(null)}
|
||||||
|
className="text-xs text-orange-400 hover:underline font-bold"
|
||||||
|
>
|
||||||
|
ดูทั่วประเทศ
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
|
||||||
|
{currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-2xl font-bold text-orange-400 font-mono mt-1">
|
||||||
|
{fmtBaht(
|
||||||
|
currentProvinceInfo
|
||||||
|
? currentProvinceInfo.revenue
|
||||||
|
: mapData?.kpi?.totalRevenue || 0
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{fmtNum(
|
||||||
|
currentProvinceInfo
|
||||||
|
? currentProvinceInfo.orders
|
||||||
|
: mapData?.kpi?.totalOrders || 0
|
||||||
|
)}{' '}
|
||||||
|
คำสั่งซื้อ •{' '}
|
||||||
|
{currentProvinceInfo
|
||||||
|
? currentProvinceInfo.regionTh
|
||||||
|
: `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color Scale Legend */}
|
||||||
|
<div className="space-y-1.5 pt-2">
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-500">
|
||||||
|
<span>ไม่มียอด</span>
|
||||||
|
<span>ยอดสูง</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Focus Button */}
|
||||||
|
{selectedProvince ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MapPin className="w-4 h-4" /> ดูรายละเอียดและแผนที่อำเภอ (Map Focus)
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[11px] text-slate-500 leading-relaxed pt-1">
|
||||||
|
เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Top 20 Provinces Ranking (4 Cols) */}
|
||||||
|
<div className="lg:col-span-4 flex flex-col bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6">
|
||||||
|
<div className="flex items-center justify-between pb-4 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="w-5 h-5 text-orange-400" />
|
||||||
|
<h2 className="text-base font-bold text-white">จังหวัดยอดขายสูง (Shopee)</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">Top 20</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto max-h-[600px] divide-y divide-white/5 space-y-1 pr-1 mt-2">
|
||||||
|
{mapData?.topProvinces?.map((p: any) => {
|
||||||
|
const isSelected = selectedProvince === p.nameTh;
|
||||||
|
const isTop3 = p.rank <= 3;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.code || p.nameTh}
|
||||||
|
onClick={() => setSelectedProvince(p.nameTh)}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-2xl cursor-pointer transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-orange-500/20 border border-orange-500/40 text-orange-300'
|
||||||
|
: 'hover:bg-white/5 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold font-mono ${
|
||||||
|
isTop3
|
||||||
|
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
|
||||||
|
: 'bg-white/5 text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.rank}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-white flex items-center gap-1.5">
|
||||||
|
{p.nameTh}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] text-slate-500">
|
||||||
|
{p.orders} ออเดอร์ • {p.regionTh}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-bold font-mono text-orange-400">
|
||||||
|
{fmtBaht(p.revenue)}
|
||||||
|
</p>
|
||||||
|
<p className="text-[9px] text-slate-500">
|
||||||
|
AOV {fmtBaht(p.aov)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* List / Table View */
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||||
|
<h2 className="text-base font-bold text-white">
|
||||||
|
ตารางสรุปยอดขาย Shopee แยกราย 77 จังหวัด
|
||||||
|
</h2>
|
||||||
|
<div className="relative w-64">
|
||||||
|
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={listSearch}
|
||||||
|
onChange={(e) => setListSearch(e.target.value)}
|
||||||
|
placeholder="ค้นหาชื่อจังหวัด หรือ ภาค..."
|
||||||
|
className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto border border-white/10 rounded-2xl">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">รหัส</th>
|
||||||
|
<th className="px-4 py-3">จังหวัด</th>
|
||||||
|
<th className="px-4 py-3">ภูมิภาค</th>
|
||||||
|
<th className="px-4 py-3 text-right">ยอดขายรวม (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">จำนวนออเดอร์</th>
|
||||||
|
<th className="px-4 py-3 text-right">AOV (฿/ออเดอร์)</th>
|
||||||
|
<th className="px-4 py-3 text-center">จัดการ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{mapData?.provinces
|
||||||
|
?.filter(
|
||||||
|
(p: any) =>
|
||||||
|
!listSearch ||
|
||||||
|
p.nameTh.includes(listSearch) ||
|
||||||
|
p.nameEn.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||||
|
p.regionTh.includes(listSearch)
|
||||||
|
)
|
||||||
|
.sort((a: any, b: any) => b.revenue - a.revenue)
|
||||||
|
.map((p: any) => (
|
||||||
|
<tr
|
||||||
|
key={p.code || p.nameTh}
|
||||||
|
className="hover:bg-white/[0.03] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-white">
|
||||||
|
{p.nameTh}{' '}
|
||||||
|
<span className="text-slate-500 text-[10px] font-mono">({p.nameEn})</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{p.regionTh}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-bold text-orange-400">
|
||||||
|
{fmtBaht(p.revenue)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-white">
|
||||||
|
{fmtNum(p.orders)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-amber-400">
|
||||||
|
{fmtBaht(p.orders > 0 ? p.revenue / p.orders : 0)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvince(p.nameTh);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold border border-orange-500/20 transition-colors inline-flex items-center gap-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3 h-3" /> แผนที่อำเภอ
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* District Focus Modal Dialog */}
|
||||||
|
{isModalOpen && selectedProvince && (
|
||||||
|
<DistrictMapModal
|
||||||
|
provinceName={selectedProvince}
|
||||||
|
shop={shop}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
platformName="Shopee"
|
||||||
|
apiEndpoint="/shopee/sales-map/district"
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -297,15 +297,79 @@ export default function TiktokAnalyticsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards Row 1 */}
|
{/* KPI Cards Row 1 - Main Financials */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard label="ยอดขายรวม (Order Amount)" value={fmtCurrency(summary.totalRevenue)} icon={DollarSign} color="text-emerald-400" bgColor="from-emerald-500/10" sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} />
|
<StatCard
|
||||||
<StatCard label="ยอดสุทธิก่อนหักคอมมิชชั่น" value={fmtCurrency(summary.netRevenue)} icon={TrendingUp} color="text-cyan-glow" bgColor="from-cyan-500/10" sub={`รายได้หลังหักค่าส่งจัดส่ง`} />
|
label="ยอดขายรวม (Gross Revenue)"
|
||||||
<StatCard label="ส่วนลด Platform (TikTok ออก)" value={fmtCurrency(summary.totalPlatformDisc)} icon={BadgePercent} color="text-purple-400" bgColor="from-purple-500/10" sub="TikTok อุดหนุนส่วนลดให้" />
|
value={fmtCurrency(summary.totalRevenue)}
|
||||||
<StatCard label="ส่วนลดร้านค้า (เราออก)" value={fmtCurrency(summary.totalSellerDisc)} icon={ReceiptText} color="text-rose-400" bgColor="from-rose-500/10" sub="ส่วนลดที่เราให้ลูกค้า" />
|
icon={DollarSign}
|
||||||
|
color="text-emerald-400"
|
||||||
|
bgColor="from-emerald-500/10"
|
||||||
|
sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ยอดเงินโอนสุทธิ (Settlement Payout)"
|
||||||
|
value={fmtCurrency(summary.totalSettlementAmount || summary.netRevenue)}
|
||||||
|
icon={TrendingUp}
|
||||||
|
color="text-cyan-glow"
|
||||||
|
bgColor="from-cyan-500/10"
|
||||||
|
sub="ยอดเงินเข้าบัญชีจริงจาก TikTok"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ต้นทุนสินค้า BOM (BOM Cost)"
|
||||||
|
value={fmtCurrency(summary.totalBOMCost || 0)}
|
||||||
|
icon={Package}
|
||||||
|
color="text-amber-400"
|
||||||
|
bgColor="from-amber-500/10"
|
||||||
|
sub="คำนวณจากสูตรการผลิต"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="กำไรสุทธิแท้จริง (Net Profit)"
|
||||||
|
value={fmtCurrency(summary.totalNetProfit || (summary.totalSettlementAmount - summary.totalBOMCost) || 0)}
|
||||||
|
icon={ShoppingBag}
|
||||||
|
color="text-emerald-300"
|
||||||
|
bgColor="from-emerald-500/20"
|
||||||
|
sub={`Margin ${fmt(summary.netProfitMargin || 0, 1)}% ของยอดขาย`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* KPI Cards Row 2 */}
|
{/* KPI Cards Row 2 - Fees & Commissions Breakdown */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
label="ค่าคอมมิชชั่น TikTok Shop"
|
||||||
|
value={fmtCurrency(summary.totalCommission || 0)}
|
||||||
|
icon={ReceiptText}
|
||||||
|
color="text-rose-400"
|
||||||
|
bgColor="from-rose-500/10"
|
||||||
|
sub="TikTok Commission Fee"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ค่าคอมมิชชั่น Affiliate"
|
||||||
|
value={fmtCurrency(summary.totalAffiliate || 0)}
|
||||||
|
icon={BadgePercent}
|
||||||
|
color="text-orange-400"
|
||||||
|
bgColor="from-orange-500/10"
|
||||||
|
sub="Affiliate Partner & Creator Fee"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ส่วนลด Platform (TikTok ออก)"
|
||||||
|
value={fmtCurrency(summary.totalPlatformDisc)}
|
||||||
|
icon={BadgePercent}
|
||||||
|
color="text-purple-400"
|
||||||
|
bgColor="from-purple-500/10"
|
||||||
|
sub="TikTok อุดหนุนส่วนลดให้"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="ส่วนลดร้านค้า (เราออก)"
|
||||||
|
value={fmtCurrency(summary.totalSellerDisc)}
|
||||||
|
icon={ReceiptText}
|
||||||
|
color="text-pink-400"
|
||||||
|
bgColor="from-pink-500/10"
|
||||||
|
sub="ส่วนลดที่เราให้ลูกค้า"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards Row 3 - Operations & Orders */}
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} />
|
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} />
|
||||||
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} />
|
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} />
|
||||||
@@ -313,14 +377,17 @@ export default function TiktokAnalyticsPage() {
|
|||||||
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" />
|
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Monthly Revenue Chart */}
|
{/* Monthly Revenue & Financial Trend Chart */}
|
||||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
|
||||||
<TrendingUp className={`w-5 h-5 ${shopCfg.color}`} />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน</h2>
|
<TrendingUp className={`w-5 h-5 ${shopCfg.color}`} />
|
||||||
|
<h2 className="text-lg font-bold text-white">ยอดขาย ค่าธรรมเนียม และกำไรสุทธิรายเดือน</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400">เปรียบเทียบยอดขาย ยอดโอน ค่าคอมมิชชั่น และกำไรสุทธิ</span>
|
||||||
</div>
|
</div>
|
||||||
{monthlyChart.length > 0 ? (
|
{monthlyChart.length > 0 ? (
|
||||||
<div className="h-[320px]">
|
<div className="h-[340px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={monthlyChart} margin={{ top: 5, right: 20, left: 10, bottom: 5 }} barGap={3}>
|
<BarChart data={monthlyChart} margin={{ top: 5, right: 20, left: 10, bottom: 5 }} barGap={3}>
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff08" />
|
<CartesianGrid strokeDasharray="3 3" stroke="#ffffff08" />
|
||||||
@@ -329,8 +396,10 @@ export default function TiktokAnalyticsPage() {
|
|||||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
|
||||||
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
|
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
|
||||||
<Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="platformDiscount" name="฿ ส่วนลด Platform" fill="#c084fc" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="settlement" name="฿ ยอดเงินโอนสุทธิ" fill="#22d3ee" radius={[4, 4, 0, 0]} />
|
||||||
<Bar dataKey="sellerDiscount" name="฿ ส่วนลดร้านค้า" fill="#fb7185" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="commission" name="฿ ค่าคอม TikTok" fill="#f43f5e" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="affiliate" name="฿ ค่า Affiliate" fill="#fb923c" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="netProfit" name="฿ กำไรสุทธิ" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -472,33 +541,46 @@ export default function TiktokAnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cost Waterfall Summary */}
|
{/* Complete Financial & Cost Waterfall Summary */}
|
||||||
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6">
|
||||||
<div className="flex items-center gap-2 mb-6">
|
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
|
||||||
<ReceiptText className="w-5 h-5 text-rose-400" />
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-bold text-white">สรุปค่าใช้จ่ายและส่วนลด</h2>
|
<ReceiptText className="w-5 h-5 text-rose-400" />
|
||||||
|
<h2 className="text-lg font-bold text-white">โครงสร้างต้นทุน ค่าธรรมเนียม และกำไรสุทธิ (Financial Breakdown)</h2>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400">หักลบรายได้ตามลำดับสู่กำไรสุทธิแท้จริง</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3 mb-6">
|
||||||
{[
|
{[
|
||||||
{ label: 'ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' },
|
{ label: '1. ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' },
|
||||||
{ label: 'หัก: ค่าจัดส่ง (ผู้ซื้อจ่าย)', value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' },
|
{ label: '2. หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' },
|
||||||
{ label: 'หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', bar: 'bg-rose-400' },
|
{ label: '3. หัก: ค่าคอม TikTok', value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' },
|
||||||
{ label: 'ยอดสุทธิก่อนหักคอมมิชชั่น', value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true },
|
{ label: '4. หัก: ค่า Affiliate', value: -(summary.totalAffiliate || 0), color: 'text-orange-400', sign: '-' },
|
||||||
|
{ label: '5. หัก: ต้นทุน BOM สินค้า', value: -(summary.totalBOMCost || 0), color: 'text-amber-400', sign: '-' },
|
||||||
|
{ label: '6. กำไรสุทธิแท้จริง (Net Profit)', value: summary.totalNetProfit || 0, color: 'text-emerald-300 font-bold', highlight: true, sign: '=' },
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/10 bg-white/[0.02]'}`}>
|
<div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-white/10 bg-white/[0.02]'}`}>
|
||||||
<p className="text-[10px] uppercase tracking-widest text-slate-500 mb-1">{item.label}</p>
|
<div className="flex items-center justify-between mb-1">
|
||||||
<p className={`text-xl font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p>
|
<p className="text-[10px] uppercase tracking-widest text-slate-400 truncate">{item.label}</p>
|
||||||
|
<span className="text-xs font-bold text-slate-500">{item.sign}</span>
|
||||||
|
</div>
|
||||||
|
<p className={`text-lg font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p>
|
||||||
{summary.totalRevenue > 0 && (
|
{summary.totalRevenue > 0 && (
|
||||||
<p className="text-[10px] text-slate-600 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด</p>
|
<p className="text-[10px] text-slate-500 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอดขาย</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-6 p-4 rounded-xl border border-purple-500/20 bg-purple-500/5">
|
|
||||||
<BadgePercent className="w-8 h-8 text-purple-400" />
|
<div className="flex items-center gap-4 p-4 rounded-xl border border-purple-500/20 bg-purple-500/5">
|
||||||
<div>
|
<BadgePercent className="w-7 h-7 text-purple-400 flex-shrink-0" />
|
||||||
<p className="text-xs text-slate-400">ส่วนลด Platform สนับสนุน (TikTok ออกให้)</p>
|
<div className="flex items-center justify-between w-full flex-wrap gap-2">
|
||||||
<p className="text-xl font-bold text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p>
|
<div>
|
||||||
|
<p className="text-xs text-slate-300 font-medium">ส่วนลด Platform สนับสนุน (TikTok ช่วยจ่ายให้ลูกค้า)</p>
|
||||||
|
<p className="text-[11px] text-slate-500">ยอดเงินที่ TikTok ออกคูปองสนับสนุนให้ผู้ซื้อ โดยไม่หักเงินจากร้านค้า</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-bold font-mono text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,512 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ShoppingBag,
|
||||||
|
Package,
|
||||||
|
Truck,
|
||||||
|
User,
|
||||||
|
MapPin,
|
||||||
|
Receipt,
|
||||||
|
ExternalLink,
|
||||||
|
Loader2,
|
||||||
|
Calendar,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
Calculator,
|
||||||
|
Layers,
|
||||||
|
ArrowUpRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import { TIKTOK_SHOPS } from './TiktokOrderPage';
|
||||||
|
|
||||||
|
function authHeaders() {
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(val: number | null | undefined) {
|
||||||
|
if (val == null) return '0.00';
|
||||||
|
return new Intl.NumberFormat('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value, mono }: { label: string; value?: string | null; mono?: boolean }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">{label}</p>
|
||||||
|
<p className={`text-sm text-slate-200 leading-relaxed ${mono ? 'font-mono' : ''}`}>{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusStyle(status: string) {
|
||||||
|
if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20';
|
||||||
|
if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return 'bg-blue-500/15 text-blue-400 border-blue-500/20';
|
||||||
|
if (status.includes('จะจัดส่ง') || status.includes('รอ')) return 'bg-amber-500/15 text-amber-400 border-amber-500/20';
|
||||||
|
if (status.includes('ยกเลิก') || status.includes('cancel')) return 'bg-rose-500/15 text-rose-400 border-rose-500/20';
|
||||||
|
return 'bg-white/5 text-slate-400 border-white/10';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TiktokOrderDetailPage() {
|
||||||
|
const { orderId } = useParams<{ orderId: string }>();
|
||||||
|
const [order, setOrder] = useState<any | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!orderId) return;
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`${API_BASE}/tiktok-order/orders/${encodeURIComponent(orderId)}`, { headers: authHeaders() })
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.error) throw new Error(data.error);
|
||||||
|
setOrder(data);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [orderId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 animate-pulse">
|
||||||
|
<Loader2 className="w-10 h-10 text-rose-400 animate-spin" />
|
||||||
|
<p className="text-slate-500 text-sm">กำลังโหลดรายละเอียดคำสั่งซื้อ TikTok และคำนวณต้นทุน BOM...</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !order) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[60vh] gap-4">
|
||||||
|
<ShoppingBag className="w-14 h-14 text-slate-700" />
|
||||||
|
<p className="text-slate-400 text-sm">{error || 'ไม่พบคำสั่งซื้อนี้'}</p>
|
||||||
|
<Link to="/tiktok-order" className="px-6 py-2 rounded-xl bg-rose-500/20 text-rose-400 border border-rose-500/30 font-bold text-xs flex items-center gap-2 hover:bg-rose-500/30 transition-all">
|
||||||
|
<ArrowLeft className="w-4 h-4" /> กลับหน้ารายการ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
|
||||||
|
const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in text-white pb-12">
|
||||||
|
{/* Header Bar */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
to="/tiktok-order"
|
||||||
|
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-2xl font-bold font-mono tracking-tight text-rose-400">
|
||||||
|
#{order.orderId}
|
||||||
|
</h1>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusStyle(order.status)}`}>
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
{order.subStatus && (
|
||||||
|
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded-full border border-white/10">
|
||||||
|
{order.subStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* BOM Status Pill */}
|
||||||
|
{hasIncompleteBom ? (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 flex items-center gap-1.5 shadow-sm shadow-rose-950">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" /> ขาด BOM ({order.missingBomCount} รายการ)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" /> BOM ครบถ้วน
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 flex items-center gap-2">
|
||||||
|
<span className={`font-bold ${shopCfg.color}`}>ร้าน: {shopCfg.label}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>สั่งซื้อเมื่อ: {order.createdTime || '—'}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
to="/tiktok-sales-map"
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
<MapPin className="w-3.5 h-3.5" /> ดูใน Sales Map
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`https://seller-th.tiktok.com/order`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/10 text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" /> TikTok Seller Center
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Missing BOM Alert Banner */}
|
||||||
|
{hasIncompleteBom && (
|
||||||
|
<div className="p-4 rounded-2xl bg-rose-500/10 border border-rose-500/30 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 text-rose-300 shadow-lg shadow-rose-950/20">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-rose-500/20 text-rose-400 shrink-0">
|
||||||
|
<AlertTriangle className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-bold text-rose-200">
|
||||||
|
ตรวจพบสินค้าขาดสูตร BOM หรือยังไม่ได้ใส่ราคาต้นทุน ({order.missingBomCount} รายการ)
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-rose-300/80">
|
||||||
|
พนักงานสามารถคลิกปุ่มสีแดงที่รายการสินค้าด้านล่าง เพื่อเข้าไปกรอกสูตร BOM หรือเชื่อมโยงสินค้าได้ทันที
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="/tools/marketplace-products"
|
||||||
|
className="px-3.5 py-1.5 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-bold text-xs shrink-0 flex items-center gap-1.5 shadow-md shadow-rose-900/50 transition-colors"
|
||||||
|
>
|
||||||
|
<Layers className="w-3.5 h-3.5" /> ตรวจสอบตาราง Mapping
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Layout Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Left Column (2 Cols): Products Table & Details */}
|
||||||
|
<div className="lg:col-span-2 space-y-6">
|
||||||
|
{/* Order Items Table */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Package className="w-4 h-4 text-rose-400" />
|
||||||
|
<h2 className="font-bold text-sm text-white">
|
||||||
|
รายการสินค้าในคำสั่งซื้อ ({order.items?.length || 0} SKU)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-3 font-mono">
|
||||||
|
<span>ต้นทุนรวม BOM: <strong className={order.orderTotalBomCost > 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-white/5">
|
||||||
|
{order.items?.map((item: any) => {
|
||||||
|
const isItemBomMissing = !item.hasBom;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className={`p-5 flex flex-col gap-3 transition-colors ${
|
||||||
|
isItemBomMissing
|
||||||
|
? 'bg-rose-500/[0.04] border-l-4 border-l-rose-500'
|
||||||
|
: 'hover:bg-white/[0.02]'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||||
|
{/* Product Name & SKU */}
|
||||||
|
<div className="space-y-1.5 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<p className="text-sm font-bold text-white leading-snug">{item.productName}</p>
|
||||||
|
{isItemBomMissing ? (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 animate-pulse">
|
||||||
|
🔴 ขาด BOM / รอใส่ราคา
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/20">
|
||||||
|
✓ มีสูตร BOM
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap text-xs text-slate-400">
|
||||||
|
{item.variation && (
|
||||||
|
<span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-300 font-mono text-[11px]">
|
||||||
|
ตัวเลือก: {item.variation}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.sellerSku && (
|
||||||
|
<span className="text-[11px] font-mono text-slate-500">
|
||||||
|
SKU: {item.sellerSku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{item.masterProductName && (
|
||||||
|
<span className="text-[11px] font-mono text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded border border-violet-500/20">
|
||||||
|
เชื่อมโยง: {item.masterProductName} ({item.masterSku || 'No SKU'})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing, Quantity & Subtotal */}
|
||||||
|
<div className="flex items-center justify-between md:justify-end gap-5 text-right shrink-0">
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ราคา/หน่วย</p>
|
||||||
|
<p className="text-xs font-mono text-slate-300">฿{fmt(item.unitOriginalPrice)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest">จำนวน</p>
|
||||||
|
<p className="text-sm font-bold font-mono text-white">x{item.quantity}</p>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-[80px]">
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ยอดขาย (SKU)</p>
|
||||||
|
<p className="text-sm font-bold font-mono text-white">฿{fmt(item.subtotalAfterDiscount)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM & Profit Row */}
|
||||||
|
<div className="pt-3 border-t border-white/5 flex flex-col sm:flex-row sm:items-center justify-between gap-3 text-xs">
|
||||||
|
{item.hasBom ? (
|
||||||
|
<div className="flex items-center gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-[11px]">ต้นทุน BOM/ชิ้น: </span>
|
||||||
|
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.unitBomCost)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-[11px]">ต้นทุนรวม: </span>
|
||||||
|
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.totalBomCost)}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 text-[11px]">กำไรขั้นต้น: </span>
|
||||||
|
<span className={`font-mono font-bold ${item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-rose-400 font-medium">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
<span>ไม่พบต้นทุน BOM สำหรับสินค้านี้ (ทำให้คำนวณกำไรไม่ได้)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Links */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{item.masterProductId ? (
|
||||||
|
<Link
|
||||||
|
to={`/products/${item.masterProductId}/bom`}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Calculator className="w-3 h-3 text-rose-400" />
|
||||||
|
{item.hasBom ? 'แก้ไขสูตร BOM' : '➕ ไปใส่สูตร BOM'}
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to="/tools/marketplace-products"
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<Layers className="w-3 h-3 text-amber-400" />
|
||||||
|
🔗 ไปเชื่อมโยง Master SKU
|
||||||
|
<ArrowUpRight className="w-3 h-3" />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BOM Raw Material Breakdown (if available) */}
|
||||||
|
{item.bomItemsDetail && item.bomItemsDetail.length > 0 && (
|
||||||
|
<div className="p-2.5 rounded-xl bg-black/20 border border-white/5 space-y-1 text-[11px] font-mono text-slate-400">
|
||||||
|
<p className="text-[10px] text-slate-500 uppercase font-sans font-bold tracking-wider mb-1">
|
||||||
|
สูตรวัตถุดิบ BOM ({item.bomItemsDetail.length} รายการ):
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||||
|
{item.bomItemsDetail.map((bom: any, bIdx: number) => (
|
||||||
|
<div key={bIdx} className="flex justify-between items-center text-slate-300">
|
||||||
|
<span>• {bom.name} ({bom.quantity} {bom.unit})</span>
|
||||||
|
<span className="text-amber-400/90 font-mono">฿{fmt(bom.lineCost)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shipping & Delivery Info */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
|
||||||
|
<Truck className="w-4 h-4 text-blue-400" />
|
||||||
|
<h2 className="font-bold text-sm text-white">ข้อมูลการจัดส่งและขนส่ง</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
|
||||||
|
<InfoRow label="Tracking ID" value={order.trackingId} mono />
|
||||||
|
<InfoRow label="ผู้ให้บริการขนส่ง" value={order.shippingProvider} />
|
||||||
|
<InfoRow label="ประเภทการส่ง" value={order.deliveryOption} />
|
||||||
|
<InfoRow label="Fulfillment" value={order.fulfillmentType} />
|
||||||
|
<InfoRow label="คลังสินค้า" value={order.warehouseName} />
|
||||||
|
<InfoRow label="ประเภทออเดอร์" value={order.orderType} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline Dates */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
|
||||||
|
<Calendar className="w-4 h-4 text-amber-400" />
|
||||||
|
<h2 className="font-bold text-sm text-white">ไทม์ไลน์คำสั่งซื้อ</h2>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
|
||||||
|
<InfoRow label="วันที่สร้างออเดอร์" value={order.createdTime} />
|
||||||
|
<InfoRow label="วันที่ชำระเงิน" value={order.paidTime} />
|
||||||
|
<InfoRow label="เวลา RTS" value={order.rtsTime} />
|
||||||
|
<InfoRow label="เวลาจัดส่งออก" value={order.shippedTime} />
|
||||||
|
<InfoRow label="เวลาส่งถึงลูกค้า" value={order.deliveredTime} />
|
||||||
|
<InfoRow label="เวลายกเลิก" value={order.cancelledTime} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column (1 Col): Financials & Customer */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* BOM Cost & Profit Summary Card */}
|
||||||
|
<div className={`border rounded-2xl p-6 space-y-4 backdrop-blur-sm ${
|
||||||
|
hasIncompleteBom
|
||||||
|
? 'bg-rose-950/20 border-rose-500/30 shadow-lg shadow-rose-950/30'
|
||||||
|
: 'bg-slate-900/60 border-emerald-500/30 shadow-lg shadow-emerald-950/20'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
|
||||||
|
<Calculator className={`w-4 h-4 ${hasIncompleteBom ? 'text-rose-400' : 'text-emerald-400'}`} />
|
||||||
|
<h2 className="font-bold text-sm text-white">สรุปต้นทุน & กำไร (BOM)</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ยอดขายสุทธิ (Revenue)</span>
|
||||||
|
<span className="font-mono text-slate-200 font-bold">
|
||||||
|
฿{fmt(order.orderAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-slate-400">ต้นทุนวัตถุดิบ (Total BOM Cost)</span>
|
||||||
|
<span className={`font-mono font-bold ${order.orderTotalBomCost > 0 ? 'text-amber-400' : 'text-rose-400'}`}>
|
||||||
|
฿{fmt(order.orderTotalBomCost)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-violet-300">
|
||||||
|
<span className="text-slate-400">ค่าคอมมิชชั่น (Commission)</span>
|
||||||
|
<span className="font-mono font-semibold">
|
||||||
|
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center text-cyan-300">
|
||||||
|
<span className="text-slate-400">ค่าพันธมิตร (Affiliate)</span>
|
||||||
|
<span className="font-mono font-semibold">
|
||||||
|
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-bold text-white">กำไรสุทธิ (Net Profit)</span>
|
||||||
|
<p className="text-[10px] text-slate-400">Margin: {order.orderGrossMargin?.toFixed(1)}%</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xl font-bold font-mono ${
|
||||||
|
(order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400'
|
||||||
|
}`}>
|
||||||
|
฿{fmt(order.orderNetProfit ?? order.orderEstProfit)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasIncompleteBom && (
|
||||||
|
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 space-y-1 text-rose-300">
|
||||||
|
<div className="flex items-center gap-1.5 font-bold text-rose-200 text-xs">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
<span>คำเตือน: ตัวเลขยังไม่สมบูรณ์</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] leading-relaxed">
|
||||||
|
มีสินค้า <strong>{order.missingBomCount} รายการ</strong> ยังไม่มีสูตร BOM หรือไม่มีราคาต้นทุน กรุณาใส่ราคา BOM เพื่อให้กำไรถูกต้อง
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer & Address */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
|
||||||
|
<User className="w-4 h-4 text-violet-400" />
|
||||||
|
<h2 className="font-bold text-sm text-white">ข้อมูลลูกค้าและพื้นที่</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<InfoRow label="ชื่อผู้ใช้ TikTok" value={order.buyerUsername} />
|
||||||
|
<InfoRow label="ช่องทางชำระเงิน" value={order.paymentMethod} />
|
||||||
|
<div className="pt-2 border-t border-white/5 space-y-2">
|
||||||
|
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
|
||||||
|
พิกัดปลายทาง
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-slate-200">
|
||||||
|
<MapPin className="w-4 h-4 text-emerald-400" />
|
||||||
|
<span>
|
||||||
|
{order.district ? `อ.${order.district}, ` : ''}จ.{order.province || 'ไม่ระบุ'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payment & Financial Summary */}
|
||||||
|
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
|
||||||
|
<Receipt className="w-4 h-4 text-emerald-400" />
|
||||||
|
<h2 className="font-bold text-sm text-white">สรุปยอดเงิน (Financials)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 text-xs">
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ยอดรวมสินค้า</span>
|
||||||
|
<span className="font-mono text-slate-200">
|
||||||
|
฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ส่วนลดแพลตฟอร์ม</span>
|
||||||
|
<span className="font-mono text-rose-400">
|
||||||
|
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ส่วนลดร้านค้า</span>
|
||||||
|
<span className="font-mono text-rose-400">
|
||||||
|
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ค่าจัดส่ง</span>
|
||||||
|
<span className="font-mono text-amber-400">฿{fmt(order.shippingFee)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-slate-400">
|
||||||
|
<span>ภาษี (Taxes)</span>
|
||||||
|
<span className="font-mono text-blue-400">฿{fmt(order.taxes)}</span>
|
||||||
|
</div>
|
||||||
|
{order.refundAmount > 0 && (
|
||||||
|
<div className="flex justify-between items-center text-rose-400 font-bold">
|
||||||
|
<span>ยอดคืนเงิน (Refund)</span>
|
||||||
|
<span className="font-mono">-฿{fmt(order.refundAmount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-3 border-t border-white/10 flex justify-between items-center">
|
||||||
|
<span className="text-sm font-bold text-white">ยอดสุทธิ (Order Amount)</span>
|
||||||
|
<span className="text-xl font-bold font-mono text-emerald-400">
|
||||||
|
฿{fmt(order.orderAmount)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,829 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Map, ShoppingBag, DollarSign, Award,
|
||||||
|
Calendar, Table2, Search, CheckCircle2, XCircle, Filter,
|
||||||
|
MapPin, Globe2, TrendingUp, Layers, ArrowUpRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
|
||||||
|
import OmnichannelDistrictModal from '../../components/analytics/OmnichannelDistrictModal';
|
||||||
|
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||||
|
import { TIKTOK_SHOPS } from '../TiktokOrderPage';
|
||||||
|
import { SHOPEE_SHOPS } from '../ShopeePage';
|
||||||
|
import { LAZADA_SHOPS } from '../LazadaOrderPage';
|
||||||
|
|
||||||
|
type ViewMode = 'map' | 'list';
|
||||||
|
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
|
||||||
|
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||||
|
type StatusFilter = 'completed' | 'all' | 'cancelled';
|
||||||
|
|
||||||
|
export default function OmnichannelSalesMapPage() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||||
|
const [channel, setChannel] = useState<ChannelType>('all');
|
||||||
|
const [tiktokShop, setTiktokShop] = useState('all');
|
||||||
|
const [shopeeShop, setShopeeShop] = useState('all');
|
||||||
|
const [lazadaShop, setLazadaShop] = useState('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
|
||||||
|
const [period, setPeriod] = useState<TimePeriod>('all');
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [mapData, setMapData] = useState<any>(null);
|
||||||
|
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
|
||||||
|
const [showDistrictModal, setShowDistrictModal] = useState(false);
|
||||||
|
const [searchTable, setSearchTable] = useState('');
|
||||||
|
|
||||||
|
// Handle Period Presets
|
||||||
|
const handlePeriodChange = (newPeriod: TimePeriod) => {
|
||||||
|
setPeriod(newPeriod);
|
||||||
|
const now = new Date();
|
||||||
|
if (newPeriod === 'all') {
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
} else if (newPeriod === '30days') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(now.getDate() - 30);
|
||||||
|
setDateFrom(past.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
} else if (newPeriod === 'this_year') {
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
setDateFrom(startOfYear.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch Map Data
|
||||||
|
const fetchData = () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (channel !== 'all') params.set('channel', channel);
|
||||||
|
if (tiktokShop !== 'all') params.set('tiktokShop', tiktokShop);
|
||||||
|
if (shopeeShop !== 'all') params.set('shopeeShop', shopeeShop);
|
||||||
|
if (lazadaShop !== 'all') params.set('lazadaShop', lazadaShop);
|
||||||
|
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||||
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/business-analytics/sales-map?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setMapData(data);
|
||||||
|
if (!selectedProvince && data.topProvinces?.length > 0) {
|
||||||
|
setSelectedProvince(data.topProvinces[0].nameTh);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load omnichannel map data:', err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [channel, tiktokShop, shopeeShop, lazadaShop, statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
// Provinces lookup record for SVG Map
|
||||||
|
const provincesLookup = useMemo(() => {
|
||||||
|
const map: Record<string, any> = {};
|
||||||
|
if (mapData?.provinces) {
|
||||||
|
for (const p of mapData.provinces) {
|
||||||
|
map[p.nameTh] = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [mapData]);
|
||||||
|
|
||||||
|
// Lookup details of the currently selected province
|
||||||
|
const selectedProvinceDetail = useMemo(() => {
|
||||||
|
if (!mapData?.provinces || !selectedProvince) return null;
|
||||||
|
return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null;
|
||||||
|
}, [mapData, selectedProvince]);
|
||||||
|
|
||||||
|
// Filtered provinces for table view
|
||||||
|
const filteredProvinces = useMemo(() => {
|
||||||
|
if (!mapData?.provinces) return [];
|
||||||
|
if (!searchTable.trim()) return mapData.provinces;
|
||||||
|
const q = searchTable.toLowerCase();
|
||||||
|
return mapData.provinces.filter(
|
||||||
|
(p: any) =>
|
||||||
|
p.nameTh.toLowerCase().includes(q) ||
|
||||||
|
p.nameEn.toLowerCase().includes(q) ||
|
||||||
|
p.regionTh.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [mapData, searchTable]);
|
||||||
|
|
||||||
|
const kpi = mapData?.kpi;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* ── Page Header ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-rose-500/20 via-amber-500/20 to-blue-500/20 border border-orange-500/30 flex items-center justify-center shadow-lg shadow-orange-500/10">
|
||||||
|
<Globe2 className="w-6 h-6 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-3xl font-bold text-white tracking-tight">Omnichannel Sales Map</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
|
||||||
|
TikTok + Shopee + Lazada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Switcher */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
||||||
|
viewMode === 'map'
|
||||||
|
? 'bg-gradient-to-r from-orange-500/20 to-rose-500/20 text-orange-300 border border-orange-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" /> มุมมองแผนที่
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-gradient-to-r from-orange-500/20 to-rose-500/20 text-orange-300 border border-orange-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Table2 className="w-4 h-4" /> ตาราง 77 จังหวัด
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
{/* Channel Selector */}
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'all'
|
||||||
|
? 'bg-white/15 text-white shadow'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
ทุกช่องทาง (Omnichannel)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('tiktok')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'tiktok'
|
||||||
|
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-500" />
|
||||||
|
เฉพาะ TikTok
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('shopee')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'shopee'
|
||||||
|
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
|
เฉพาะ Shopee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('lazada')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'lazada'
|
||||||
|
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
เฉพาะ Lazada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-500 mr-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'all'
|
||||||
|
? 'bg-white/15 text-white border-white/20'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทั้งหมด (All Time)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('30days')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === '30days'
|
||||||
|
? 'bg-white/15 text-white border-white/20'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
30 วันล่าสุด
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('this_year')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'this_year'
|
||||||
|
? 'bg-white/15 text-white border-white/20'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ปีนี้
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('custom')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'custom'
|
||||||
|
? 'bg-white/15 text-white border-white/20'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
กำหนดเอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Filters: Shop selections & Custom Date Pickers */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 pt-3 border-t border-white/5 text-xs">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* TikTok Shop Dropdown */}
|
||||||
|
{(channel === 'all' || channel === 'tiktok') && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">ร้าน TikTok:</span>
|
||||||
|
<select
|
||||||
|
value={tiktokShop}
|
||||||
|
onChange={(e) => setTiktokShop(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-rose-300 font-bold focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">TikTok ทุกร้าน</option>
|
||||||
|
{TIKTOK_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Shopee Shop Dropdown */}
|
||||||
|
{(channel === 'all' || channel === 'shopee') && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">ร้าน Shopee:</span>
|
||||||
|
<select
|
||||||
|
value={shopeeShop}
|
||||||
|
onChange={(e) => setShopeeShop(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-orange-300 font-bold focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">Shopee ทุกร้าน</option>
|
||||||
|
{SHOPEE_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lazada Shop Dropdown */}
|
||||||
|
{(channel === 'all' || channel === 'lazada') && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">ร้าน Lazada:</span>
|
||||||
|
<select
|
||||||
|
value={lazadaShop}
|
||||||
|
onChange={(e) => setLazadaShop(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-blue-300 font-bold focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">Lazada ทุกร้าน</option>
|
||||||
|
{LAZADA_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
|
||||||
|
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
|
||||||
|
<Filter className="w-3 h-3 text-slate-400" /> สถานะ:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('completed')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
|
||||||
|
statusFilter === 'completed'
|
||||||
|
? 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||||
|
สำเร็จ (ไม่รวมยกเลิก)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
statusFilter === 'all'
|
||||||
|
? 'bg-white/20 text-white shadow'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทุกสถานะ (รวมยกเลิก)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('cancelled')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
|
||||||
|
statusFilter === 'cancelled'
|
||||||
|
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<XCircle className="w-3 h-3 text-rose-400" />
|
||||||
|
เฉพาะที่ยกเลิก
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range */}
|
||||||
|
{period === 'custom' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">ตั้งแต่วันที่:</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">ถึง</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{/* Total Revenue & Channel Split */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวมทุกช่องทาง</p>
|
||||||
|
<DollarSign className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtBaht(kpi?.totalRevenue || 0)}
|
||||||
|
</p>
|
||||||
|
{/* Split bar: TikTok Rose / Shopee Orange / Lazada Blue */}
|
||||||
|
<div className="mt-2.5 space-y-1">
|
||||||
|
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.tiktokSharePercent || 0}%` }}
|
||||||
|
title={`TikTok: ${kpi?.tiktokSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.shopeeSharePercent || 0}%` }}
|
||||||
|
title={`Shopee: ${kpi?.shopeeSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.lazadaSharePercent || 0}%` }}
|
||||||
|
title={`Lazada: ${kpi?.lazadaSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 font-mono flex-wrap gap-1">
|
||||||
|
<span className="text-rose-400">TT {kpi?.tiktokSharePercent?.toFixed(0)}%</span>
|
||||||
|
<span className="text-orange-400">SP {kpi?.shopeeSharePercent?.toFixed(0)}%</span>
|
||||||
|
<span className="text-blue-400">LZ {kpi?.lazadaSharePercent?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Orders & Channel Count */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">คำสั่งซื้อทั้งหมด</p>
|
||||||
|
<ShoppingBag className="w-4 h-4 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono">
|
||||||
|
{fmtNum(kpi?.totalOrders || 0)} <span className="text-xs font-normal text-slate-400">ออเดอร์</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
|
||||||
|
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokOrders || 0)}</span>
|
||||||
|
<span className="text-orange-400">SP: {fmtNum(kpi?.totalShopeeOrders || 0)}</span>
|
||||||
|
<span className="text-blue-400">LZ: {fmtNum(kpi?.totalLazadaOrders || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Province Coverage */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การเข้าถึงพื้นที่</p>
|
||||||
|
<Map className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-cyan-400 font-mono">
|
||||||
|
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AOV (Average Order Value) */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดซื้อเฉลี่ย (AOV)</p>
|
||||||
|
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-amber-400 font-mono">
|
||||||
|
{fmtBaht(kpi?.aov || 0)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-[10px] text-slate-400 mt-1 font-mono flex-wrap gap-1">
|
||||||
|
<span className="text-rose-400">TT: ฿{fmtNum(kpi?.tiktokAov || 0)}</span>
|
||||||
|
<span className="text-orange-400">SP: ฿{fmtNum(kpi?.shopeeAov || 0)}</span>
|
||||||
|
<span className="text-blue-400">LZ: ฿{fmtNum(kpi?.lazadaAov || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Region */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p>
|
||||||
|
<Award className="w-4 h-4 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{kpi?.topRegion?.name || '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
{fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Section: Map View or Table View ─────────────────────────────── */}
|
||||||
|
{viewMode === 'map' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Map & Focus Card Container (8 cols) */}
|
||||||
|
<div className="lg:col-span-8 flex flex-col gap-4">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm relative min-h-[560px] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Map className="w-4 h-4 text-orange-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">แผนที่ความหนาแน่นยอดขาย (Thailand Choropleth)</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
|
||||||
|
<span>ยอดขายน้อย</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
|
||||||
|
<span>ยอดขายสูง</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thailand SVG Map */}
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-24 text-center text-slate-400 text-sm">
|
||||||
|
<span className="animate-spin inline-block mr-2">⏳</span> กำลังประมวลผลข้อมูลแผนที่...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ThailandMapSvg
|
||||||
|
provincesData={provincesLookup}
|
||||||
|
selectedProvince={selectedProvince}
|
||||||
|
onSelectProvince={(provName) => setSelectedProvince(provName)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Province Details Card */}
|
||||||
|
{selectedProvinceDetail && (
|
||||||
|
<div className="bg-gradient-to-r from-orange-500/10 via-rose-500/10 to-blue-500/10 border border-orange-500/30 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-orange-400" />
|
||||||
|
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4>
|
||||||
|
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
|
||||||
|
{selectedProvinceDetail.regionTh}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
|
||||||
|
<span>ยอดรวม: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
|
||||||
|
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.orders}</strong> รายการ</span>
|
||||||
|
<span>AOV: <strong className="text-amber-400">{fmtBaht(selectedProvinceDetail.aov)}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Breakdown Mini Bar */}
|
||||||
|
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
|
||||||
|
<span className="text-[11px] text-slate-400">สัดส่วนยอดขาย:</span>
|
||||||
|
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.tiktokShare}%` }}
|
||||||
|
title={`TikTok: ${selectedProvinceDetail.tiktokShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.shopeeShare}%` }}
|
||||||
|
title={`Shopee: ${selectedProvinceDetail.shopeeShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.lazadaShare}%` }}
|
||||||
|
title={`Lazada: ${selectedProvinceDetail.lazadaShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-rose-400 font-mono text-[11px]">TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)}</span>
|
||||||
|
<span className="text-orange-400 font-mono text-[11px]">SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)}</span>
|
||||||
|
<span className="text-blue-400 font-mono text-[11px]">LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDistrictModal(true)}
|
||||||
|
className="px-6 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-rose-600 hover:from-orange-600 hover:to-rose-700 text-white font-bold text-sm shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
|
||||||
|
>
|
||||||
|
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span>
|
||||||
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */}
|
||||||
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
{/* Top 20 Provinces Card */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="w-4 h-4 text-orange-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดยอดขายสูงสุด</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">Omnichannel</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2 max-h-[460px] overflow-y-auto pr-1">
|
||||||
|
{mapData?.topProvinces?.map((item: any) => {
|
||||||
|
const isSelected = selectedProvince === item.nameTh;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code}
|
||||||
|
onClick={() => setSelectedProvince(item.nameTh)}
|
||||||
|
className={`p-3 rounded-xl border transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-orange-500/20 border-orange-500/40 shadow-sm'
|
||||||
|
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className={`w-6 h-6 rounded-lg flex items-center justify-center font-mono font-bold text-xs ${
|
||||||
|
item.rank <= 3
|
||||||
|
? 'bg-gradient-to-br from-amber-400 to-orange-500 text-slate-950 font-black'
|
||||||
|
: 'bg-white/10 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.rank}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-white">{item.nameTh}</p>
|
||||||
|
<p className="text-[10px] text-slate-400 font-mono">{item.orders} ออเดอร์</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-bold text-emerald-400 font-mono">{fmtBaht(item.revenue)}</p>
|
||||||
|
<div className="flex items-center gap-1 text-[9px] font-mono mt-0.5">
|
||||||
|
<span className="text-rose-400">TT {item.tiktokShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className="text-orange-400">SP {item.shopeeShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className="text-blue-400">LZ {item.lazadaShare?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini platform share bar */}
|
||||||
|
<div className="mt-2 h-1 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-rose-500" style={{ width: `${item.tiktokShare}%` }} />
|
||||||
|
<div className="h-full bg-orange-500" style={{ width: `${item.shopeeShare}%` }} />
|
||||||
|
<div className="h-full bg-blue-500" style={{ width: `${item.lazadaShare}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region Breakdown Card */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2 pb-3 border-b border-white/10">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">สัดส่วนยอดขายตามภูมิภาค</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{mapData?.regionBreakdown?.map((reg: any) => (
|
||||||
|
<div key={reg.region} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-white font-medium">{reg.regionTh}</span>
|
||||||
|
<span className="font-mono text-emerald-400 font-bold">
|
||||||
|
{fmtBaht(reg.revenue)}{' '}
|
||||||
|
<span className="text-slate-400 font-normal">({reg.percentage?.toFixed(1)}%)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500"
|
||||||
|
style={{ width: `${(reg.tiktokRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500"
|
||||||
|
style={{ width: `${(reg.shopeeRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500"
|
||||||
|
style={{ width: `${(reg.lazadaRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>{reg.orders} ออเดอร์</span>
|
||||||
|
<span className="text-slate-400">
|
||||||
|
TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Table View: 77 Provinces ────────────────────────────────────────── */
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
|
||||||
|
<div className="p-4 border-b border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.02]">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาชื่อจังหวัด หรือภาค..."
|
||||||
|
value={searchTable}
|
||||||
|
onChange={(e) => setSearchTable(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">รหัส</th>
|
||||||
|
<th className="px-4 py-3">จังหวัด</th>
|
||||||
|
<th className="px-4 py-3">ภูมิภาค</th>
|
||||||
|
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">ออเดอร์รวม</th>
|
||||||
|
<th className="px-4 py-3 text-right">TikTok (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">Shopee (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">Lazada (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">AOV (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredProvinces.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={11} className="px-4 py-12 text-center text-slate-500">
|
||||||
|
ไม่พบข้อมูลจังหวัดตามคำค้นหา
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredProvinces.map((p: any) => (
|
||||||
|
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-white">
|
||||||
|
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
|
||||||
|
{p.regionTh}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-white">
|
||||||
|
{p.orders > 0 ? `${p.orders} ออเดอร์` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-rose-400">
|
||||||
|
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-orange-400">
|
||||||
|
{p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-blue-400">
|
||||||
|
{p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-amber-400">
|
||||||
|
{p.aov > 0 ? fmtBaht(p.aov) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{p.revenue > 0 ? (
|
||||||
|
<div className="w-28 mx-auto space-y-1">
|
||||||
|
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-rose-500" style={{ width: `${p.tiktokShare}%` }} />
|
||||||
|
<div className="h-full bg-orange-500" style={{ width: `${p.shopeeShare}%` }} />
|
||||||
|
<div className="h-full bg-blue-500" style={{ width: `${p.lazadaShare}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[9px] font-mono text-slate-400">
|
||||||
|
<span className="text-rose-400">{p.tiktokShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-orange-400">{p.shopeeShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-blue-400">{p.lazadaShare?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600 block text-center">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvince(p.nameTh);
|
||||||
|
setShowDistrictModal(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-orange-500/20 hover:text-orange-300 text-slate-400 border border-white/10 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
ดูรายอำเภอ
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── District Level Modal ─────────────────────────────────────────────── */}
|
||||||
|
{showDistrictModal && selectedProvince && (
|
||||||
|
<OmnichannelDistrictModal
|
||||||
|
provinceName={selectedProvince}
|
||||||
|
channel={channel}
|
||||||
|
tiktokShop={tiktokShop}
|
||||||
|
shopeeShop={shopeeShop}
|
||||||
|
lazadaShop={lazadaShop}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onClose={() => setShowDistrictModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,970 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Map, ShoppingBag, DollarSign, Award,
|
||||||
|
Calendar, Table2, Search, CheckCircle2, XCircle, Filter,
|
||||||
|
MapPin, Globe2, Layers, ArrowUpRight,
|
||||||
|
Package, ChevronDown, Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
|
||||||
|
import ProductDistrictModal from '../../components/analytics/ProductDistrictModal';
|
||||||
|
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||||
|
|
||||||
|
type ViewMode = 'map' | 'list';
|
||||||
|
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
|
||||||
|
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||||
|
type MetricType = 'revenue' | 'quantity';
|
||||||
|
type StatusFilter = 'completed' | 'all' | 'cancelled';
|
||||||
|
|
||||||
|
interface ProductItem {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
brand: string;
|
||||||
|
totalMappedMarketplaceCount: number;
|
||||||
|
totalSoldUnits: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
variants: Array<{
|
||||||
|
id: number;
|
||||||
|
sku: string;
|
||||||
|
barcode: string;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
optionsStr: string;
|
||||||
|
displayName: string;
|
||||||
|
mappedMarketplaceCount: number;
|
||||||
|
soldUnits: number;
|
||||||
|
revenue: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductSalesMapPage() {
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
||||||
|
const [metric, setMetric] = useState<MetricType>('revenue');
|
||||||
|
const [channel, setChannel] = useState<ChannelType>('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
|
||||||
|
const [period, setPeriod] = useState<TimePeriod>('all');
|
||||||
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
|
const [dateTo, setDateTo] = useState('');
|
||||||
|
|
||||||
|
// Products list
|
||||||
|
const [productsList, setProductsList] = useState<ProductItem[]>([]);
|
||||||
|
const [loadingProducts, setLoadingProducts] = useState(true);
|
||||||
|
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||||
|
const [selectedVariantId, setSelectedVariantId] = useState<number | null>(null);
|
||||||
|
const [showProductDropdown, setShowProductDropdown] = useState(false);
|
||||||
|
const [productSearch, setProductSearch] = useState('');
|
||||||
|
|
||||||
|
// Map Data
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [mapData, setMapData] = useState<any>(null);
|
||||||
|
const [selectedProvince, setSelectedProvince] = useState<string | null>(null);
|
||||||
|
const [showDistrictModal, setShowDistrictModal] = useState(false);
|
||||||
|
const [searchTable, setSearchTable] = useState('');
|
||||||
|
|
||||||
|
// 1. Fetch Products List
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingProducts(true);
|
||||||
|
fetch(`${API_BASE}/business-analytics/products-list`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res.products && res.products.length > 0) {
|
||||||
|
setProductsList(res.products);
|
||||||
|
// Default to first product
|
||||||
|
setSelectedProductId(res.products[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load products list:', err))
|
||||||
|
.finally(() => setLoadingProducts(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle Period Presets
|
||||||
|
const handlePeriodChange = (newPeriod: TimePeriod) => {
|
||||||
|
setPeriod(newPeriod);
|
||||||
|
const now = new Date();
|
||||||
|
if (newPeriod === 'all') {
|
||||||
|
setDateFrom('');
|
||||||
|
setDateTo('');
|
||||||
|
} else if (newPeriod === '30days') {
|
||||||
|
const past = new Date();
|
||||||
|
past.setDate(now.getDate() - 30);
|
||||||
|
setDateFrom(past.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
} else if (newPeriod === 'this_year') {
|
||||||
|
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||||
|
setDateFrom(startOfYear.toISOString().slice(0, 10));
|
||||||
|
setDateTo(now.toISOString().slice(0, 10));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Fetch Map Data for Selected Product / Variant
|
||||||
|
const fetchMapData = () => {
|
||||||
|
if (!selectedProductId && !selectedVariantId) return;
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (selectedVariantId) params.set('variantId', selectedVariantId.toString());
|
||||||
|
else if (selectedProductId) params.set('productId', selectedProductId.toString());
|
||||||
|
|
||||||
|
if (channel !== 'all') params.set('channel', channel);
|
||||||
|
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||||
|
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||||
|
if (dateTo) params.set('dateTo', dateTo);
|
||||||
|
params.set('metric', metric);
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/business-analytics/sales-map-products?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
setMapData(data);
|
||||||
|
if (!selectedProvince && data.topProvinces?.length > 0) {
|
||||||
|
setSelectedProvince(data.topProvinces[0].nameTh);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load product map data:', err))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMapData();
|
||||||
|
}, [selectedProductId, selectedVariantId, channel, metric, statusFilter, dateFrom, dateTo]);
|
||||||
|
|
||||||
|
// Selected Product & Variant Details
|
||||||
|
const currentProduct = useMemo(() => {
|
||||||
|
return productsList.find((p) => p.id === selectedProductId) || null;
|
||||||
|
}, [productsList, selectedProductId]);
|
||||||
|
|
||||||
|
const currentVariant = useMemo(() => {
|
||||||
|
if (!currentProduct || !selectedVariantId) return null;
|
||||||
|
return currentProduct.variants.find((v) => v.id === selectedVariantId) || null;
|
||||||
|
}, [currentProduct, selectedVariantId]);
|
||||||
|
|
||||||
|
// Filter products in dropdown search
|
||||||
|
const filteredDropdownProducts = useMemo(() => {
|
||||||
|
if (!productSearch.trim()) return productsList;
|
||||||
|
const q = productSearch.toLowerCase();
|
||||||
|
return productsList.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.category.toLowerCase().includes(q) ||
|
||||||
|
p.variants.some((v) => v.sku.toLowerCase().includes(q) || v.optionsStr.toLowerCase().includes(q))
|
||||||
|
);
|
||||||
|
}, [productsList, productSearch]);
|
||||||
|
|
||||||
|
// Provinces lookup record for SVG Map
|
||||||
|
const provincesLookup = useMemo(() => {
|
||||||
|
const map: Record<string, any> = {};
|
||||||
|
if (mapData?.provinces) {
|
||||||
|
for (const p of mapData.provinces) {
|
||||||
|
map[p.nameTh] = {
|
||||||
|
...p,
|
||||||
|
// When metric is quantity, set revenue to unitsSold for SVG heat scale
|
||||||
|
revenue: metric === 'quantity' ? p.unitsSold : p.revenue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [mapData, metric]);
|
||||||
|
|
||||||
|
// Lookup details of the currently selected province
|
||||||
|
const selectedProvinceDetail = useMemo(() => {
|
||||||
|
if (!mapData?.provinces || !selectedProvince) return null;
|
||||||
|
return mapData.provinces.find((p: any) => p.nameTh === selectedProvince) || null;
|
||||||
|
}, [mapData, selectedProvince]);
|
||||||
|
|
||||||
|
// Filtered provinces for table view
|
||||||
|
const filteredProvinces = useMemo(() => {
|
||||||
|
if (!mapData?.provinces) return [];
|
||||||
|
if (!searchTable.trim()) return mapData.provinces;
|
||||||
|
const q = searchTable.toLowerCase();
|
||||||
|
return mapData.provinces.filter(
|
||||||
|
(p: any) =>
|
||||||
|
p.nameTh.toLowerCase().includes(q) ||
|
||||||
|
p.nameEn.toLowerCase().includes(q) ||
|
||||||
|
p.regionTh.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [mapData, searchTable]);
|
||||||
|
|
||||||
|
const kpi = mapData?.kpi;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in pb-12">
|
||||||
|
{/* ── Page Header ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-500/20 via-purple-500/20 to-pink-500/20 border border-indigo-500/30 flex items-center justify-center shadow-lg shadow-indigo-500/10">
|
||||||
|
<Package className="w-6 h-6 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h1 className="text-3xl font-bold text-white tracking-tight">Product Sales Map</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||||
|
Geo-Intelligence by Product
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Switcher & Metric Switcher */}
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* Metric Switcher */}
|
||||||
|
<div className="flex items-center p-1 bg-black/40 rounded-xl border border-white/10 text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setMetric('revenue')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
|
||||||
|
metric === 'revenue'
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
฿ ดูตามยอดขาย
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMetric('quantity')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg font-bold transition-all ${
|
||||||
|
metric === 'quantity'
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
📦 ดูตามจำนวนชิ้น
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* View Switcher */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('map')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'map'
|
||||||
|
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Map className="w-4 h-4" /> แผนที่
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Table2 className="w-4 h-4" /> ตาราง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Product & Variant Selector Bar ────────────────────────────────────── */}
|
||||||
|
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-4 backdrop-blur-md relative z-30">
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1 relative">
|
||||||
|
<span className="text-xs font-bold text-slate-300 shrink-0">เลือกสินค้า Master:</span>
|
||||||
|
|
||||||
|
{/* Product Selector Dropdown Button */}
|
||||||
|
<div className="relative flex-1 max-w-xl">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowProductDropdown(!showProductDropdown)}
|
||||||
|
className="w-full flex items-center justify-between px-4 py-2.5 rounded-xl bg-slate-900/90 hover:bg-slate-900 border border-indigo-500/40 text-left text-xs font-bold text-white transition-all shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
<Package className="w-4 h-4 text-indigo-400 shrink-0" />
|
||||||
|
<span className="truncate">
|
||||||
|
{loadingProducts ? 'กำลังโหลดสินค้า...' : currentProduct ? currentProduct.name : 'เลือกสินค้า'}
|
||||||
|
</span>
|
||||||
|
{currentProduct?.category && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-normal">
|
||||||
|
{currentProduct.category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-4 h-4 text-slate-400 shrink-0 ml-2" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{showProductDropdown && (
|
||||||
|
<div className="absolute left-0 top-full mt-2 w-full max-h-[380px] bg-slate-900 border border-white/20 rounded-2xl shadow-2xl p-3 z-50 flex flex-col space-y-2 backdrop-blur-xl animate-scale-in">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาชื่อสินค้าหลัก, SKU, หมวดหมู่..."
|
||||||
|
value={productSearch}
|
||||||
|
onChange={(e) => setProductSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-black/50 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-1 pr-1 max-h-[280px]">
|
||||||
|
{filteredDropdownProducts.map((prod) => {
|
||||||
|
const isSelected = selectedProductId === prod.id && selectedVariantId === null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={prod.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProductId(prod.id);
|
||||||
|
setSelectedVariantId(null);
|
||||||
|
setShowProductDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`p-2.5 rounded-xl border transition-all cursor-pointer flex items-center justify-between text-xs ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-500/20 border-indigo-500/40 text-white font-bold'
|
||||||
|
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5 truncate mr-2">
|
||||||
|
<p className="truncate font-semibold">{prod.name}</p>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-slate-400">
|
||||||
|
<span>{prod.category}</span>
|
||||||
|
<span>• {prod.variants.length} ตัวเลือก</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right shrink-0 font-mono text-[11px]">
|
||||||
|
<p className="text-emerald-400 font-bold">{fmtBaht(prod.totalRevenue)}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">{fmtNum(prod.totalSoldUnits)} ชิ้น</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Variant Selector Tabs */}
|
||||||
|
{currentProduct && currentProduct.variants.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap text-xs">
|
||||||
|
<span className="text-slate-400 mr-1">ขนาด/ตัวเลือก:</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVariantId(null)}
|
||||||
|
className={`px-3 py-1 rounded-lg font-bold border transition-all ${
|
||||||
|
selectedVariantId === null
|
||||||
|
? 'bg-indigo-500/25 text-indigo-300 border-indigo-500/40 shadow'
|
||||||
|
: 'bg-black/30 text-slate-400 border-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทุกตัวเลือกรวม ({currentProduct.variants.length})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentProduct.variants.map((v) => {
|
||||||
|
const isSelected = selectedVariantId === v.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
onClick={() => setSelectedVariantId(v.id)}
|
||||||
|
className={`px-3 py-1 rounded-lg font-mono text-xs font-bold border transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-500/25 text-indigo-300 border-indigo-500/40 shadow'
|
||||||
|
: 'bg-black/30 text-slate-400 border-white/10 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{v.optionsStr || v.sku || `Variant #${v.id}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
{/* Channel Selector */}
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe2 className="w-3.5 h-3.5 text-cyan-400" />
|
||||||
|
ทุกช่องทาง (Omnichannel)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('tiktok')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'tiktok'
|
||||||
|
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-rose-500" />
|
||||||
|
เฉพาะ TikTok
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('shopee')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'shopee'
|
||||||
|
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||||
|
เฉพาะ Shopee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setChannel('lazada')}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||||
|
channel === 'lazada'
|
||||||
|
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
||||||
|
เฉพาะ Lazada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Period Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-500 mr-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('all')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'all' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทั้งหมด (All Time)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('30days')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === '30days' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
30 วันล่าสุด
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('this_year')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'this_year' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ปีนี้
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handlePeriodChange('custom')}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
period === 'custom' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
กำหนดเอง
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Row: Status Filter & Custom Date Range */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 pt-3 border-t border-white/5 text-xs">
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
|
||||||
|
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
|
||||||
|
<Filter className="w-3 h-3 text-slate-400" /> สถานะ:
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('completed')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
|
||||||
|
statusFilter === 'completed'
|
||||||
|
? 'bg-emerald-500/25 text-emerald-300 border border-emerald-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||||
|
สำเร็จ (ไม่รวมยกเลิก)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('all')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
statusFilter === 'all'
|
||||||
|
? 'bg-white/20 text-white shadow'
|
||||||
|
: 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทุกสถานะ (รวมยกเลิก)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setStatusFilter('cancelled')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1 ${
|
||||||
|
statusFilter === 'cancelled'
|
||||||
|
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<XCircle className="w-3 h-3 text-rose-400" />
|
||||||
|
เฉพาะที่ยกเลิก
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Date Range */}
|
||||||
|
{period === 'custom' && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">ตั้งแต่วันที่:</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateFrom}
|
||||||
|
onChange={(e) => setDateFrom(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400">ถึง</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={dateTo}
|
||||||
|
onChange={(e) => setDateTo(e.target.value)}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{/* Product Total Revenue */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายสินค้านี้รวม</p>
|
||||||
|
<DollarSign className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtBaht(kpi?.totalRevenue || 0)}
|
||||||
|
</p>
|
||||||
|
{/* Split bar */}
|
||||||
|
<div className="mt-2.5 space-y-1">
|
||||||
|
<div className="h-2 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.tiktokSharePercent || 0}%` }}
|
||||||
|
title={`TikTok: ${kpi?.tiktokSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.shopeeSharePercent || 0}%` }}
|
||||||
|
title={`Shopee: ${kpi?.shopeeSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500 transition-all"
|
||||||
|
style={{ width: `${kpi?.lazadaSharePercent || 0}%` }}
|
||||||
|
title={`Lazada: ${kpi?.lazadaSharePercent?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-slate-400 font-mono flex-wrap gap-1">
|
||||||
|
<span className="text-rose-400">TT {kpi?.tiktokSharePercent?.toFixed(0)}%</span>
|
||||||
|
<span className="text-orange-400">SP {kpi?.shopeeSharePercent?.toFixed(0)}%</span>
|
||||||
|
<span className="text-blue-400">LZ {kpi?.lazadaSharePercent?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Units Sold */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้</p>
|
||||||
|
<ShoppingBag className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-amber-400 font-mono">
|
||||||
|
{fmtNum(kpi?.totalUnitsSold || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span>
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
|
||||||
|
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokUnits || 0)}</span>
|
||||||
|
<span className="text-orange-400">SP: {fmtNum(kpi?.totalShopeeUnits || 0)}</span>
|
||||||
|
<span className="text-blue-400">LZ: {fmtNum(kpi?.totalLazadaUnits || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Province Coverage */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การกระจายตัวในจังหวัด</p>
|
||||||
|
<Map className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-cyan-400 font-mono">
|
||||||
|
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Province */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จังหวัดที่ขายดีที่สุด</p>
|
||||||
|
<Award className="w-4 h-4 text-rose-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{kpi?.topProvince?.name ? `จ.${kpi.topProvince.name}` : '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 font-mono">
|
||||||
|
{fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} ชิ้น
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Region */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p>
|
||||||
|
<Layers className="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xl font-bold text-white">
|
||||||
|
{kpi?.topRegion?.name || '-'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 font-mono">
|
||||||
|
{fmtBaht(kpi?.topRegion?.revenue || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Main Section: Map View or Table View ─────────────────────────────── */}
|
||||||
|
{viewMode === 'map' ? (
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
{/* Map & Focus Card Container (8 cols) */}
|
||||||
|
<div className="lg:col-span-8 flex flex-col gap-4">
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-6 backdrop-blur-sm relative min-h-[560px] flex flex-col">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Map className="w-4 h-4 text-indigo-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">
|
||||||
|
แผนที่ความหนาแน่น {metric === 'quantity' ? 'จำนวนชิ้นที่ขาย (Units)' : 'ยอดขาย (Revenue)'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
|
||||||
|
<span>น้อย</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="w-2.5 h-2.5 rounded bg-emerald-500" />
|
||||||
|
<span>มาก</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thailand SVG Map */}
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
{loading ? (
|
||||||
|
<div className="py-24 text-center text-slate-400 text-sm">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-indigo-400" />
|
||||||
|
กำลังประมวลผลข้อมูลแผนที่สินค้า...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ThailandMapSvg
|
||||||
|
provincesData={provincesLookup}
|
||||||
|
selectedProvince={selectedProvince}
|
||||||
|
onSelectProvince={(provName) => setSelectedProvince(provName)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Province Details Card */}
|
||||||
|
{selectedProvinceDetail && (
|
||||||
|
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="w-5 h-5 text-indigo-400" />
|
||||||
|
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4>
|
||||||
|
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span>
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
|
||||||
|
{selectedProvinceDetail.regionTh}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
|
||||||
|
<span>ยอดขาย: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
|
||||||
|
<span>จำนวนที่ขาย: <strong className="text-amber-400 text-sm">{selectedProvinceDetail.unitsSold}</strong> ชิ้น</span>
|
||||||
|
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.ordersCount}</strong> ออเดอร์</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform Breakdown Mini Bar */}
|
||||||
|
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
|
||||||
|
<span className="text-[11px] text-slate-400">สัดส่วนช่องทาง:</span>
|
||||||
|
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.tiktokShare}%` }}
|
||||||
|
title={`TikTok: ${selectedProvinceDetail.tiktokShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.shopeeShare}%` }}
|
||||||
|
title={`Shopee: ${selectedProvinceDetail.shopeeShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500"
|
||||||
|
style={{ width: `${selectedProvinceDetail.lazadaShare}%` }}
|
||||||
|
title={`Lazada: ${selectedProvinceDetail.lazadaShare?.toFixed(1)}%`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-rose-400 font-mono text-[11px]">TT: {fmtBaht(selectedProvinceDetail.tiktokRevenue)}</span>
|
||||||
|
<span className="text-orange-400 font-mono text-[11px]">SP: {fmtBaht(selectedProvinceDetail.shopeeRevenue)}</span>
|
||||||
|
<span className="text-blue-400 font-mono text-[11px]">LZ: {fmtBaht(selectedProvinceDetail.lazadaRevenue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDistrictModal(true)}
|
||||||
|
className="px-6 py-3 rounded-xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:to-pink-600 text-white font-bold text-sm shadow-lg shadow-indigo-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
|
||||||
|
>
|
||||||
|
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span>
|
||||||
|
<ArrowUpRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 20 Provinces Ranking & Region Breakdown (4 cols) */}
|
||||||
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
{/* Top 20 Provinces Card */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center justify-between pb-3 border-b border-white/10">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Award className="w-4 h-4 text-indigo-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด</h3>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">By {metric}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2 max-h-[460px] overflow-y-auto pr-1">
|
||||||
|
{mapData?.topProvinces?.map((item: any) => {
|
||||||
|
const isSelected = selectedProvince === item.nameTh;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.code}
|
||||||
|
onClick={() => setSelectedProvince(item.nameTh)}
|
||||||
|
className={`p-3 rounded-xl border transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-indigo-500/20 border-indigo-500/40 shadow-sm'
|
||||||
|
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className={`w-6 h-6 rounded-lg flex items-center justify-center font-mono font-bold text-xs ${
|
||||||
|
item.rank <= 3
|
||||||
|
? 'bg-gradient-to-br from-indigo-400 to-purple-500 text-white font-black'
|
||||||
|
: 'bg-white/10 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.rank}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-bold text-white">{item.nameTh}</p>
|
||||||
|
<p className="text-[10px] text-amber-400 font-mono font-bold">{item.unitsSold} ชิ้น</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-xs font-bold text-emerald-400 font-mono">{fmtBaht(item.revenue)}</p>
|
||||||
|
<div className="flex items-center gap-1 text-[9px] font-mono mt-0.5">
|
||||||
|
<span className="text-rose-400">TT {item.tiktokShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className="text-orange-400">SP {item.shopeeShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-slate-500">•</span>
|
||||||
|
<span className="text-blue-400">LZ {item.lazadaShare?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mini platform share bar */}
|
||||||
|
<div className="mt-2 h-1 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-rose-500" style={{ width: `${item.tiktokShare}%` }} />
|
||||||
|
<div className="h-full bg-orange-500" style={{ width: `${item.shopeeShare}%` }} />
|
||||||
|
<div className="h-full bg-blue-500" style={{ width: `${item.lazadaShare}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region Breakdown Card */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
|
||||||
|
<div className="flex items-center gap-2 pb-3 border-b border-white/10">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
<h3 className="font-bold text-white text-sm">สัดส่วนตามภูมิภาค</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{mapData?.regionBreakdown?.map((reg: any) => (
|
||||||
|
<div key={reg.region} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-white font-medium">{reg.regionTh}</span>
|
||||||
|
<span className="font-mono text-emerald-400 font-bold">
|
||||||
|
{fmtBaht(reg.revenue)}{' '}
|
||||||
|
<span className="text-amber-400 font-normal">({reg.unitsSold} ชิ้น)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div
|
||||||
|
className="h-full bg-rose-500"
|
||||||
|
style={{ width: `${(reg.tiktokRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-orange-500"
|
||||||
|
style={{ width: `${(reg.shopeeRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full bg-blue-500"
|
||||||
|
style={{ width: `${(reg.lazadaRevenue / (reg.revenue || 1)) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* ── Table View: 77 Provinces ────────────────────────────────────────── */
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
|
||||||
|
<div className="p-4 border-b border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.02]">
|
||||||
|
<div className="relative flex-1 max-w-sm">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาชื่อจังหวัด หรือภาค..."
|
||||||
|
value={searchTable}
|
||||||
|
onChange={(e) => setSearchTable(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">รหัส</th>
|
||||||
|
<th className="px-4 py-3">จังหวัด</th>
|
||||||
|
<th className="px-4 py-3">ภูมิภาค</th>
|
||||||
|
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">จำนวนที่ขาย (ชิ้น)</th>
|
||||||
|
<th className="px-4 py-3 text-right">TikTok (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">Shopee (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-right">Lazada (฿)</th>
|
||||||
|
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th>
|
||||||
|
<th className="px-4 py-3" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredProvinces.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="px-4 py-12 text-center text-slate-500">
|
||||||
|
ไม่พบข้อมูลจังหวัดตามคำค้นหา
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
filteredProvinces.map((p: any) => (
|
||||||
|
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
|
||||||
|
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
|
||||||
|
<td className="px-4 py-3 font-bold text-white">
|
||||||
|
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
|
||||||
|
{p.regionTh}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-amber-400 font-bold">
|
||||||
|
{p.unitsSold > 0 ? `${p.unitsSold} ชิ้น` : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-rose-400">
|
||||||
|
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-orange-400">
|
||||||
|
{p.shopeeRevenue > 0 ? fmtBaht(p.shopeeRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right font-mono text-blue-400">
|
||||||
|
{p.lazadaRevenue > 0 ? fmtBaht(p.lazadaRevenue) : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{p.revenue > 0 ? (
|
||||||
|
<div className="w-28 mx-auto space-y-1">
|
||||||
|
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
|
||||||
|
<div className="h-full bg-rose-500" style={{ width: `${p.tiktokShare}%` }} />
|
||||||
|
<div className="h-full bg-orange-500" style={{ width: `${p.shopeeShare}%` }} />
|
||||||
|
<div className="h-full bg-blue-500" style={{ width: `${p.lazadaShare}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[9px] font-mono text-slate-400">
|
||||||
|
<span className="text-rose-400">{p.tiktokShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-orange-400">{p.shopeeShare?.toFixed(0)}%</span>
|
||||||
|
<span className="text-blue-400">{p.lazadaShare?.toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600 block text-center">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProvince(p.nameTh);
|
||||||
|
setShowDistrictModal(true);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-indigo-500/20 hover:text-indigo-300 text-slate-400 border border-white/10 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
ดูรายอำเภอ
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Product District Level Modal ─────────────────────────────────────── */}
|
||||||
|
{showDistrictModal && selectedProvince && (
|
||||||
|
<ProductDistrictModal
|
||||||
|
provinceName={selectedProvince}
|
||||||
|
productId={selectedProductId}
|
||||||
|
variantId={selectedVariantId}
|
||||||
|
productName={currentProduct?.name}
|
||||||
|
variantName={currentVariant?.optionsStr}
|
||||||
|
channel={channel}
|
||||||
|
statusFilter={statusFilter}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onClose={() => setShowDistrictModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,908 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
Wrench,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
CheckCircle2,
|
||||||
|
AlertCircle,
|
||||||
|
Link2,
|
||||||
|
Unlink,
|
||||||
|
DollarSign,
|
||||||
|
ShoppingBag,
|
||||||
|
TrendingUp,
|
||||||
|
Package,
|
||||||
|
Sparkles,
|
||||||
|
X,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { API_BASE } from '@homaroma/types';
|
||||||
|
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||||
|
import { TIKTOK_SHOPS } from '../TiktokOrderPage';
|
||||||
|
import { SHOPEE_SHOPS } from '../ShopeePage';
|
||||||
|
import { LAZADA_SHOPS } from '../LazadaOrderPage';
|
||||||
|
|
||||||
|
interface MasterVariant {
|
||||||
|
id: number;
|
||||||
|
sku: string;
|
||||||
|
barcode: string;
|
||||||
|
price: number;
|
||||||
|
stock: number;
|
||||||
|
optionsStr: string;
|
||||||
|
productName: string;
|
||||||
|
categoryName: string;
|
||||||
|
brandName: string;
|
||||||
|
bomCost: number;
|
||||||
|
displayName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarketplaceProductsPage() {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [autoMatching, setAutoMatching] = useState(false);
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [channel, setChannel] = useState<'all' | 'tiktok' | 'shopee' | 'lazada'>('all');
|
||||||
|
const [shop, setShop] = useState('all');
|
||||||
|
const [status, setStatus] = useState<'all' | 'mapped' | 'unmapped'>('all');
|
||||||
|
const [sortBy, setSortBy] = useState('revenue');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const limit = 30;
|
||||||
|
|
||||||
|
// Master variants for mapping picker
|
||||||
|
const [masterVariants, setMasterVariants] = useState<MasterVariant[]>([]);
|
||||||
|
const [mappingModalItem, setMappingModalItem] = useState<any>(null);
|
||||||
|
const [variantSearch, setVariantSearch] = useState('');
|
||||||
|
const [selectedVariantId, setSelectedVariantId] = useState<number | null>(null);
|
||||||
|
const [savingMapping, setSavingMapping] = useState(false);
|
||||||
|
|
||||||
|
// Toast / notification message
|
||||||
|
const [toast, setToast] = useState<{ type: 'success' | 'info' | 'error'; message: string } | null>(null);
|
||||||
|
|
||||||
|
const showToast = (message: string, type: 'success' | 'info' | 'error' = 'success') => {
|
||||||
|
setToast({ type, message });
|
||||||
|
setTimeout(() => setToast(null), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Fetch Marketplace Products
|
||||||
|
const fetchData = () => {
|
||||||
|
setLoading(true);
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (channel !== 'all') params.set('channel', channel);
|
||||||
|
if (shop !== 'all') params.set('shop', shop);
|
||||||
|
if (status !== 'all') params.set('status', status);
|
||||||
|
if (sortBy) params.set('sortBy', sortBy);
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
params.set('page', page.toString());
|
||||||
|
params.set('limit', limit.toString());
|
||||||
|
|
||||||
|
fetch(`${API_BASE}/tools/marketplace-products?${params.toString()}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => setData(res))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to load marketplace products:', err);
|
||||||
|
showToast('เกิดข้อผิดพลาดในการโหลดข้อมูลสินค้า', 'error');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Fetch Master Variants
|
||||||
|
const fetchMasterVariants = () => {
|
||||||
|
fetch(`${API_BASE}/tools/marketplace-products/master-variants`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((res) => {
|
||||||
|
if (res.variants) setMasterVariants(res.variants);
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Failed to load master variants:', err));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchData();
|
||||||
|
}, [channel, shop, status, sortBy, search, page]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMasterVariants();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3. Trigger Sync All Platforms
|
||||||
|
const handleSyncAll = async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/tools/marketplace-products/sync`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
showToast(
|
||||||
|
`Sync ข้อมูลสินค้าเรียบร้อย! ทั้งหมด ${fmtNum(result.totalMarketplaceProducts)} รายการ (จับคู่ใหม่อัตโนมัติ ${result.matchedCount} รายการ)`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
fetchData();
|
||||||
|
} else {
|
||||||
|
showToast('ไม่สามารถ Sync ข้อมูลได้', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('เกิดข้อผิดพลาดในการเชื่อมต่อ Sync', 'error');
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Trigger Bulk Auto Match
|
||||||
|
const handleBulkAutoMatch = async () => {
|
||||||
|
setAutoMatching(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/tools/marketplace-products/bulk-auto-match`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
showToast(
|
||||||
|
`Auto-Match สำเร็จ! จับคู่ SKU ตรงกันได้เพิ่ม ${fmtNum(result.matched)} รายการ (คงเหลือยังไม่ผูก ${fmtNum(result.remainingUnmapped)} รายการ)`,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('เกิดข้อผิดพลาดในการ Auto-Match', 'error');
|
||||||
|
} finally {
|
||||||
|
setAutoMatching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 5. Save Mapping for Single Item
|
||||||
|
const handleSaveMapping = async (marketplaceProductId: number, masterVariantId: number | null) => {
|
||||||
|
setSavingMapping(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/tools/marketplace-products/map`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ marketplaceProductId, masterVariantId }),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (result.success) {
|
||||||
|
showToast(masterVariantId ? 'ผูกสินค้ากับ Master Catalog เรียบร้อย' : 'ยกเลิกการผูกสินค้าเรียบร้อย', 'success');
|
||||||
|
setMappingModalItem(null);
|
||||||
|
fetchData();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
showToast('ไม่สามารถบันทึกการผูกสินค้าได้', 'error');
|
||||||
|
} finally {
|
||||||
|
setSavingMapping(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter master variants inside modal
|
||||||
|
const filteredMasterVariants = useMemo(() => {
|
||||||
|
if (!variantSearch.trim()) return masterVariants;
|
||||||
|
const q = variantSearch.toLowerCase();
|
||||||
|
return masterVariants.filter(
|
||||||
|
(v) =>
|
||||||
|
v.productName.toLowerCase().includes(q) ||
|
||||||
|
v.sku.toLowerCase().includes(q) ||
|
||||||
|
v.barcode.toLowerCase().includes(q) ||
|
||||||
|
v.optionsStr.toLowerCase().includes(q) ||
|
||||||
|
v.categoryName.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [masterVariants, variantSearch]);
|
||||||
|
|
||||||
|
const kpi = data?.kpi;
|
||||||
|
const items = data?.items || [];
|
||||||
|
const pagination = data?.pagination;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in pb-12">
|
||||||
|
{/* ── Toast Notification ──────────────────────────────────────────────── */}
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
className={`fixed bottom-6 right-6 z-50 px-4 py-3 rounded-2xl shadow-2xl border text-sm font-medium flex items-center gap-3 backdrop-blur-md animate-slide-up ${
|
||||||
|
toast.type === 'success'
|
||||||
|
? 'bg-emerald-950/90 border-emerald-500/40 text-emerald-200'
|
||||||
|
: toast.type === 'error'
|
||||||
|
? 'bg-rose-950/90 border-rose-500/40 text-rose-200'
|
||||||
|
: 'bg-slate-900/90 border-white/20 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.type === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-rose-400" />
|
||||||
|
)}
|
||||||
|
<span>{toast.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Page Header ──────────────────────────────────────────────────────── */}
|
||||||
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-amber-500/20 via-orange-500/20 to-rose-500/20 border border-amber-500/30 flex items-center justify-center shadow-lg shadow-amber-500/10">
|
||||||
|
<Wrench className="w-6 h-6 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-3xl font-bold text-white tracking-tight">Marketplace Products & SKU Manager</h1>
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||||
|
Tool • TikTok + Shopee + Lazada
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={handleBulkAutoMatch}
|
||||||
|
disabled={autoMatching || syncing}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 hover:text-white border border-white/10 text-xs font-bold transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{autoMatching ? <Loader2 className="w-4 h-4 animate-spin text-amber-400" /> : <Sparkles className="w-4 h-4 text-amber-400" />}
|
||||||
|
<span>⚡ Auto-Match SKUs</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSyncAll}
|
||||||
|
disabled={syncing || autoMatching}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 hover:from-amber-600 hover:to-rose-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
|
<span>{syncing ? 'กำลังประมวลผลดึงข้อมูล...' : '🔄 Sync จาก Order ทั้งหมด'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── KPI Summary Cards ─────────────────────────────────────────────────── */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||||
|
{/* Total SKUs */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สินค้าบน Marketplace</p>
|
||||||
|
<Package className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-white font-mono">
|
||||||
|
{fmtNum(kpi?.totalSkus || 0)} <span className="text-xs font-normal text-slate-400">รายการ/SKU</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
รวมทุกรูปแบบสินค้าที่เคยขายได้
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mapping Progress */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สถานะการผูก Master SKU</p>
|
||||||
|
<Link2 className="w-4 h-4 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-2xl font-bold text-cyan-400 font-mono">
|
||||||
|
{kpi?.mappedSkus || 0} <span className="text-xs font-normal text-slate-400">/ {kpi?.totalSkus || 0}</span>
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-bold text-cyan-400 font-mono">
|
||||||
|
{kpi?.mappedPercent?.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden mt-2">
|
||||||
|
<div
|
||||||
|
className="h-full bg-gradient-to-r from-cyan-500 to-emerald-400 transition-all duration-500"
|
||||||
|
style={{ width: `${kpi?.mappedPercent || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-1 font-mono">
|
||||||
|
ยังไม่ผูก: <span className="text-rose-400 font-bold">{kpi?.unmappedSkus || 0}</span> รายการ
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Sold Units */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้รวม</p>
|
||||||
|
<ShoppingBag className="w-4 h-4 text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-orange-400 font-mono">
|
||||||
|
{fmtNum(kpi?.totalSoldQty || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
ยอดขายรวมสะสมจากทุกออเดอร์
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Revenue */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวม (Gross)</p>
|
||||||
|
<DollarSign className="w-4 h-4 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-emerald-400 font-mono">
|
||||||
|
{fmtBaht(kpi?.totalRevenue || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
|
ยอดเงินที่ได้รับจากลูกค้า
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Est. Gross Profit (From Linked BOM) */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">กำไรขั้นต้นประเมิน (BOM)</p>
|
||||||
|
<TrendingUp className="w-4 h-4 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-amber-400 font-mono">
|
||||||
|
{fmtBaht(kpi?.totalEstProfit || 0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 font-mono">
|
||||||
|
Margin รวม: <span className="text-emerald-400 font-bold">{kpi?.grossProfitMargin?.toFixed(1)}%</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Filter Bar ────────────────────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl p-4 backdrop-blur-sm space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
{/* Channel Tabs */}
|
||||||
|
<div className="flex items-center gap-1.5 p-1 bg-black/30 rounded-xl border border-white/10 flex-wrap">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChannel('all');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🌐 ทุกช่องทาง
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChannel('tiktok');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
channel === 'tiktok'
|
||||||
|
? 'bg-rose-500/25 text-rose-300 border border-rose-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔴 TikTok
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChannel('shopee');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
channel === 'shopee'
|
||||||
|
? 'bg-orange-500/25 text-orange-300 border border-orange-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-orange-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🟠 Shopee
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setChannel('lazada');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||||
|
channel === 'lazada'
|
||||||
|
? 'bg-blue-500/25 text-blue-300 border border-blue-500/40 shadow'
|
||||||
|
: 'text-slate-400 hover:text-blue-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🔵 Lazada
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shop Selector */}
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span className="text-slate-400">ร้านค้า:</span>
|
||||||
|
<select
|
||||||
|
value={shop}
|
||||||
|
onChange={(e) => {
|
||||||
|
setShop(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1.5 rounded-xl bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="all">ทุกร้านค้า (All Shops)</option>
|
||||||
|
{channel === 'tiktok' &&
|
||||||
|
TIKTOK_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'shopee' &&
|
||||||
|
SHOPEE_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'lazada' &&
|
||||||
|
LAZADA_SHOPS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{channel === 'all' && (
|
||||||
|
<>
|
||||||
|
<optgroup label="TikTok Shops">
|
||||||
|
{TIKTOK_SHOPS.map((s) => (
|
||||||
|
<option key={`tt-${s.id}`} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Shopee Shops">
|
||||||
|
{SHOPEE_SHOPS.map((s) => (
|
||||||
|
<option key={`sp-${s.id}`} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Lazada Shops">
|
||||||
|
{LAZADA_SHOPS.map((s) => (
|
||||||
|
<option key={`lz-${s.id}`} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatus('all');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
status === 'all'
|
||||||
|
? 'bg-white/15 text-white border-white/20'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
ทั้งหมด
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatus('mapped');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
status === 'mapped'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-300 border-cyan-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-cyan-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1 text-cyan-400" />
|
||||||
|
ผูกแล้ว (Mapped)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setStatus('unmapped');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={`px-3 py-1.5 rounded-xl text-xs font-bold border transition-all ${
|
||||||
|
status === 'unmapped'
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border-rose-500/40'
|
||||||
|
: 'bg-white/5 text-slate-400 border-white/5 hover:text-rose-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 inline mr-1 text-rose-400" />
|
||||||
|
ยังไม่ผูก (Unmapped)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary: Search & Sort */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 pt-2 border-t border-white/5">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="ค้นหาชื่อสินค้า, SKU, ตัวเลือก, หรือ Master Variant..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">เรียงตาม:</span>
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSortBy(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="revenue">ยอดขายรวมสูงสุด (Revenue)</option>
|
||||||
|
<option value="sold">จำนวนชิ้นขายสูงสุด (Sold Qty)</option>
|
||||||
|
<option value="name">ชื่อสินค้า (A-Z)</option>
|
||||||
|
<option value="recent">ขายล่าสุด (Recent)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Marketplace Products Table ────────────────────────────────────────── */}
|
||||||
|
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm shadow-xl">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs text-left">
|
||||||
|
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase tracking-wider">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3.5">ช่องทาง / ร้านค้า</th>
|
||||||
|
<th className="px-4 py-3.5">ชื่อสินค้า & ตัวเลือก (Marketplace)</th>
|
||||||
|
<th className="px-4 py-3.5">Seller SKU</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">จำนวนที่ขาย</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">ยอดขายรวม (฿)</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">ราคาเฉลี่ย</th>
|
||||||
|
<th className="px-4 py-3.5">สินค้า Master (Core Catalog)</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">ต้นทุน BOM (฿)</th>
|
||||||
|
<th className="px-4 py-3.5 text-right">กำไรประเมิน</th>
|
||||||
|
<th className="px-4 py-3.5 text-center">จัดการ</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="px-4 py-16 text-center text-slate-400">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-amber-400" />
|
||||||
|
กำลังโหลดข้อมูลสินค้า...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={10} className="px-4 py-16 text-center text-slate-400 space-y-2">
|
||||||
|
<p>ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSyncAll}
|
||||||
|
className="px-4 py-2 rounded-xl bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-bold hover:bg-amber-500/30 transition-all"
|
||||||
|
>
|
||||||
|
กดเพื่อ Sync ข้อมูลจากออเดอร์
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
items.map((item: any) => {
|
||||||
|
const hasMapping = !!item.masterVariantId && !!item.masterVariant;
|
||||||
|
const profit = item.profitability;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-white/[0.03] transition-colors">
|
||||||
|
{/* Platform & Shop */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{item.channel === 'tiktok' ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">
|
||||||
|
TikTok
|
||||||
|
</span>
|
||||||
|
) : item.channel === 'shopee' ? (
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-orange-500/20 text-orange-300 border border-orange-500/30">
|
||||||
|
Shopee
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-block px-2 py-0.5 rounded text-[10px] font-bold bg-blue-500/20 text-blue-300 border border-blue-500/30">
|
||||||
|
Lazada
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-[10px] font-mono text-slate-400">{item.shop}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Product Name & Variation */}
|
||||||
|
<td className="px-4 py-3.5 max-w-xs">
|
||||||
|
<p className="font-bold text-white line-clamp-2" title={item.productName}>
|
||||||
|
{item.productName}
|
||||||
|
</p>
|
||||||
|
{item.variation && (
|
||||||
|
<span className="inline-block mt-1 px-2 py-0.5 rounded text-[10px] bg-white/5 text-amber-300 border border-white/10">
|
||||||
|
ตัวเลือก: {item.variation}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Seller SKU */}
|
||||||
|
<td className="px-4 py-3.5 font-mono text-slate-300">
|
||||||
|
{item.marketplaceSku ? (
|
||||||
|
<span className="bg-black/30 px-2 py-1 rounded border border-white/5 text-[11px]">
|
||||||
|
{item.marketplaceSku}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Sold Qty */}
|
||||||
|
<td className="px-4 py-3.5 text-right font-mono font-bold text-white">
|
||||||
|
{fmtNum(item.totalSoldQty)} <span className="text-[10px] font-normal text-slate-500">ชิ้น</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Total Revenue */}
|
||||||
|
<td className="px-4 py-3.5 text-right font-mono font-bold text-emerald-400">
|
||||||
|
{fmtBaht(item.totalRevenue)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Avg Price */}
|
||||||
|
<td className="px-4 py-3.5 text-right font-mono text-slate-300">
|
||||||
|
{fmtBaht(item.avgPrice)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Master Core Catalog Mapping */}
|
||||||
|
<td className="px-4 py-3.5">
|
||||||
|
{hasMapping ? (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-cyan-300 font-bold">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-cyan-400 shrink-0" />
|
||||||
|
<span className="truncate max-w-[200px]" title={item.masterVariant?.productName}>
|
||||||
|
{item.masterVariant?.productName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 text-[10px] text-slate-400 font-mono">
|
||||||
|
<span>SKU: {item.masterVariant?.sku || '-'}</span>
|
||||||
|
{item.masterVariant?.option1 && <span>• {item.masterVariant.option1}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-rose-500/10 text-rose-300 border border-rose-500/20">
|
||||||
|
<AlertCircle className="w-3 h-3 text-rose-400" /> ยังไม่ผูก SKU
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* BOM Cost */}
|
||||||
|
<td className="px-4 py-3.5 text-right font-mono">
|
||||||
|
{hasMapping && profit?.unitBomCost > 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-300">{fmtBaht(profit.unitBomCost)}/ชิ้น</span>
|
||||||
|
<p className="text-[10px] text-slate-500">รวม {fmtBaht(profit.totalCost)}</p>
|
||||||
|
</div>
|
||||||
|
) : hasMapping ? (
|
||||||
|
<span className="text-slate-500 text-[10px]">ยังไม่ตั้ง BOM</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Estimated Profit */}
|
||||||
|
<td className="px-4 py-3.5 text-right font-mono">
|
||||||
|
{hasMapping && profit ? (
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-amber-400">{fmtBaht(profit.profit)}</span>
|
||||||
|
<p className="text-[10px] text-emerald-400 font-bold">{profit.marginPercent.toFixed(1)}% margin</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-4 py-3.5 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMappingModalItem(item);
|
||||||
|
setSelectedVariantId(item.masterVariantId);
|
||||||
|
setVariantSearch('');
|
||||||
|
}}
|
||||||
|
className="px-2.5 py-1.5 rounded-xl bg-white/5 hover:bg-amber-500/20 hover:text-amber-300 text-slate-300 border border-white/10 text-xs font-semibold transition-all inline-flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Link2 className="w-3.5 h-3.5" />
|
||||||
|
<span>{hasMapping ? 'เปลี่ยนผูก' : 'ผูกสินค้า'}</span>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Footer */}
|
||||||
|
{pagination && pagination.totalPages > 1 && (
|
||||||
|
<div className="p-4 border-t border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.01]">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
แสดงหน้าที่ <strong className="text-white">{pagination.page}</strong> จาก{' '}
|
||||||
|
<strong className="text-white">{pagination.totalPages}</strong> (ทั้งหมด {fmtNum(pagination.total)} รายการ)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.max(p - 1, 1))}
|
||||||
|
disabled={pagination.page <= 1}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" /> ก่อนหน้า
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage((p) => Math.min(p + 1, pagination.totalPages))}
|
||||||
|
disabled={pagination.page >= pagination.totalPages}
|
||||||
|
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
|
||||||
|
>
|
||||||
|
ถัดไป <ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Mapping Modal ────────────────────────────────────────────────────── */}
|
||||||
|
{mappingModalItem && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||||
|
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-2xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-amber-500/20 border border-amber-500/30 flex items-center justify-center">
|
||||||
|
<Link2 className="w-5 h-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-white text-base">ผูกสินค้า Marketplace เข้า Master Catalog</h3>
|
||||||
|
<p className="text-xs text-slate-400">เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setMappingModalItem(null)}
|
||||||
|
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target Marketplace Item Summary */}
|
||||||
|
<div className="p-4 mx-6 mt-4 bg-white/5 border border-white/10 rounded-xl space-y-1.5 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">สินค้าบน {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}):</span>
|
||||||
|
{mappingModalItem.marketplaceSku && (
|
||||||
|
<span className="px-2 py-0.2 rounded bg-black/40 text-amber-300 font-mono">
|
||||||
|
SKU: {mappingModalItem.marketplaceSku}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="font-bold text-white text-sm">{mappingModalItem.productName}</p>
|
||||||
|
{mappingModalItem.variation && (
|
||||||
|
<p className="text-amber-400">ตัวเลือก: {mappingModalItem.variation}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4 text-slate-400 font-mono pt-1">
|
||||||
|
<span>ขายแล้ว: <strong className="text-white">{fmtNum(mappingModalItem.totalSoldQty)}</strong> ชิ้น</span>
|
||||||
|
<span>ยอดขาย: <strong className="text-emerald-400">{fmtBaht(mappingModalItem.totalRevenue)}</strong></span>
|
||||||
|
<span>ราคาเฉลี่ย: <strong className="text-amber-400">{fmtBaht(mappingModalItem.avgPrice)}</strong></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Variants List */}
|
||||||
|
<div className="p-6 flex-1 overflow-hidden flex flex-col space-y-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="พิมพ์ค้นหาชื่อสินค้าหลัก, รหัส SKU, หรือขนาด (เช่น 50ml, 120ml)..."
|
||||||
|
value={variantSearch}
|
||||||
|
onChange={(e) => setVariantSearch(e.target.value)}
|
||||||
|
className="w-full pl-9 pr-4 py-2 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1 max-h-[300px]">
|
||||||
|
{filteredMasterVariants.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-slate-500 text-xs">
|
||||||
|
ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredMasterVariants.map((variant) => {
|
||||||
|
const isSelected = selectedVariantId === variant.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={variant.id}
|
||||||
|
onClick={() => setSelectedVariantId(variant.id)}
|
||||||
|
className={`p-3 rounded-xl border transition-all cursor-pointer flex items-center justify-between gap-3 ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-amber-500/20 border-amber-500/40 shadow-sm'
|
||||||
|
: 'bg-white/[0.02] border-white/5 hover:bg-white/5 hover:border-white/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<p className="text-xs font-bold text-white">{variant.productName}</p>
|
||||||
|
<div className="flex items-center gap-2 text-[11px] text-slate-400 flex-wrap">
|
||||||
|
<span className="font-mono text-cyan-300">SKU: {variant.sku || '-'}</span>
|
||||||
|
{variant.optionsStr && <span className="text-amber-300">• {variant.optionsStr}</span>}
|
||||||
|
{variant.categoryName && <span className="text-slate-500">• {variant.categoryName}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0 font-mono text-xs">
|
||||||
|
<p className="text-emerald-400 font-bold">{fmtBaht(variant.price)}</p>
|
||||||
|
<p className="text-[10px] text-slate-400">
|
||||||
|
ต้นทุน BOM: <span className="text-amber-300">{fmtBaht(variant.bomCost)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="p-4 px-6 border-t border-white/10 flex items-center justify-between bg-white/[0.02]">
|
||||||
|
{mappingModalItem.masterVariantId ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveMapping(mappingModalItem.id, null)}
|
||||||
|
disabled={savingMapping}
|
||||||
|
className="px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-bold transition-all flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
<Unlink className="w-4 h-4" /> ยกเลิกการผูก
|
||||||
|
</button>
|
||||||
|
) : <div />}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMappingModalItem(null)}
|
||||||
|
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-bold transition-all"
|
||||||
|
>
|
||||||
|
ปิด
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveMapping(mappingModalItem.id, selectedVariantId)}
|
||||||
|
disabled={savingMapping || !selectedVariantId}
|
||||||
|
className="px-5 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50 flex items-center gap-1.5"
|
||||||
|
>
|
||||||
|
{savingMapping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
|
||||||
|
<span>บันทึกการผูกสินค้า</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MarketplaceProduct" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"channel" TEXT NOT NULL,
|
||||||
|
"shop" TEXT NOT NULL DEFAULT 'hom-aroma',
|
||||||
|
"marketplaceSku" TEXT NOT NULL DEFAULT '',
|
||||||
|
"productName" TEXT NOT NULL,
|
||||||
|
"variation" TEXT NOT NULL DEFAULT '',
|
||||||
|
"avgPrice" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"totalSoldQty" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"totalRevenue" DOUBLE PRECISION NOT NULL DEFAULT 0,
|
||||||
|
"firstSoldAt" TEXT,
|
||||||
|
"lastSoldAt" TEXT,
|
||||||
|
"masterVariantId" INTEGER,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MarketplaceProduct_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketplaceProduct_channel_idx" ON "MarketplaceProduct"("channel");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketplaceProduct_shop_idx" ON "MarketplaceProduct"("shop");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketplaceProduct_marketplaceSku_idx" ON "MarketplaceProduct"("marketplaceSku");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketplaceProduct_masterVariantId_idx" ON "MarketplaceProduct"("masterVariantId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MarketplaceProduct_totalRevenue_idx" ON "MarketplaceProduct"("totalRevenue");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MarketplaceProduct_channel_shop_productName_variation_marke_key" ON "MarketplaceProduct"("channel", "shop", "productName", "variation", "marketplaceSku");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MarketplaceProduct" ADD CONSTRAINT "MarketplaceProduct_masterVariantId_fkey" FOREIGN KEY ("masterVariantId") REFERENCES "ProductVariant"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -112,6 +112,7 @@ model ProductVariant {
|
|||||||
productId Int
|
productId Int
|
||||||
product Product @relation(fields: [productId], references: [id])
|
product Product @relation(fields: [productId], references: [id])
|
||||||
bomItems BomItem[]
|
bomItems BomItem[]
|
||||||
|
marketplaceProducts MarketplaceProduct[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -497,6 +498,12 @@ model TiktokOrder {
|
|||||||
shippingFee Float @default(0) // Shipping Fee After Discount
|
shippingFee Float @default(0) // Shipping Fee After Discount
|
||||||
originalShipping Float @default(0)
|
originalShipping Float @default(0)
|
||||||
taxes Float @default(0)
|
taxes Float @default(0)
|
||||||
|
// Settlement Financials
|
||||||
|
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok
|
||||||
|
affiliateFee Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
|
||||||
|
netSettlement Float @default(0) // ยอดเงินโอนจริงสุทธิ
|
||||||
|
settledAt String? // วันที่โอนเงิน
|
||||||
|
isSettled Boolean @default(false)
|
||||||
// Geography
|
// Geography
|
||||||
province String?
|
province String?
|
||||||
district String?
|
district String?
|
||||||
@@ -509,9 +516,9 @@ model TiktokOrder {
|
|||||||
deliveredTime String?
|
deliveredTime String?
|
||||||
cancelledTime String?
|
cancelledTime String?
|
||||||
|
|
||||||
items TiktokOrderItem[]
|
items TiktokOrderItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([shop])
|
@@index([shop])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@ -537,6 +544,44 @@ model TiktokOrderItem {
|
|||||||
weight Float @default(0)
|
weight Float @default(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model TiktokSettlement {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
orderId String @unique // หมายเลขคำสั่งซื้อ/การปรับ
|
||||||
|
shop String @default("hom-aroma")
|
||||||
|
transactionType String? // ประเภทธุรกรรม (คำสั่งซื้อ / การปรับยอด)
|
||||||
|
orderCreatedTime String? // เวลาที่สร้างคำสั่งซื้อ
|
||||||
|
orderSettledTime String? // เวลาที่ชำระคำสั่งซื้อ / วันที่โอนเงิน
|
||||||
|
currency String @default("THB")
|
||||||
|
|
||||||
|
// Financial breakdowns
|
||||||
|
settlementAmount Float @default(0) // ยอดการชำระเงินทั้งหมด (Net Payout)
|
||||||
|
grossRevenue Float @default(0) // รายได้ทั้งหมด
|
||||||
|
subtotalAfterSellerDiscount Float @default(0) // ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย
|
||||||
|
subtotalBeforeDiscount Float @default(0) // ยอดรวมค่าสินค้าก่อนหักส่วนลด
|
||||||
|
sellerDiscount Float @default(0) // ส่วนลดจากร้านค้า
|
||||||
|
refundAmount Float @default(0) // ยอดรวมเงินคืน
|
||||||
|
|
||||||
|
// Fees
|
||||||
|
totalFee Float @default(0) // ค่าธรรมเนียมทั้งหมด
|
||||||
|
orderFee Float @default(0) // ค่าธรรมเนียมคำสั่งซื้อ
|
||||||
|
commissionFee Float @default(0) // ค่าคอมมิชชั่น TikTok Shop
|
||||||
|
affiliateCommission Float @default(0) // ค่าคอมมิชชั่นแอฟฟิลิเอต
|
||||||
|
affiliatePartnerFee Float @default(0) // ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต
|
||||||
|
growthSupportFee Float @default(0) // ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า
|
||||||
|
shippingFeeActual Float @default(0) // ค่าธรรมเนียมการจัดส่งจริง
|
||||||
|
platformShippingDiscount Float @default(0) // ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม
|
||||||
|
|
||||||
|
customerPayment Float @default(0) // การชำระเงินของลูกค้า
|
||||||
|
rawData Json? // Full raw row data for audit
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([shop])
|
||||||
|
@@index([orderSettledTime])
|
||||||
|
@@index([orderId])
|
||||||
|
}
|
||||||
|
|
||||||
// ── Lazada Orders ──────────────────────────────────────────────────────────────
|
// ── Lazada Orders ──────────────────────────────────────────────────────────────
|
||||||
model LazadaOrder {
|
model LazadaOrder {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@@ -715,3 +760,33 @@ model ContactMessage {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Marketplace Products & SKU Mapping ──────────────────────────────────────────
|
||||||
|
model MarketplaceProduct {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
channel String // "tiktok" | "shopee" | "lazada"
|
||||||
|
shop String @default("hom-aroma")
|
||||||
|
marketplaceSku String @default("") // sellerSku / skuRef / skuId
|
||||||
|
productName String // Item name on marketplace
|
||||||
|
variation String @default("") // Variation / flavor / size
|
||||||
|
avgPrice Float @default(0)
|
||||||
|
totalSoldQty Int @default(0)
|
||||||
|
totalRevenue Float @default(0)
|
||||||
|
firstSoldAt String?
|
||||||
|
lastSoldAt String?
|
||||||
|
|
||||||
|
// Link to Master Core Catalog
|
||||||
|
masterVariantId Int?
|
||||||
|
masterVariant ProductVariant? @relation(fields: [masterVariantId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([channel, shop, productName, variation, marketplaceSku])
|
||||||
|
@@index([channel])
|
||||||
|
@@index([shop])
|
||||||
|
@@index([marketplaceSku])
|
||||||
|
@@index([masterVariantId])
|
||||||
|
@@index([totalRevenue])
|
||||||
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,8 @@ import { mediaRoute } from "./routes/media";
|
|||||||
import { contactsRoute } from "./routes/contacts";
|
import { contactsRoute } from "./routes/contacts";
|
||||||
import { productAttributesRoute } from "./routes/product-attributes";
|
import { productAttributesRoute } from "./routes/product-attributes";
|
||||||
import { backupRoute } from "./routes/backup";
|
import { backupRoute } from "./routes/backup";
|
||||||
|
import { businessAnalyticsRoute } from "./routes/businessAnalytics";
|
||||||
|
import { toolsRoute } from "./routes/tools";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3051;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_brand";
|
||||||
@@ -136,7 +138,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.group("/api", (app) =>
|
||||||
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute)
|
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(productAttributesRoute).use(uploadRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute).use(paymentsRoute).use(profileRoute).use(lineRoutes).use(menusRoute).use(newsletterRoute).use(mediaRoute).use(contactsRoute).use(backupRoute).use(businessAnalyticsRoute).use(toolsRoute)
|
||||||
)
|
)
|
||||||
.listen({ port: PORT, hostname: "0.0.0.0" });
|
.listen({ port: PORT, hostname: "0.0.0.0" });
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
|
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
|
||||||
|
|
||||||
const toNum = (val: any): number => {
|
const toNum = (val: any): number => {
|
||||||
if (val === null || val === undefined || val === "") return 0;
|
if (val === null || val === undefined || val === "") return 0;
|
||||||
@@ -8,6 +9,13 @@ const toNum = (val: any): number => {
|
|||||||
return isNaN(n) ? 0 : n;
|
return isNaN(n) ? 0 : n;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function extractShopeeAddress(order: any): { province: string; district: string } {
|
||||||
|
const raw = (order.items?.[0]?.rawData as any) || {};
|
||||||
|
const province = String(raw["จังหวัด"] || raw["Province"] || raw["City"] || "").trim();
|
||||||
|
const district = String(raw["เขต/อำเภอ"] || raw["อำเภอ/เขต"] || raw["อำเภอ"] || raw["District"] || "").trim();
|
||||||
|
return { province, district };
|
||||||
|
}
|
||||||
|
|
||||||
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
||||||
// ── Import Orders ─────────────────────────────────────────────────────────
|
// ── Import Orders ─────────────────────────────────────────────────────────
|
||||||
.post(
|
.post(
|
||||||
@@ -340,4 +348,212 @@ export const shopeeRoute = new Elysia({ prefix: "/shopee" })
|
|||||||
totalItems: itemAgg._sum.quantity ?? 0,
|
totalItems: itemAgg._sum.quantity ?? 0,
|
||||||
totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
|
totalItemRevenue: itemAgg._sum.totalPayment ?? 0,
|
||||||
};
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── GET Thailand Sales Map (Shopee 77 Provinces & Top 20) ─────────────────
|
||||||
|
.get("/sales-map", async ({ query }) => {
|
||||||
|
const shop = (query.shop as string) || "hom-aroma";
|
||||||
|
const dateFrom = query.dateFrom as string | undefined;
|
||||||
|
const dateTo = query.dateTo as string | undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (shop && shop !== "all") {
|
||||||
|
where.shop = shop;
|
||||||
|
}
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
where.orderCreatedAt = {};
|
||||||
|
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||||
|
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||||
|
}
|
||||||
|
|
||||||
|
const orders = await prisma.shopeeOrder.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
items: {
|
||||||
|
select: {
|
||||||
|
rawData: true,
|
||||||
|
},
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const provSalesMap = new Map<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: number }>();
|
||||||
|
|
||||||
|
for (const p of THAILAND_PROVINCES) {
|
||||||
|
provSalesMap.set(p.nameTh, {
|
||||||
|
code: p.code,
|
||||||
|
nameTh: p.nameTh,
|
||||||
|
nameEn: p.nameEn,
|
||||||
|
region: p.region,
|
||||||
|
regionTh: p.regionTh,
|
||||||
|
revenue: 0,
|
||||||
|
orders: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalOrders = orders.length;
|
||||||
|
let unmappedOrders = 0;
|
||||||
|
let unmappedRevenue = 0;
|
||||||
|
|
||||||
|
for (const o of orders) {
|
||||||
|
totalRevenue += o.totalAmount;
|
||||||
|
const { province } = extractShopeeAddress(o);
|
||||||
|
const matched = findProvince(province);
|
||||||
|
if (matched) {
|
||||||
|
const p = provSalesMap.get(matched.nameTh)!;
|
||||||
|
p.revenue += o.totalAmount;
|
||||||
|
p.orders += 1;
|
||||||
|
} else {
|
||||||
|
unmappedOrders += 1;
|
||||||
|
unmappedRevenue += o.totalAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const provinceList = Array.from(provSalesMap.values());
|
||||||
|
const activeProvinces = provinceList.filter(p => p.orders > 0);
|
||||||
|
const coverageCount = activeProvinces.length;
|
||||||
|
|
||||||
|
// Top 20 provinces by revenue
|
||||||
|
const topProvinces = [...provinceList]
|
||||||
|
.sort((a, b) => b.revenue - a.revenue)
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((p, idx) => ({
|
||||||
|
rank: idx + 1,
|
||||||
|
...p,
|
||||||
|
aov: p.orders > 0 ? p.revenue / p.orders : 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Region Breakdown
|
||||||
|
const regionMap = new Map<string, { region: string; regionTh: string; revenue: number; orders: number }>();
|
||||||
|
for (const p of provinceList) {
|
||||||
|
if (!regionMap.has(p.region)) {
|
||||||
|
regionMap.set(p.region, { region: p.region, regionTh: p.regionTh, revenue: 0, orders: 0 });
|
||||||
|
}
|
||||||
|
const r = regionMap.get(p.region)!;
|
||||||
|
r.revenue += p.revenue;
|
||||||
|
r.orders += p.orders;
|
||||||
|
}
|
||||||
|
const regionBreakdown = Array.from(regionMap.values())
|
||||||
|
.map(r => ({
|
||||||
|
...r,
|
||||||
|
percentage: totalRevenue > 0 ? (r.revenue / totalRevenue) * 100 : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.revenue - a.revenue);
|
||||||
|
|
||||||
|
const topRegion = regionBreakdown[0] || { region: 'Central', regionTh: 'ภาคกลาง', revenue: 0, orders: 0 };
|
||||||
|
const aov = totalOrders > 0 ? totalRevenue / totalOrders : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpi: {
|
||||||
|
totalRevenue,
|
||||||
|
totalOrders,
|
||||||
|
coverageCount,
|
||||||
|
coverageTotal: 77,
|
||||||
|
coveragePercent: (coverageCount / 77) * 100,
|
||||||
|
aov,
|
||||||
|
topRegion: {
|
||||||
|
name: topRegion.regionTh,
|
||||||
|
orders: topRegion.orders,
|
||||||
|
revenue: topRegion.revenue,
|
||||||
|
},
|
||||||
|
unmappedOrders,
|
||||||
|
unmappedRevenue,
|
||||||
|
},
|
||||||
|
provinces: provinceList,
|
||||||
|
topProvinces,
|
||||||
|
regionBreakdown,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── GET District Level Details & Orders (Shopee) ──────────────────────────
|
||||||
|
.get("/sales-map/district", async ({ query, set }) => {
|
||||||
|
const provinceQuery = query.province as string;
|
||||||
|
if (!provinceQuery) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: "Province is required" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetProv = findProvince(provinceQuery);
|
||||||
|
const provNameTh = targetProv ? targetProv.nameTh : provinceQuery;
|
||||||
|
|
||||||
|
const shop = query.shop as string | undefined;
|
||||||
|
const dateFrom = query.dateFrom as string | undefined;
|
||||||
|
const dateTo = query.dateTo as string | undefined;
|
||||||
|
|
||||||
|
const where: any = {};
|
||||||
|
if (shop && shop !== "all") {
|
||||||
|
where.shop = shop;
|
||||||
|
}
|
||||||
|
if (dateFrom || dateTo) {
|
||||||
|
where.orderCreatedAt = {};
|
||||||
|
if (dateFrom) where.orderCreatedAt.gte = dateFrom;
|
||||||
|
if (dateTo) where.orderCreatedAt.lte = dateTo + " 23:59";
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOrders = await prisma.shopeeOrder.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
items: true,
|
||||||
|
},
|
||||||
|
orderBy: { orderCreatedAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter in JS by matching extracted address
|
||||||
|
const matchedOrders = allOrders.filter(o => {
|
||||||
|
const { province } = extractShopeeAddress(o);
|
||||||
|
const p = findProvince(province);
|
||||||
|
return p ? p.nameTh === provNameTh : (province?.includes(provNameTh) || provNameTh.includes(province || ''));
|
||||||
|
});
|
||||||
|
|
||||||
|
const districtMap = new Map<string, { districtName: string; revenue: number; orders: number }>();
|
||||||
|
let provRevenue = 0;
|
||||||
|
|
||||||
|
for (const o of matchedOrders) {
|
||||||
|
provRevenue += o.totalAmount;
|
||||||
|
const { district } = extractShopeeAddress(o);
|
||||||
|
const dName = cleanDistrictName(district) || "ไม่ระบุอำเภอ";
|
||||||
|
if (!districtMap.has(dName)) {
|
||||||
|
districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 });
|
||||||
|
}
|
||||||
|
const d = districtMap.get(dName)!;
|
||||||
|
d.revenue += o.totalAmount;
|
||||||
|
d.orders += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const districts = Array.from(districtMap.values()).sort((a, b) => b.revenue - a.revenue);
|
||||||
|
|
||||||
|
return {
|
||||||
|
province: targetProv || { nameTh: provNameTh, nameEn: provNameTh, region: 'Central', regionTh: 'ภาคกลาง', code: '' },
|
||||||
|
summary: {
|
||||||
|
totalRevenue: provRevenue,
|
||||||
|
totalOrders: matchedOrders.length,
|
||||||
|
districtCount: districts.length,
|
||||||
|
},
|
||||||
|
districts,
|
||||||
|
orders: matchedOrders.map(o => {
|
||||||
|
const { district, province } = extractShopeeAddress(o);
|
||||||
|
return {
|
||||||
|
orderId: o.orderId,
|
||||||
|
shop: o.shop,
|
||||||
|
status: o.status,
|
||||||
|
buyerUsername: o.buyerUsername,
|
||||||
|
createdTime: o.orderCreatedAt,
|
||||||
|
orderAmount: o.totalAmount,
|
||||||
|
shippingFee: o.shippingFee,
|
||||||
|
province: province || 'ไม่ระบุจังหวัด',
|
||||||
|
district: cleanDistrictName(district) || district || 'ไม่ระบุอำเภอ',
|
||||||
|
itemsCount: o.items.reduce((s, i) => s + i.quantity, 0),
|
||||||
|
items: o.items.map(i => ({
|
||||||
|
productName: i.productName,
|
||||||
|
variation: i.variantName,
|
||||||
|
sellerSku: i.skuRef || i.parentSku,
|
||||||
|
quantity: i.quantity,
|
||||||
|
subtotalAfterDiscount: i.totalPayment,
|
||||||
|
unitOriginalPrice: i.originalPrice || i.dealPrice,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
@@ -7,7 +7,7 @@ const toNum = (val: any): number => {
|
|||||||
const s = String(val).replace(/[^0-9.\-]/g, "");
|
const s = String(val).replace(/[^0-9.\-]/g, "");
|
||||||
const n = parseFloat(s);
|
const n = parseFloat(s);
|
||||||
return isNaN(n) ? 0 : n;
|
return isNaN(n) ? 0 : n;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TikTok CSV values can have trailing \t — trim everything
|
// TikTok CSV values can have trailing \t — trim everything
|
||||||
const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim();
|
const str = (val: any): string => String(val ?? "").replace(/\t/g, "").trim();
|
||||||
@@ -26,9 +26,216 @@ const parseTiktokDate = (val: any): string => {
|
|||||||
return d;
|
return d;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to enrich TikTok orders with BOM & Profitability
|
||||||
|
async function enrichOrdersWithBOM(orders: any[], shop: string) {
|
||||||
|
if (!orders || orders.length === 0) return [];
|
||||||
|
|
||||||
|
const orderIds = orders.map((o) => o.orderId).filter(Boolean);
|
||||||
|
|
||||||
|
// Fetch all MarketplaceProduct mappings, master variants, and settlements for this batch
|
||||||
|
const [marketplaceProducts, masterVariants, settlements] = await Promise.all([
|
||||||
|
prisma.marketplaceProduct.findMany({
|
||||||
|
where: { channel: "tiktok", shop },
|
||||||
|
include: {
|
||||||
|
masterVariant: {
|
||||||
|
include: {
|
||||||
|
product: { select: { id: true, name: true } },
|
||||||
|
bomItems: {
|
||||||
|
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.productVariant.findMany({
|
||||||
|
include: {
|
||||||
|
product: { select: { id: true, name: true } },
|
||||||
|
bomItems: {
|
||||||
|
include: { rawMaterial: { select: { id: true, name: true, unit: true, pricePerUnit: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
orderIds.length > 0
|
||||||
|
? prisma.tiktokSettlement.findMany({ where: { orderId: { in: orderIds } } })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Index Settlements
|
||||||
|
const settlementMap = new Map<string, any>();
|
||||||
|
for (const s of settlements) {
|
||||||
|
settlementMap.set(s.orderId, s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index MarketplaceProducts
|
||||||
|
const mpExactMap = new Map<string, any>(); // shop:::productName:::variation:::sku
|
||||||
|
const mpSkuMap = new Map<string, any>(); // shop:::sku
|
||||||
|
const mpNameVarMap = new Map<string, any>(); // shop:::productName:::variation
|
||||||
|
const variantSkuMap = new Map<string, any>(); // SKU uppercase -> ProductVariant
|
||||||
|
|
||||||
|
for (const mp of marketplaceProducts) {
|
||||||
|
const s = (mp.shop || "hom-aroma").trim().toLowerCase();
|
||||||
|
const pName = (mp.productName || "").trim().toLowerCase();
|
||||||
|
const vName = (mp.variation || "").trim().toLowerCase();
|
||||||
|
const sku = (mp.marketplaceSku || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const exactKey = `${s}:::${pName}:::${vName}:::${sku}`;
|
||||||
|
mpExactMap.set(exactKey, mp);
|
||||||
|
|
||||||
|
if (sku) {
|
||||||
|
mpSkuMap.set(`${s}:::${sku}`, mp);
|
||||||
|
}
|
||||||
|
if (pName) {
|
||||||
|
mpNameVarMap.set(`${s}:::${pName}:::${vName}`, mp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const v of masterVariants) {
|
||||||
|
if (v.sku) {
|
||||||
|
variantSkuMap.set(v.sku.trim().toLowerCase(), v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders.map((order) => {
|
||||||
|
const oShop = (order.shop || "hom-aroma").trim().toLowerCase();
|
||||||
|
let orderTotalBomCost = 0;
|
||||||
|
let missingBomCount = 0;
|
||||||
|
|
||||||
|
const enrichedItems = (order.items || []).map((item: any) => {
|
||||||
|
const pName = (item.productName || "").trim().toLowerCase();
|
||||||
|
const vName = (item.variation || "").trim().toLowerCase();
|
||||||
|
const sSku = (item.sellerSku || item.skuId || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
// Resolve Master Variant
|
||||||
|
let resolvedVariant: any = null;
|
||||||
|
let matchedVia = "";
|
||||||
|
|
||||||
|
// 1. Exact Match
|
||||||
|
const exact = mpExactMap.get(`${oShop}:::${pName}:::${vName}:::${sSku}`);
|
||||||
|
if (exact?.masterVariant) {
|
||||||
|
resolvedVariant = exact.masterVariant;
|
||||||
|
matchedVia = "marketplace_exact";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. SKU Match in MarketplaceProduct
|
||||||
|
if (!resolvedVariant && sSku) {
|
||||||
|
const bySku = mpSkuMap.get(`${oShop}:::${sSku}`);
|
||||||
|
if (bySku?.masterVariant) {
|
||||||
|
resolvedVariant = bySku.masterVariant;
|
||||||
|
matchedVia = "marketplace_sku";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Name + Variation Match in MarketplaceProduct
|
||||||
|
if (!resolvedVariant && pName) {
|
||||||
|
const byNameVar = mpNameVarMap.get(`${oShop}:::${pName}:::${vName}`);
|
||||||
|
if (byNameVar?.masterVariant) {
|
||||||
|
resolvedVariant = byNameVar.masterVariant;
|
||||||
|
matchedVia = "marketplace_name_variation";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Direct Core Variant SKU match
|
||||||
|
if (!resolvedVariant && sSku) {
|
||||||
|
const direct = variantSkuMap.get(sSku);
|
||||||
|
if (direct) {
|
||||||
|
resolvedVariant = direct;
|
||||||
|
matchedVia = "direct_sku";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate BOM Cost
|
||||||
|
let unitBomCost = 0;
|
||||||
|
let bomItemsDetail: any[] = [];
|
||||||
|
let hasBom = false;
|
||||||
|
let hasMapping = !!resolvedVariant;
|
||||||
|
|
||||||
|
if (resolvedVariant) {
|
||||||
|
const bItems = resolvedVariant.bomItems || [];
|
||||||
|
unitBomCost = bItems.reduce((acc: number, b: any) => {
|
||||||
|
return acc + (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
bomItemsDetail = bItems.map((b: any) => ({
|
||||||
|
rawMaterialId: b.rawMaterialId,
|
||||||
|
name: b.rawMaterial?.name || "วัตถุดิบ",
|
||||||
|
unit: b.rawMaterial?.unit || "หน่วย",
|
||||||
|
pricePerUnit: b.rawMaterial?.pricePerUnit || 0,
|
||||||
|
quantity: b.quantity || 0,
|
||||||
|
lineCost: (Number(b.quantity) || 0) * (Number(b.rawMaterial?.pricePerUnit) || 0),
|
||||||
|
}));
|
||||||
|
|
||||||
|
hasBom = unitBomCost > 0 && bItems.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qty = Number(item.quantity) || 1;
|
||||||
|
const totalBomCost = unitBomCost * qty;
|
||||||
|
const itemRevenue = item.subtotalAfterDiscount > 0 ? item.subtotalAfterDiscount : (item.unitOriginalPrice * qty);
|
||||||
|
const profit = itemRevenue - totalBomCost;
|
||||||
|
const marginPercent = itemRevenue > 0 ? (profit / itemRevenue) * 100 : 0;
|
||||||
|
|
||||||
|
if (!hasBom) {
|
||||||
|
missingBomCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
orderTotalBomCost += totalBomCost;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
unitBomCost,
|
||||||
|
totalBomCost,
|
||||||
|
profit,
|
||||||
|
marginPercent,
|
||||||
|
hasBom,
|
||||||
|
hasMapping,
|
||||||
|
matchedVia,
|
||||||
|
masterProductId: resolvedVariant?.productId || (typeof resolvedVariant?.product?.id === "number" ? resolvedVariant.product.id : null),
|
||||||
|
masterProductName: typeof resolvedVariant?.product?.name === "string" ? resolvedVariant.product.name : (resolvedVariant?.product?.name as any)?.th || (resolvedVariant?.product?.name as any)?.en || null,
|
||||||
|
masterSku: resolvedVariant?.sku || null,
|
||||||
|
masterVariantId: resolvedVariant?.id || null,
|
||||||
|
bomItemsDetail,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const settlement = settlementMap.get(order.orderId);
|
||||||
|
const rawComm = (settlement?.commissionFee ?? order.commissionFee ?? 0);
|
||||||
|
const commissionFee = Math.abs(rawComm);
|
||||||
|
const rawAff = settlement
|
||||||
|
? (settlement.affiliateCommission + settlement.affiliatePartnerFee)
|
||||||
|
: (order.affiliateFee ?? 0);
|
||||||
|
const affiliateFee = Math.abs(rawAff);
|
||||||
|
const netSettlement = settlement?.settlementAmount ?? order.netSettlement ?? (order.orderAmount - commissionFee - affiliateFee);
|
||||||
|
const isSettled = order.isSettled || !!settlement;
|
||||||
|
const settledAt = order.settledAt || settlement?.orderSettledTime || null;
|
||||||
|
|
||||||
|
const orderEstProfit = order.orderAmount - orderTotalBomCost;
|
||||||
|
const orderNetProfit = (isSettled && settlement ? settlement.settlementAmount : (order.orderAmount - commissionFee - affiliateFee)) - orderTotalBomCost;
|
||||||
|
const orderGrossMargin = order.orderAmount > 0 ? (orderEstProfit / order.orderAmount) * 100 : 0;
|
||||||
|
const allBomComplete = missingBomCount === 0 && enrichedItems.length > 0;
|
||||||
|
const hasIncompleteBom = missingBomCount > 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
items: enrichedItems,
|
||||||
|
settlement: settlement || null,
|
||||||
|
orderTotalBomCost,
|
||||||
|
orderEstProfit,
|
||||||
|
orderNetProfit,
|
||||||
|
commissionFee,
|
||||||
|
affiliateFee,
|
||||||
|
netSettlement,
|
||||||
|
isSettled,
|
||||||
|
settledAt,
|
||||||
|
orderGrossMargin,
|
||||||
|
missingBomCount,
|
||||||
|
allBomComplete,
|
||||||
|
hasIncompleteBom,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
||||||
|
|
||||||
// ── Import (CSV rows as JSON) ──────────────────────────────────────────────
|
// ── Import Orders (CSV rows as JSON) ───────────────────────────────────────
|
||||||
.post(
|
.post(
|
||||||
"/import",
|
"/import",
|
||||||
async ({ body, set }) => {
|
async ({ body, set }) => {
|
||||||
@@ -57,6 +264,8 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
const chunk = entries.slice(i, i + CHUNK);
|
const chunk = entries.slice(i, i + CHUNK);
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
for (const [orderId, { meta, items }] of chunk) {
|
for (const [orderId, { meta, items }] of chunk) {
|
||||||
|
const existingSettlement = await tx.tiktokSettlement.findUnique({ where: { orderId } });
|
||||||
|
|
||||||
// Delete old items first (sync strategy)
|
// Delete old items first (sync strategy)
|
||||||
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
|
await tx.tiktokOrderItem.deleteMany({ where: { orderId } });
|
||||||
|
|
||||||
@@ -104,6 +313,11 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
shippedTime: parseTiktokDate(meta["Shipped Time"]),
|
shippedTime: parseTiktokDate(meta["Shipped Time"]),
|
||||||
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
|
deliveredTime: parseTiktokDate(meta["Delivered Time"]),
|
||||||
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
|
cancelledTime: parseTiktokDate(meta["Cancelled Time"]),
|
||||||
|
commissionFee: existingSettlement ? existingSettlement.commissionFee : 0,
|
||||||
|
affiliateFee: existingSettlement ? (existingSettlement.affiliateCommission + existingSettlement.affiliatePartnerFee) : 0,
|
||||||
|
netSettlement: existingSettlement ? existingSettlement.settlementAmount : 0,
|
||||||
|
settledAt: existingSettlement ? existingSettlement.orderSettledTime : null,
|
||||||
|
isSettled: !!existingSettlement,
|
||||||
};
|
};
|
||||||
|
|
||||||
await tx.tiktokOrder.upsert({
|
await tx.tiktokOrder.upsert({
|
||||||
@@ -114,7 +328,6 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
upserted++;
|
upserted++;
|
||||||
}
|
}
|
||||||
}, { timeout: 30000 });
|
}, { timeout: 30000 });
|
||||||
console.log(`[TiktokOrder] Progress: ${Math.min(i + CHUNK, entries.length)}/${entries.length}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
|
console.log(`[TiktokOrder] ✅ Done — ${upserted} orders upserted`);
|
||||||
@@ -128,56 +341,301 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Import Settlement (Income XLSX rows as JSON) ───────────────────────────
|
||||||
|
.post(
|
||||||
|
"/import-settlement",
|
||||||
|
async ({ body, set }) => {
|
||||||
|
try {
|
||||||
|
const { rows, shop } = body as { rows: any[]; shop?: string };
|
||||||
|
const shopName = shop || "hom-aroma";
|
||||||
|
|
||||||
|
console.log(`[TiktokSettlement] Received ${rows.length} raw settlement rows for shop: ${shopName}`);
|
||||||
|
|
||||||
|
let upsertedCount = 0;
|
||||||
|
let matchedOrdersCount = 0;
|
||||||
|
let totalSettlementAmount = 0;
|
||||||
|
let totalCommission = 0;
|
||||||
|
let totalAffiliate = 0;
|
||||||
|
|
||||||
|
const CHUNK = 50;
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||||
|
const chunk = rows.slice(i, i + CHUNK);
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const row of chunk) {
|
||||||
|
const orderId = str(
|
||||||
|
row["หมายเลขคำสั่งซื้อ/การปรับ"] ||
|
||||||
|
row["หมายเลขคำสั่งซื้อ"] ||
|
||||||
|
row["Order ID"] ||
|
||||||
|
row["order_id"]
|
||||||
|
);
|
||||||
|
if (!orderId) continue;
|
||||||
|
|
||||||
|
const transactionType = str(row["ประเภทธุรกรรม"] || row["Transaction Type"]);
|
||||||
|
const orderCreatedTime = str(row["เวลาที่สร้างคำสั่งซื้อ"] || row["Order Created Time"]);
|
||||||
|
const orderSettledTime = str(row["เวลาที่ชำระคำสั่งซื้อ"] || row["Order Settled Time"] || row["เวลาที่สำเร็จ"]);
|
||||||
|
const currency = str(row["สกุลเงิน"] || row["Currency"] || "THB");
|
||||||
|
|
||||||
|
const settlementAmount = toNum(row["ยอดการชำระเงินทั้งหมด"] || row["Total Settlement Amount"] || row["จำนวน"]);
|
||||||
|
const grossRevenue = toNum(row["รายได้ทั้งหมด"] || row["Total Revenue"]);
|
||||||
|
const subtotalAfterSellerDiscount = toNum(row["ยอดรวมค่าสินค้าหลังหักส่วนลดจากผู้ขาย"]);
|
||||||
|
const subtotalBeforeDiscount = toNum(row["ยอดรวมค่าสินค้าก่อนหักส่วนลด"]);
|
||||||
|
const sellerDiscount = toNum(row["ส่วนลดจากร้านค้า"]);
|
||||||
|
const refundAmount = toNum(row["ยอดรวมเงินคืนหลังหักส่วนลดจากผู้ขาย"]);
|
||||||
|
|
||||||
|
const totalFee = toNum(row["ค่าธรรมเนียมทั้งหมด"]);
|
||||||
|
const orderFee = toNum(row["ค่าธรรมเนียมคำสั่งซื้อ"]);
|
||||||
|
const commissionFee = Math.abs(toNum(row["ค่าคอมมิชชั่น TikTok Shop"]));
|
||||||
|
const affiliateCommission = Math.abs(toNum(row["ค่าคอมมิชชั่นแอฟฟิลิเอต"]));
|
||||||
|
const affiliatePartnerFee = Math.abs(toNum(row["ค่าคอมมิชชั่นของพาร์ทเนอร์แอฟฟิลิเอต"]));
|
||||||
|
const growthSupportFee = toNum(row["ค่าธรรมเนียมสนับสนุนการเติบโตของร้านค้า"]);
|
||||||
|
const shippingFeeActual = toNum(row["ค่าธรรมเนียมการจัดส่งจริง"]);
|
||||||
|
const platformShippingDiscount = toNum(row["ส่วนลดค่าธรรมเนียมการจัดส่งจากแพลตฟอร์ม"]);
|
||||||
|
const customerPayment = toNum(row["การชำระเงินของลูกค้า"]);
|
||||||
|
|
||||||
|
const totalAff = affiliateCommission + affiliatePartnerFee;
|
||||||
|
|
||||||
|
totalSettlementAmount += settlementAmount;
|
||||||
|
totalCommission += commissionFee;
|
||||||
|
totalAffiliate += totalAff;
|
||||||
|
|
||||||
|
// Upsert into TiktokSettlement
|
||||||
|
await tx.tiktokSettlement.upsert({
|
||||||
|
where: { orderId },
|
||||||
|
update: {
|
||||||
|
shop: shopName,
|
||||||
|
transactionType,
|
||||||
|
orderCreatedTime,
|
||||||
|
orderSettledTime,
|
||||||
|
currency,
|
||||||
|
settlementAmount,
|
||||||
|
grossRevenue,
|
||||||
|
subtotalAfterSellerDiscount,
|
||||||
|
subtotalBeforeDiscount,
|
||||||
|
sellerDiscount,
|
||||||
|
refundAmount,
|
||||||
|
totalFee,
|
||||||
|
orderFee,
|
||||||
|
commissionFee,
|
||||||
|
affiliateCommission,
|
||||||
|
affiliatePartnerFee,
|
||||||
|
growthSupportFee,
|
||||||
|
shippingFeeActual,
|
||||||
|
platformShippingDiscount,
|
||||||
|
customerPayment,
|
||||||
|
rawData: row,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
orderId,
|
||||||
|
shop: shopName,
|
||||||
|
transactionType,
|
||||||
|
orderCreatedTime,
|
||||||
|
orderSettledTime,
|
||||||
|
currency,
|
||||||
|
settlementAmount,
|
||||||
|
grossRevenue,
|
||||||
|
subtotalAfterSellerDiscount,
|
||||||
|
subtotalBeforeDiscount,
|
||||||
|
sellerDiscount,
|
||||||
|
refundAmount,
|
||||||
|
totalFee,
|
||||||
|
orderFee,
|
||||||
|
commissionFee,
|
||||||
|
affiliateCommission,
|
||||||
|
affiliatePartnerFee,
|
||||||
|
growthSupportFee,
|
||||||
|
shippingFeeActual,
|
||||||
|
platformShippingDiscount,
|
||||||
|
customerPayment,
|
||||||
|
rawData: row,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
upsertedCount++;
|
||||||
|
|
||||||
|
// Update corresponding TiktokOrder if it exists
|
||||||
|
const matchedOrder = await tx.tiktokOrder.findUnique({ where: { orderId } });
|
||||||
|
if (matchedOrder) {
|
||||||
|
await tx.tiktokOrder.update({
|
||||||
|
where: { orderId },
|
||||||
|
data: {
|
||||||
|
commissionFee,
|
||||||
|
affiliateFee: totalAff,
|
||||||
|
netSettlement: settlementAmount,
|
||||||
|
settledAt: orderSettledTime,
|
||||||
|
isSettled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
matchedOrdersCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { timeout: 30000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[TiktokSettlement] ✅ Done — ${upsertedCount} settlements saved, ${matchedOrdersCount} orders linked`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `นำเข้าใบแจ้งยอดสำเร็จ ${upsertedCount} รายการ (เชื่อมโยงคำสั่งซื้อแล้ว ${matchedOrdersCount} ออเดอร์)`,
|
||||||
|
settlementsCount: upsertedCount,
|
||||||
|
matchedOrdersCount,
|
||||||
|
totalSettlementAmount,
|
||||||
|
totalCommission,
|
||||||
|
totalAffiliate,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[TiktokSettlement] ❌ Error:`, error.message);
|
||||||
|
set.status = 500;
|
||||||
|
return { success: false, message: error.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ body: t.Object({ rows: t.Array(t.Any()), shop: t.Optional(t.String()) }) }
|
||||||
|
)
|
||||||
|
|
||||||
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
// ── Clear Shop Data ───────────────────────────────────────────────────────
|
||||||
.delete("/clear", async ({ query, set }) => {
|
.delete("/clear", async ({ query, set }) => {
|
||||||
try {
|
try {
|
||||||
const shop = (query.shop as string) || "hom-aroma";
|
const shop = (query.shop as string) || "hom-aroma";
|
||||||
const result = await prisma.tiktokOrder.deleteMany({ where: { shop } });
|
const [orderResult, settlementResult] = await Promise.all([
|
||||||
return { success: true, message: `ลบข้อมูลร้าน ${shop} สำเร็จ (${result.count} รายการ)`, count: result.count };
|
prisma.tiktokOrder.deleteMany({ where: { shop } }),
|
||||||
|
prisma.tiktokSettlement.deleteMany({ where: { shop } }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `ลบข้อมูลร้าน ${shop} สำเร็จ (คำสั่งซื้อ: ${orderResult.count}, ใบแจ้งยอด: ${settlementResult.count})`,
|
||||||
|
count: orderResult.count + settlementResult.count,
|
||||||
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error(`[TiktokOrder] ❌ Clear Error:`, error.message);
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { success: false, message: error.message };
|
return { success: false, message: error.message };
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GET Single Order ──────────────────────────────────────────────────────
|
// ── GET Single Order Detail (with BOM Breakdown & Settlement) ──────────────
|
||||||
.get("/orders/:orderId", async ({ params, set }) => {
|
.get("/orders/:orderId", async ({ params, set }) => {
|
||||||
const order = await prisma.tiktokOrder.findUnique({ where: { orderId: params.orderId }, include: { items: true } });
|
const order = await prisma.tiktokOrder.findUnique({
|
||||||
if (!order) { set.status = 404; return { error: "Order not found" }; }
|
where: { orderId: params.orderId },
|
||||||
return order;
|
include: { items: true },
|
||||||
|
});
|
||||||
|
if (!order) {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Order not found" };
|
||||||
|
}
|
||||||
|
const [enriched] = await enrichOrdersWithBOM([order], order.shop);
|
||||||
|
return enriched || order;
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GET Orders (paginated) ────────────────────────────────────────────────
|
// ── GET Orders (paginated & searchable with BOM & Settlement) ──────────────
|
||||||
.get("/orders", async ({ query }) => {
|
.get("/orders", async ({ query }) => {
|
||||||
const page = Number(query.page ?? 1);
|
const page = Number(query.page ?? 1);
|
||||||
const limit = Number(query.limit ?? 30);
|
const limit = Number(query.limit ?? 20);
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
const shop = (query.shop as string) || "hom-aroma";
|
const shop = (query.shop as string) || "hom-aroma";
|
||||||
const where = { shop };
|
const search = ((query.search as string) || "").trim();
|
||||||
const [items, total] = await Promise.all([
|
const status = (query.status as string) || "";
|
||||||
prisma.tiktokOrder.findMany({ where, include: { items: true }, orderBy: { createdTime: "desc" }, skip, take: limit }),
|
const bomStatus = (query.bomStatus as string) || "all"; // "all" | "missing" | "complete"
|
||||||
|
const settlementStatus = (query.settlementStatus as string) || "all"; // "all" | "settled" | "pending"
|
||||||
|
|
||||||
|
const where: any = { shop };
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ orderId: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ buyerUsername: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ trackingId: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ province: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ district: { contains: search, mode: "insensitive" as const } },
|
||||||
|
{ items: { some: { productName: { contains: search, mode: "insensitive" as const } } } },
|
||||||
|
{ items: { some: { sellerSku: { contains: search, mode: "insensitive" as const } } } },
|
||||||
|
{ items: { some: { variation: { contains: search, mode: "insensitive" as const } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== "all") {
|
||||||
|
where.status = { contains: status, mode: "insensitive" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (settlementStatus === "settled") {
|
||||||
|
where.isSettled = true;
|
||||||
|
} else if (settlementStatus === "pending") {
|
||||||
|
where.isSettled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [rawItems, total] = await Promise.all([
|
||||||
|
prisma.tiktokOrder.findMany({
|
||||||
|
where,
|
||||||
|
include: { items: true },
|
||||||
|
orderBy: { createdTime: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
prisma.tiktokOrder.count({ where }),
|
prisma.tiktokOrder.count({ where }),
|
||||||
]);
|
]);
|
||||||
return { items, total, page, limit, totalPages: Math.ceil(total / limit) };
|
|
||||||
|
const items = await enrichOrdersWithBOM(rawItems, shop);
|
||||||
|
|
||||||
|
let filteredItems = items;
|
||||||
|
if (bomStatus === "missing") {
|
||||||
|
filteredItems = items.filter((o) => o.hasIncompleteBom);
|
||||||
|
} else if (bomStatus === "complete") {
|
||||||
|
filteredItems = items.filter((o) => o.allBomComplete);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items: filteredItems, total, page, limit, totalPages: Math.ceil(total / limit) };
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── GET Summary ───────────────────────────────────────────────────────────
|
// ── GET Summary (With BOM Cost, Settlement & Profit Breakdown) ─────────────
|
||||||
.get("/summary", async ({ query }) => {
|
.get("/summary", async ({ query }) => {
|
||||||
const shop = (query.shop as string) || "hom-aroma";
|
const shop = (query.shop as string) || "hom-aroma";
|
||||||
const where = { shop };
|
const where = { shop };
|
||||||
const [orderCount, agg, itemAgg] = await Promise.all([
|
const [orderCount, agg, itemAgg, allOrders, settlementAgg] = await Promise.all([
|
||||||
prisma.tiktokOrder.count({ where }),
|
prisma.tiktokOrder.count({ where }),
|
||||||
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true } }),
|
prisma.tiktokOrder.aggregate({ where, _sum: { orderAmount: true, shippingFee: true, refundAmount: true, taxes: true, commissionFee: true, affiliateFee: true, netSettlement: true } }),
|
||||||
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
|
prisma.tiktokOrderItem.aggregate({ where: { order: where }, _sum: { quantity: true, subtotalAfterDiscount: true } }),
|
||||||
|
prisma.tiktokOrder.findMany({ where, include: { items: true } }),
|
||||||
|
prisma.tiktokSettlement.aggregate({ where: { shop }, _sum: { settlementAmount: true, commissionFee: true, affiliateCommission: true, affiliatePartnerFee: true, orderFee: true, totalFee: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const enrichedOrders = await enrichOrdersWithBOM(allOrders, shop);
|
||||||
|
let totalBOMCost = 0;
|
||||||
|
let ordersMissingBomCount = 0;
|
||||||
|
let ordersWithBOMCount = 0;
|
||||||
|
let totalNetProfit = 0;
|
||||||
|
|
||||||
|
for (const o of enrichedOrders) {
|
||||||
|
totalBOMCost += o.orderTotalBomCost || 0;
|
||||||
|
totalNetProfit += o.orderNetProfit || 0;
|
||||||
|
if (o.hasIncompleteBom) {
|
||||||
|
ordersMissingBomCount++;
|
||||||
|
} else {
|
||||||
|
ordersWithBOMCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAmount = agg._sum.orderAmount ?? 0;
|
||||||
|
const totalEstProfit = totalAmount - totalBOMCost;
|
||||||
|
const grossProfitMargin = totalAmount > 0 ? (totalEstProfit / totalAmount) * 100 : 0;
|
||||||
|
|
||||||
|
const totalCommission = settlementAgg._sum.commissionFee ?? (agg._sum.commissionFee ?? 0);
|
||||||
|
const totalAffiliate = ((settlementAgg._sum.affiliateCommission ?? 0) + (settlementAgg._sum.affiliatePartnerFee ?? 0)) || (agg._sum.affiliateFee ?? 0);
|
||||||
|
const totalSettlementAmount = settlementAgg._sum.settlementAmount ?? (agg._sum.netSettlement ?? 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
orderCount,
|
orderCount,
|
||||||
totalAmount: agg._sum.orderAmount ?? 0,
|
totalAmount,
|
||||||
totalShipping: agg._sum.shippingFee ?? 0,
|
totalShipping: agg._sum.shippingFee ?? 0,
|
||||||
totalRefund: agg._sum.refundAmount ?? 0,
|
totalRefund: agg._sum.refundAmount ?? 0,
|
||||||
totalTaxes: agg._sum.taxes ?? 0,
|
totalTaxes: agg._sum.taxes ?? 0,
|
||||||
totalItems: itemAgg._sum.quantity ?? 0,
|
totalItems: itemAgg._sum.quantity ?? 0,
|
||||||
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
|
totalSkuRevenue: itemAgg._sum.subtotalAfterDiscount ?? 0,
|
||||||
|
totalBOMCost,
|
||||||
|
totalEstProfit,
|
||||||
|
grossProfitMargin,
|
||||||
|
ordersMissingBomCount,
|
||||||
|
ordersWithBOMCount,
|
||||||
|
totalCommission,
|
||||||
|
totalAffiliate,
|
||||||
|
totalSettlementAmount,
|
||||||
|
totalNetProfit,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -194,28 +652,63 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
|
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
|
||||||
}
|
}
|
||||||
|
|
||||||
const allOrders = await prisma.tiktokOrder.findMany({
|
const rawOrders = await prisma.tiktokOrder.findMany({
|
||||||
where,
|
where,
|
||||||
select: {
|
include: {
|
||||||
orderId: true, status: true, subStatus: true, createdTime: true,
|
items: true,
|
||||||
orderAmount: true, shippingFee: true, refundAmount: true, taxes: true,
|
|
||||||
paymentMethod: true, province: true,
|
|
||||||
items: { select: { quantity: true, subtotalAfterDiscount: true, platformDiscount: true, sellerDiscount: true } },
|
|
||||||
},
|
},
|
||||||
|
orderBy: { createdTime: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Enrich with BOM & Settlement
|
||||||
|
const allOrders = await enrichOrdersWithBOM(rawOrders, shop);
|
||||||
|
|
||||||
// Monthly aggregation
|
// Monthly aggregation
|
||||||
const monthMap = new Map<string, { month: string; revenue: number; orders: number; cancelled: number; items: number; platformDiscount: number; sellerDiscount: number }>();
|
const monthMap = new Map<string, {
|
||||||
|
month: string;
|
||||||
|
revenue: number;
|
||||||
|
orders: number;
|
||||||
|
cancelled: number;
|
||||||
|
items: number;
|
||||||
|
platformDiscount: number;
|
||||||
|
sellerDiscount: number;
|
||||||
|
commission: number;
|
||||||
|
affiliate: number;
|
||||||
|
bomCost: number;
|
||||||
|
netProfit: number;
|
||||||
|
settlement: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
for (const o of allOrders) {
|
for (const o of allOrders) {
|
||||||
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
const month = (o.createdTime ?? "").slice(0, 7) || "Unknown";
|
||||||
if (!monthMap.has(month)) monthMap.set(month, { month, revenue: 0, orders: 0, cancelled: 0, items: 0, platformDiscount: 0, sellerDiscount: 0 });
|
if (!monthMap.has(month)) {
|
||||||
|
monthMap.set(month, {
|
||||||
|
month,
|
||||||
|
revenue: 0,
|
||||||
|
orders: 0,
|
||||||
|
cancelled: 0,
|
||||||
|
items: 0,
|
||||||
|
platformDiscount: 0,
|
||||||
|
sellerDiscount: 0,
|
||||||
|
commission: 0,
|
||||||
|
affiliate: 0,
|
||||||
|
bomCost: 0,
|
||||||
|
netProfit: 0,
|
||||||
|
settlement: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
const m = monthMap.get(month)!;
|
const m = monthMap.get(month)!;
|
||||||
m.orders += 1;
|
m.orders += 1;
|
||||||
m.revenue += o.orderAmount;
|
m.revenue += o.orderAmount;
|
||||||
m.platformDiscount += o.items.reduce((s, i) => s + i.platformDiscount, 0);
|
m.platformDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0);
|
||||||
m.sellerDiscount += o.items.reduce((s, i) => s + i.sellerDiscount, 0);
|
m.sellerDiscount += (o.items || []).reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0);
|
||||||
if (o.status.includes("ยกเลิก") || o.status.includes("cancel")) m.cancelled += 1;
|
m.commission += (o.commissionFee || 0);
|
||||||
m.items += o.items.reduce((s, i) => s + i.quantity, 0);
|
m.affiliate += (o.affiliateFee || 0);
|
||||||
|
m.bomCost += (o.orderTotalBomCost || 0);
|
||||||
|
m.netProfit += (o.orderNetProfit || 0);
|
||||||
|
m.settlement += (o.netSettlement || 0);
|
||||||
|
if (o.status?.includes("ยกเลิก") || o.status?.includes("cancel")) m.cancelled += 1;
|
||||||
|
m.items += (o.items || []).reduce((s: number, i: any) => s + (i.quantity || 0), 0);
|
||||||
}
|
}
|
||||||
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
const monthly = Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month));
|
||||||
|
|
||||||
@@ -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);
|
const topProducts = Array.from(productMap.values()).sort((a, b) => b.revenue - a.revenue).slice(0, 10);
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
const totalRevenue = allOrders.reduce((s, o) => s + o.orderAmount, 0);
|
||||||
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
const totalShipping = allOrders.reduce((s, o) => s + o.shippingFee, 0);
|
||||||
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
const totalRefund = allOrders.reduce((s, o) => s + o.refundAmount, 0);
|
||||||
const totalPlatformDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.platformDiscount, 0);
|
const totalPlatformDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.platformDiscount || 0), 0);
|
||||||
const totalSellerDisc = allOrders.flatMap(o => o.items).reduce((s, i) => s + i.sellerDiscount, 0);
|
const totalSellerDisc = allOrders.flatMap(o => o.items || []).reduce((s, i) => s + (i.sellerDiscount || 0), 0);
|
||||||
const totalOrders = allOrders.length;
|
const totalCommission = allOrders.reduce((s, o) => s + (o.commissionFee || 0), 0);
|
||||||
const cancelledOrders = allOrders.filter(o => o.status.includes("ยกเลิก")).length;
|
const totalAffiliate = allOrders.reduce((s, o) => s + (o.affiliateFee || 0), 0);
|
||||||
const cancelRate = totalOrders > 0 ? (cancelledOrders / totalOrders) * 100 : 0;
|
const totalSettlementAmount = allOrders.reduce((s, o) => s + (o.netSettlement || 0), 0);
|
||||||
const completedOrders = totalOrders - cancelledOrders;
|
const totalBOMCost = allOrders.reduce((s, o) => s + (o.orderTotalBomCost || 0), 0);
|
||||||
const aov = completedOrders > 0 ? totalRevenue / completedOrders : 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 {
|
return {
|
||||||
monthly, statusBreakdown, paymentBreakdown, topProducts,
|
monthly,
|
||||||
|
statusBreakdown,
|
||||||
|
paymentBreakdown,
|
||||||
|
topProducts,
|
||||||
summary: {
|
summary: {
|
||||||
totalRevenue, totalShipping, totalRefund, totalPlatformDisc, totalSellerDisc,
|
totalRevenue,
|
||||||
|
totalShipping,
|
||||||
|
totalRefund,
|
||||||
|
totalPlatformDisc,
|
||||||
|
totalSellerDisc,
|
||||||
netRevenue: totalRevenue - totalShipping,
|
netRevenue: totalRevenue - totalShipping,
|
||||||
totalOrders, completedOrders, cancelledOrders, cancelRate, aov,
|
totalOrders,
|
||||||
|
completedOrders,
|
||||||
|
cancelledOrders,
|
||||||
|
cancelRate,
|
||||||
|
aov,
|
||||||
|
totalCommission,
|
||||||
|
totalAffiliate,
|
||||||
|
totalSettlementAmount,
|
||||||
|
totalBOMCost,
|
||||||
|
totalNetProfit,
|
||||||
|
netProfitMargin,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,509 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import prisma from "../lib/prisma";
|
||||||
|
|
||||||
|
export const toolsRoute = new Elysia({ prefix: "/tools" })
|
||||||
|
|
||||||
|
// ── 1. GET Master Product Variants (For mapping dropdown/picker) ────────────
|
||||||
|
.get("/marketplace-products/master-variants", async () => {
|
||||||
|
const variants = await prisma.productVariant.findMany({
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
category: { select: { name: true } },
|
||||||
|
brand: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bomItems: {
|
||||||
|
include: {
|
||||||
|
rawMaterial: {
|
||||||
|
select: { pricePerUnit: true, name: true, unit: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ product: { name: "asc" } },
|
||||||
|
{ sku: "asc" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = variants.map((v) => {
|
||||||
|
// Calculate unit BOM cost
|
||||||
|
const bomCost = (v.bomItems || []).reduce((sum, b) => {
|
||||||
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const optionsStr = [v.option1, v.option2, v.option3].filter(Boolean).join(" / ");
|
||||||
|
const displayName = `${v.product?.name || "Unnamed Product"} ${optionsStr ? `(${optionsStr})` : ""} ${v.sku ? `[${v.sku}]` : ""}`.trim();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: v.id,
|
||||||
|
sku: v.sku || "",
|
||||||
|
barcode: v.barcode || "",
|
||||||
|
price: v.price || 0,
|
||||||
|
stock: v.stock || 0,
|
||||||
|
optionsStr,
|
||||||
|
productName: v.product?.name || "",
|
||||||
|
categoryName: v.product?.category?.name || "",
|
||||||
|
brandName: v.product?.brand?.name || "",
|
||||||
|
bomCost,
|
||||||
|
displayName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { variants: result };
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 2. POST Sync & Extract Marketplace Products ──────────────────────────────
|
||||||
|
.post("/marketplace-products/sync", async () => {
|
||||||
|
// A. Fetch all existing variants for auto-matching
|
||||||
|
const masterVariants = await prisma.productVariant.findMany({
|
||||||
|
select: { id: true, sku: true, barcode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skuToVariantId = new Map<string, number>();
|
||||||
|
for (const v of masterVariants) {
|
||||||
|
if (v.sku?.trim()) skuToVariantId.set(v.sku.trim().toLowerCase(), v.id);
|
||||||
|
if (v.barcode?.trim()) skuToVariantId.set(v.barcode.trim().toLowerCase(), v.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing marketplace products map to preserve manual mappings
|
||||||
|
const existingMp = await prisma.marketplaceProduct.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
channel: true,
|
||||||
|
shop: true,
|
||||||
|
productName: true,
|
||||||
|
variation: true,
|
||||||
|
marketplaceSku: true,
|
||||||
|
masterVariantId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingMap = new Map<string, { id: number; masterVariantId: number | null }>();
|
||||||
|
for (const ep of existingMp) {
|
||||||
|
const key = `${ep.channel}:::${ep.shop}:::${ep.productName}:::${ep.variation}:::${ep.marketplaceSku}`;
|
||||||
|
existingMap.set(key, { id: ep.id, masterVariantId: ep.masterVariantId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory aggregation map
|
||||||
|
interface AggregatedItem {
|
||||||
|
channel: string;
|
||||||
|
shop: string;
|
||||||
|
marketplaceSku: string;
|
||||||
|
productName: string;
|
||||||
|
variation: string;
|
||||||
|
totalSoldQty: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
firstSoldAt: string | null;
|
||||||
|
lastSoldAt: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggregated = new Map<string, AggregatedItem>();
|
||||||
|
|
||||||
|
const updateAgg = (
|
||||||
|
channel: string,
|
||||||
|
shop: string,
|
||||||
|
sku: string,
|
||||||
|
name: string,
|
||||||
|
variation: string,
|
||||||
|
qty: number,
|
||||||
|
rev: number,
|
||||||
|
time: string | null
|
||||||
|
) => {
|
||||||
|
const cleanSku = (sku || "").trim();
|
||||||
|
const cleanName = (name || "").trim() || "ไม่ระบุชื่อสินค้า";
|
||||||
|
const cleanVar = (variation || "").trim();
|
||||||
|
const cleanShop = (shop || "").trim() || "hom-aroma";
|
||||||
|
|
||||||
|
const key = `${channel}:::${cleanShop}:::${cleanName}:::${cleanVar}:::${cleanSku}`;
|
||||||
|
let item = aggregated.get(key);
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
item = {
|
||||||
|
channel,
|
||||||
|
shop: cleanShop,
|
||||||
|
marketplaceSku: cleanSku,
|
||||||
|
productName: cleanName,
|
||||||
|
variation: cleanVar,
|
||||||
|
totalSoldQty: 0,
|
||||||
|
totalRevenue: 0,
|
||||||
|
firstSoldAt: time,
|
||||||
|
lastSoldAt: time,
|
||||||
|
};
|
||||||
|
aggregated.set(key, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
item.totalSoldQty += qty;
|
||||||
|
item.totalRevenue += rev;
|
||||||
|
|
||||||
|
if (time) {
|
||||||
|
if (!item.firstSoldAt || time < item.firstSoldAt) item.firstSoldAt = time;
|
||||||
|
if (!item.lastSoldAt || time > item.lastSoldAt) item.lastSoldAt = time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Scan TikTok Order Items
|
||||||
|
const tiktokItems = await prisma.tiktokOrderItem.findMany({
|
||||||
|
include: {
|
||||||
|
order: { select: { shop: true, createdTime: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const it of tiktokItems) {
|
||||||
|
const shop = it.order?.shop || "hom-aroma";
|
||||||
|
const sku = it.sellerSku || it.skuId || "";
|
||||||
|
const rev = it.subtotalAfterDiscount > 0 ? it.subtotalAfterDiscount : (it.unitOriginalPrice * (it.quantity || 1));
|
||||||
|
updateAgg("tiktok", shop, sku, it.productName, it.variation || "", it.quantity || 1, rev, it.order?.createdTime || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Scan Shopee Order Items
|
||||||
|
const shopeeItems = await prisma.shopeeOrderItem.findMany({
|
||||||
|
include: {
|
||||||
|
order: { select: { shop: true, orderCreatedAt: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const it of shopeeItems) {
|
||||||
|
const shop = it.order?.shop || "hom-aroma";
|
||||||
|
const sku = it.skuRef || it.parentSku || "";
|
||||||
|
const rev = it.totalPayment > 0 ? it.totalPayment : (it.dealPrice * (it.quantity || 1));
|
||||||
|
updateAgg("shopee", shop, sku, it.productName, it.variantName || "", it.quantity || 1, rev, it.order?.orderCreatedAt || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Scan Lazada Order Items
|
||||||
|
const lazadaItems = await prisma.lazadaOrderItem.findMany({
|
||||||
|
include: {
|
||||||
|
order: { select: { shop: true, createTime: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const it of lazadaItems) {
|
||||||
|
const shop = it.order?.shop || "hom-aroma";
|
||||||
|
const sku = it.sellerSku || it.lazadaSku || "";
|
||||||
|
const rev = it.paidPrice > 0 ? it.paidPrice : it.unitPrice;
|
||||||
|
updateAgg("lazada", shop, sku, it.itemName, it.variation || "", 1, rev, it.order?.createTime || null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert into Database
|
||||||
|
let syncedCount = 0;
|
||||||
|
let matchedCount = 0;
|
||||||
|
|
||||||
|
const itemsToProcess = Array.from(aggregated.values());
|
||||||
|
const batchSize = 100;
|
||||||
|
|
||||||
|
for (let i = 0; i < itemsToProcess.length; i += batchSize) {
|
||||||
|
const chunk = itemsToProcess.slice(i, i + batchSize);
|
||||||
|
await prisma.$transaction(
|
||||||
|
chunk.map((item) => {
|
||||||
|
const key = `${item.channel}:::${item.shop}:::${item.productName}:::${item.variation}:::${item.marketplaceSku}`;
|
||||||
|
const existing = existingMap.get(key);
|
||||||
|
|
||||||
|
// Determine masterVariantId
|
||||||
|
let masterVariantId = existing?.masterVariantId || null;
|
||||||
|
|
||||||
|
// If unmapped, try auto-matching by SKU
|
||||||
|
if (!masterVariantId && item.marketplaceSku) {
|
||||||
|
const normalized = item.marketplaceSku.trim().toLowerCase();
|
||||||
|
const autoMatchId = skuToVariantId.get(normalized);
|
||||||
|
if (autoMatchId) {
|
||||||
|
masterVariantId = autoMatchId;
|
||||||
|
matchedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgPrice = item.totalSoldQty > 0 ? item.totalRevenue / item.totalSoldQty : 0;
|
||||||
|
|
||||||
|
return prisma.marketplaceProduct.upsert({
|
||||||
|
where: {
|
||||||
|
channel_shop_productName_variation_marketplaceSku: {
|
||||||
|
channel: item.channel,
|
||||||
|
shop: item.shop,
|
||||||
|
productName: item.productName,
|
||||||
|
variation: item.variation,
|
||||||
|
marketplaceSku: item.marketplaceSku,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
channel: item.channel,
|
||||||
|
shop: item.shop,
|
||||||
|
marketplaceSku: item.marketplaceSku,
|
||||||
|
productName: item.productName,
|
||||||
|
variation: item.variation,
|
||||||
|
avgPrice,
|
||||||
|
totalSoldQty: item.totalSoldQty,
|
||||||
|
totalRevenue: item.totalRevenue,
|
||||||
|
firstSoldAt: item.firstSoldAt,
|
||||||
|
lastSoldAt: item.lastSoldAt,
|
||||||
|
masterVariantId,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
avgPrice,
|
||||||
|
totalSoldQty: item.totalSoldQty,
|
||||||
|
totalRevenue: item.totalRevenue,
|
||||||
|
firstSoldAt: item.firstSoldAt,
|
||||||
|
lastSoldAt: item.lastSoldAt,
|
||||||
|
// If already mapped manually, preserve it; otherwise use newly matched
|
||||||
|
...(masterVariantId ? { masterVariantId } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
syncedCount += chunk.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
syncedCount,
|
||||||
|
matchedCount,
|
||||||
|
totalMarketplaceProducts: itemsToProcess.length,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 3. GET List of Marketplace Products with Profitability ──────────────────
|
||||||
|
.get("/marketplace-products", async ({ query }) => {
|
||||||
|
const channel = (query.channel as string) || "all";
|
||||||
|
const shop = (query.shop as string) || "all";
|
||||||
|
const status = (query.status as string) || "all"; // 'all' | 'mapped' | 'unmapped'
|
||||||
|
const search = (query.search as string || "").trim();
|
||||||
|
const sortBy = (query.sortBy as string) || "revenue"; // 'revenue' | 'sold' | 'name' | 'recent'
|
||||||
|
const page = parseInt(query.page as string || "1", 10);
|
||||||
|
const limit = parseInt(query.limit as string || "50", 10);
|
||||||
|
|
||||||
|
// Build Where Clause
|
||||||
|
const where: any = {};
|
||||||
|
|
||||||
|
if (channel !== "all") where.channel = channel;
|
||||||
|
if (shop !== "all") where.shop = shop;
|
||||||
|
if (status === "mapped") where.masterVariantId = { not: null };
|
||||||
|
if (status === "unmapped") where.masterVariantId = null;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ productName: { contains: search, mode: "insensitive" } },
|
||||||
|
{ marketplaceSku: { contains: search, mode: "insensitive" } },
|
||||||
|
{ variation: { contains: search, mode: "insensitive" } },
|
||||||
|
{ masterVariant: { sku: { contains: search, mode: "insensitive" } } },
|
||||||
|
{ masterVariant: { product: { name: { contains: search, mode: "insensitive" } } } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build OrderBy Clause
|
||||||
|
let orderBy: any = [{ totalRevenue: "desc" }];
|
||||||
|
if (sortBy === "sold") orderBy = [{ totalSoldQty: "desc" }];
|
||||||
|
if (sortBy === "name") orderBy = [{ productName: "asc" }];
|
||||||
|
if (sortBy === "recent") orderBy = [{ lastSoldAt: "desc" }];
|
||||||
|
|
||||||
|
// Fetch Global KPIs across all items (without pagination limits)
|
||||||
|
const allItems = await prisma.marketplaceProduct.findMany({
|
||||||
|
where: channel !== "all" || shop !== "all" ? { ...(channel !== "all" ? { channel } : {}), ...(shop !== "all" ? { shop } : {}) } : {},
|
||||||
|
include: {
|
||||||
|
masterVariant: {
|
||||||
|
include: {
|
||||||
|
bomItems: {
|
||||||
|
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let totalSkus = allItems.length;
|
||||||
|
let mappedSkus = 0;
|
||||||
|
let totalSoldQty = 0;
|
||||||
|
let totalRevenue = 0;
|
||||||
|
let totalEstCost = 0;
|
||||||
|
let totalEstProfit = 0;
|
||||||
|
|
||||||
|
for (const item of allItems) {
|
||||||
|
totalSoldQty += item.totalSoldQty;
|
||||||
|
totalRevenue += item.totalRevenue;
|
||||||
|
|
||||||
|
if (item.masterVariantId && item.masterVariant) {
|
||||||
|
mappedSkus++;
|
||||||
|
const unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
||||||
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const cost = unitBomCost * item.totalSoldQty;
|
||||||
|
totalEstCost += cost;
|
||||||
|
totalEstProfit += (item.totalRevenue - cost);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmappedSkus = totalSkus - mappedSkus;
|
||||||
|
const mappedPercent = totalSkus > 0 ? (mappedSkus / totalSkus) * 100 : 0;
|
||||||
|
const grossProfitMargin = totalRevenue > 0 ? (totalEstProfit / totalRevenue) * 100 : 0;
|
||||||
|
|
||||||
|
// Fetch Paginated Items
|
||||||
|
const [items, totalCount] = await Promise.all([
|
||||||
|
prisma.marketplaceProduct.findMany({
|
||||||
|
where,
|
||||||
|
orderBy,
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
include: {
|
||||||
|
masterVariant: {
|
||||||
|
include: {
|
||||||
|
product: {
|
||||||
|
select: { id: true, name: true, category: { select: { name: true } } },
|
||||||
|
},
|
||||||
|
bomItems: {
|
||||||
|
include: {
|
||||||
|
rawMaterial: { select: { name: true, pricePerUnit: true, unit: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.marketplaceProduct.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Format output with profitability details
|
||||||
|
const formattedItems = items.map((item) => {
|
||||||
|
let unitBomCost = 0;
|
||||||
|
let totalCost = 0;
|
||||||
|
let profit = 0;
|
||||||
|
let marginPercent = 0;
|
||||||
|
|
||||||
|
if (item.masterVariant) {
|
||||||
|
unitBomCost = (item.masterVariant.bomItems || []).reduce((sum, b) => {
|
||||||
|
return sum + (b.quantity * (b.rawMaterial?.pricePerUnit || 0));
|
||||||
|
}, 0);
|
||||||
|
totalCost = unitBomCost * item.totalSoldQty;
|
||||||
|
profit = item.totalRevenue - totalCost;
|
||||||
|
marginPercent = item.totalRevenue > 0 ? (profit / item.totalRevenue) * 100 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
channel: item.channel,
|
||||||
|
shop: item.shop,
|
||||||
|
marketplaceSku: item.marketplaceSku,
|
||||||
|
productName: item.productName,
|
||||||
|
variation: item.variation,
|
||||||
|
avgPrice: item.avgPrice,
|
||||||
|
totalSoldQty: item.totalSoldQty,
|
||||||
|
totalRevenue: item.totalRevenue,
|
||||||
|
firstSoldAt: item.firstSoldAt,
|
||||||
|
lastSoldAt: item.lastSoldAt,
|
||||||
|
masterVariantId: item.masterVariantId,
|
||||||
|
masterVariant: item.masterVariant
|
||||||
|
? {
|
||||||
|
id: item.masterVariant.id,
|
||||||
|
sku: item.masterVariant.sku,
|
||||||
|
barcode: item.masterVariant.barcode,
|
||||||
|
productName: item.masterVariant.product?.name,
|
||||||
|
categoryName: item.masterVariant.product?.category?.name,
|
||||||
|
option1: item.masterVariant.option1,
|
||||||
|
option2: item.masterVariant.option2,
|
||||||
|
option3: item.masterVariant.option3,
|
||||||
|
unitBomCost,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
profitability: item.masterVariant
|
||||||
|
? {
|
||||||
|
unitBomCost,
|
||||||
|
totalCost,
|
||||||
|
profit,
|
||||||
|
marginPercent,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
kpi: {
|
||||||
|
totalSkus,
|
||||||
|
mappedSkus,
|
||||||
|
unmappedSkus,
|
||||||
|
mappedPercent,
|
||||||
|
totalSoldQty,
|
||||||
|
totalRevenue,
|
||||||
|
totalEstCost,
|
||||||
|
totalEstProfit,
|
||||||
|
grossProfitMargin,
|
||||||
|
},
|
||||||
|
pagination: {
|
||||||
|
total: totalCount,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalCount / limit) || 1,
|
||||||
|
},
|
||||||
|
items: formattedItems,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── 4. POST Link / Unlink Master Variant ─────────────────────────────────────
|
||||||
|
.post(
|
||||||
|
"/marketplace-products/map",
|
||||||
|
async ({ body }: { body: { marketplaceProductId: number; masterVariantId: number | null } }) => {
|
||||||
|
const { marketplaceProductId, masterVariantId } = body;
|
||||||
|
|
||||||
|
const updated = await prisma.marketplaceProduct.update({
|
||||||
|
where: { id: marketplaceProductId },
|
||||||
|
data: { masterVariantId },
|
||||||
|
include: {
|
||||||
|
masterVariant: {
|
||||||
|
include: {
|
||||||
|
product: { select: { id: true, name: true } },
|
||||||
|
bomItems: {
|
||||||
|
include: { rawMaterial: { select: { pricePerUnit: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, item: updated };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
marketplaceProductId: t.Number(),
|
||||||
|
masterVariantId: t.Nullable(t.Number()),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── 5. POST Bulk Auto Match Unmapped Items ──────────────────────────────────
|
||||||
|
.post("/marketplace-products/bulk-auto-match", async () => {
|
||||||
|
const masterVariants = await prisma.productVariant.findMany({
|
||||||
|
select: { id: true, sku: true, barcode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const skuMap = new Map<string, number>();
|
||||||
|
for (const v of masterVariants) {
|
||||||
|
if (v.sku?.trim()) skuMap.set(v.sku.trim().toLowerCase(), v.id);
|
||||||
|
if (v.barcode?.trim()) skuMap.set(v.barcode.trim().toLowerCase(), v.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmapped = await prisma.marketplaceProduct.findMany({
|
||||||
|
where: { masterVariantId: null, marketplaceSku: { not: "" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
let matched = 0;
|
||||||
|
for (const item of unmapped) {
|
||||||
|
const norm = item.marketplaceSku.trim().toLowerCase();
|
||||||
|
const variantId = skuMap.get(norm);
|
||||||
|
if (variantId) {
|
||||||
|
await prisma.marketplaceProduct.update({
|
||||||
|
where: { id: item.id },
|
||||||
|
data: { masterVariantId: variantId },
|
||||||
|
});
|
||||||
|
matched++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingUnmapped = await prisma.marketplaceProduct.count({
|
||||||
|
where: { masterVariantId: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
matched,
|
||||||
|
remainingUnmapped,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -144,3 +144,110 @@ export function cleanDistrictName(input: string | null | undefined): string {
|
|||||||
.replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '')
|
.replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '')
|
||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const POSTCODE_EXCEPTIONS: Record<string, string> = {
|
||||||
|
'10540': 'สมุทรปราการ',
|
||||||
|
'10270': 'สมุทรปราการ',
|
||||||
|
'10280': 'สมุทรปราการ',
|
||||||
|
'10560': 'สมุทรปราการ',
|
||||||
|
'13170': 'พระนครศรีอยุธยา',
|
||||||
|
'13180': 'พระนครศรีอยุธยา',
|
||||||
|
};
|
||||||
|
|
||||||
|
const POSTCODE_PREFIX_TO_PROVINCE_NAME: Record<string, string> = {
|
||||||
|
'10': 'กรุงเทพมหานคร',
|
||||||
|
'11': 'นนทบุรี',
|
||||||
|
'12': 'ปทุมธานี',
|
||||||
|
'13': 'พระนครศรีอยุธยา',
|
||||||
|
'14': 'อ่างทอง',
|
||||||
|
'15': 'ลพบุรี',
|
||||||
|
'16': 'สิงห์บุรี',
|
||||||
|
'17': 'ชัยนาท',
|
||||||
|
'18': 'สระบุรี',
|
||||||
|
'19': 'สระบุรี',
|
||||||
|
'20': 'ชลบุรี',
|
||||||
|
'21': 'ระยอง',
|
||||||
|
'22': 'จันทบุรี',
|
||||||
|
'23': 'ตราด',
|
||||||
|
'24': 'ฉะเชิงเทรา',
|
||||||
|
'25': 'ปราจีนบุรี',
|
||||||
|
'26': 'นครนายก',
|
||||||
|
'27': 'สระแก้ว',
|
||||||
|
'30': 'นครราชสีมา',
|
||||||
|
'31': 'บุรีรัมย์',
|
||||||
|
'32': 'สุรินทร์',
|
||||||
|
'33': 'ศรีสะเกษ',
|
||||||
|
'34': 'อุบลราชธานี',
|
||||||
|
'35': 'ยโสธร',
|
||||||
|
'36': 'ชัยภูมิ',
|
||||||
|
'37': 'อำนาจเจริญ',
|
||||||
|
'38': 'บึงกาฬ',
|
||||||
|
'39': 'หนองบัวลำภู',
|
||||||
|
'40': 'ขอนแก่น',
|
||||||
|
'41': 'อุดรธานี',
|
||||||
|
'42': 'เลย',
|
||||||
|
'43': 'หนองคาย',
|
||||||
|
'44': 'มหาสารคาม',
|
||||||
|
'45': 'ร้อยเอ็ด',
|
||||||
|
'46': 'กาฬสินธุ์',
|
||||||
|
'47': 'สกลนคร',
|
||||||
|
'48': 'นครพนม',
|
||||||
|
'49': 'มุกดาหาร',
|
||||||
|
'50': 'เชียงใหม่',
|
||||||
|
'51': 'ลำพูน',
|
||||||
|
'52': 'ลำปาง',
|
||||||
|
'53': 'อุตรดิตถ์',
|
||||||
|
'54': 'แพร่',
|
||||||
|
'55': 'น่าน',
|
||||||
|
'56': 'พะเยา',
|
||||||
|
'57': 'เชียงราย',
|
||||||
|
'58': 'แม่ฮ่องสอน',
|
||||||
|
'60': 'นครสวรรค์',
|
||||||
|
'61': 'อุทัยธานี',
|
||||||
|
'62': 'กำแพงเพชร',
|
||||||
|
'63': 'ตาก',
|
||||||
|
'64': 'สุโขทัย',
|
||||||
|
'65': 'พิษณุโลก',
|
||||||
|
'66': 'พิจิตร',
|
||||||
|
'67': 'เพชรบูรณ์',
|
||||||
|
'70': 'ราชบุรี',
|
||||||
|
'71': 'กาญจนบุรี',
|
||||||
|
'72': 'สุพรรณบุรี',
|
||||||
|
'73': 'นครปฐม',
|
||||||
|
'74': 'สมุทรสาคร',
|
||||||
|
'75': 'สมุทรสงคราม',
|
||||||
|
'76': 'เพชรบุรี',
|
||||||
|
'77': 'ประจวบคีรีขันธ์',
|
||||||
|
'80': 'นครศรีธรรมราช',
|
||||||
|
'81': 'กระบี่',
|
||||||
|
'82': 'พังงา',
|
||||||
|
'83': 'ภูเก็ต',
|
||||||
|
'84': 'สุราษฎร์ธานี',
|
||||||
|
'85': 'ระนอง',
|
||||||
|
'86': 'ชุมพร',
|
||||||
|
'90': 'สงขลา',
|
||||||
|
'91': 'สตูล',
|
||||||
|
'92': 'ตรัง',
|
||||||
|
'93': 'พัทลุง',
|
||||||
|
'94': 'ปัตตานี',
|
||||||
|
'95': 'ยะลา',
|
||||||
|
'96': 'นราธิวาส',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function findProvinceByPostcode(postcode: string | null | undefined): ProvinceMeta | null {
|
||||||
|
if (!postcode) return null;
|
||||||
|
const clean = String(postcode).replace(/\D/g, '').trim();
|
||||||
|
if (clean.length < 2) return null;
|
||||||
|
|
||||||
|
if (POSTCODE_EXCEPTIONS[clean]) {
|
||||||
|
const provName = POSTCODE_EXCEPTIONS[clean];
|
||||||
|
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefix = clean.slice(0, 2);
|
||||||
|
const provName = POSTCODE_PREFIX_TO_PROVINCE_NAME[prefix];
|
||||||
|
if (provName) {
|
||||||
|
return THAILAND_PROVINCES.find(p => p.nameTh === provName) || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in new issue
Block a user