This commit is contained in:
Your Name committed 2026-09-05 05:33:58 +07:00
1 parent 037ee775db
commit abee5249ca
14 files changed
+128241 -2

No files matched your search

+4 -1
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin</title>
<title>HomAroma Admin</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Sarabun:ital,wght@0,300;0,400;0,500;0,600;0,700;0,800;1,400;1,600&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
+2
View File
@@ -14,6 +14,7 @@
"@tailwindcss/vite": "^4.2.2",
"@types/papaparse": "^5.5.2",
"@uiw/react-md-editor": "^4.1.1",
"d3-geo": "^3.1.1",
"lucide-react": "^1.7.0",
"papaparse": "^5.5.3",
"react": "^19.2.4",
@@ -26,6 +27,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/d3-geo": "^3.1.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+2
View File
@@ -35,6 +35,7 @@ import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage'
import ShopeeAnalyticsPage from './pages/ShopeeAnalyticsPage'
import ShopeeOverviewPage from './pages/ShopeeOverviewPage'
import TiktokOrderPage from './pages/TiktokOrderPage'
import TiktokSalesMapPage from './pages/TiktokSalesMapPage'
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
import TiktokOverviewPage from './pages/TiktokOverviewPage'
import LazadaOrderPage from './pages/LazadaOrderPage'
@@ -105,6 +106,7 @@ function App() {
<Route path="/shopee-analytics" element={<ShopeeAnalyticsPage />} />
<Route path="/shopee-overview" element={<ShopeeOverviewPage />} />
<Route path="/tiktok-order" element={<TiktokOrderPage />} />
<Route path="/tiktok-sales-map" element={<TiktokSalesMapPage />} />
<Route path="/tiktok-analytics" element={<TiktokAnalyticsPage />} />
<Route path="/tiktok-overview" element={<TiktokOverviewPage />} />
<Route path="/lazada-order" element={<LazadaOrderPage />} />
+2
View File
@@ -33,6 +33,7 @@ import {
DatabaseBackup,
FileJson,
FolderArchive,
Map,
} from 'lucide-react'
const MAIN_MENU = [
@@ -55,6 +56,7 @@ const SALES_MENU = [
const TIKTOK_MENU = [
{ icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' },
{ icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' },
{ icon: BarChart3, label: 'TikTok Analytics', to: '/tiktok-analytics' },
{ icon: Layers, label: 'TikTok ทุกร้าน', to: '/tiktok-overview' },
{ icon: Calculator, label: 'TikTok Price Simulator', to: '/tiktok-price-simulator' },
@@ -0,0 +1,480 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo';
import { X, Search, Package, ShoppingBag, RotateCcw, MapPin, Tag } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types';
interface DistrictMapModalProps {
provinceName: string;
shop: string;
dateFrom?: string;
dateTo?: string;
onClose: () => void;
}
export default function DistrictMapModal({
provinceName,
shop,
dateFrom,
dateTo,
onClose,
}: DistrictMapModalProps) {
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 [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 (shop && shop !== 'all') params.set('shop', shop);
if (dateFrom) params.set('dateFrom', dateFrom);
if (dateTo) params.set('dateTo', dateTo);
fetch(`${API_BASE}/tiktok-order/sales-map/district?${params.toString()}`)
.then((r) => r.json())
.then((data) => setDistrictData(data))
.catch((err) => console.error('Failed to load district data:', err))
.finally(() => setLoadingData(false));
}, [provinceName, shop, dateFrom, dateTo]);
// Filter district features matching the current province
const provinceFeatures = useMemo(() => {
if (!districtGeo || !districtGeo.features) return [];
const cleanProv = provinceName.replace(/[\s\-_.,()จ\.\/\\]/g, '').toLowerCase();
return districtGeo.features.filter((f: any) => {
const pTh = (f.properties.pro_th || '').replace(/[\s\-_.,()จ\.\/\\]/g, '').toLowerCase();
const pEn = (f.properties.pro_en || '').replace(/[\s\-_.,()จ\.\/\\]/g, '').toLowerCase();
return pTh.includes(cleanProv) || cleanProv.includes(pTh) || pEn.includes(cleanProv) || cleanProv.includes(pEn);
});
}, [districtGeo, provinceName]);
// District sales lookup map
const districtSalesMap = useMemo(() => {
const map = new Map<string, { revenue: number; orders: number }>();
if (districtData?.districts) {
for (const d of districtData.districts) {
map.set(d.districtName, { revenue: d.revenue, orders: d.orders });
}
}
return map;
}, [districtData]);
// Max revenue among districts for color scaling
const maxDistrictRevenue = useMemo(() => {
let max = 0;
for (const v of districtSalesMap.values()) {
if (v.revenue > max) max = v.revenue;
}
return max || 1;
}, [districtSalesMap]);
// Compute D3 paths for filtered districts
const paths = useMemo(() => {
if (provinceFeatures.length === 0) return [];
const featureCollection = { type: 'FeatureCollection', features: provinceFeatures };
const proj = d3Geo.geoMercator().fitExtent(
[
[20, 20],
[width - 20, height - 20],
],
featureCollection as any
);
const pathGen = d3Geo.geoPath().projection(proj);
return provinceFeatures.map((f: any) => {
const ampTh = f.properties.amp_th;
const d = pathGen(f);
return { feature: f, ampTh, d };
});
}, [provinceFeatures]);
// Toggle district selection
const handleToggleDistrict = (ampName: string) => {
setSelectedDistricts((prev) => {
const next = new Set(prev);
if (next.has(ampName)) {
next.delete(ampName);
} else {
next.add(ampName);
}
return next;
});
};
const handleClearSelected = () => {
setSelectedDistricts(new Set());
};
// Filter orders based on selected districts & search query
const filteredOrders = useMemo(() => {
if (!districtData?.orders) return [];
let list = districtData.orders;
if (selectedDistricts.size > 0) {
list = list.filter((o: any) => {
const dName = o.district || '';
return Array.from(selectedDistricts).some(
(sel) => dName.includes(sel) || sel.includes(dName)
);
});
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim();
list = list.filter((o: any) => {
return (
o.orderId.toLowerCase().includes(q) ||
(o.buyerUsername && o.buyerUsername.toLowerCase().includes(q)) ||
(o.district && o.district.toLowerCase().includes(q)) ||
o.items?.some(
(it: any) =>
it.productName?.toLowerCase().includes(q) ||
it.variation?.toLowerCase().includes(q) ||
it.sellerSku?.toLowerCase().includes(q)
)
);
});
}
return list;
}, [districtData, selectedDistricts, searchQuery]);
// Calculate selected districts revenue
const selectedDistrictsRevenue = useMemo(() => {
if (selectedDistricts.size === 0) return 0;
let sum = 0;
for (const d of selectedDistricts) {
sum += districtSalesMap.get(d)?.revenue || 0;
}
return sum;
}, [selectedDistricts, districtSalesMap]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 md:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="bg-slate-900 border border-white/10 rounded-3xl w-full max-w-7xl max-h-[92vh] flex flex-col shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
<div>
<div className="flex items-center gap-2">
<span className="text-xs text-emerald-400 font-bold uppercase tracking-wider">
จังหวัดที่เลือก • {districtData?.province?.regionTh || 'ภาคอีสาน'}
</span>
</div>
<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>
</h2>
<p className="text-xs text-slate-400 mt-0.5">
คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก)
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 3-Column Content Body */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 p-6 overflow-y-auto flex-1 bg-slate-950/40">
{/* Column 1: District SVG Map (5 Cols) */}
<div className="lg:col-span-5 flex flex-col bg-slate-900/60 rounded-2xl border border-white/10 p-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-white">
แผนที่อำเภอ ({provinceFeatures.length} อำเภอ)
</span>
</div>
<span className="text-[11px] text-slate-400">คลิกเพื่อเลือกหลายพื้นที่</span>
</div>
<div
ref={containerRef}
className="relative flex-1 min-h-[360px] bg-slate-950/80 rounded-xl border border-white/5 flex items-center justify-center overflow-hidden p-2 select-none"
onMouseMove={(e) => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setTooltipPos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
}
}}
onMouseLeave={() => setHoveredDistrict(null)}
>
{loadingMap || loadingData ? (
<div className="flex flex-col items-center gap-2 text-slate-500 animate-pulse">
<div className="w-6 h-6 border-2 border-emerald-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">กำลังเตรียมแผนที่อำเภอ...</span>
</div>
) : provinceFeatures.length === 0 ? (
<div className="text-xs text-slate-500 text-center px-4">
ไม่พบข้อมูลพิกัดอำเภอของจังหวัด {provinceName}
</div>
) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px]">
{paths.map(({ feature, ampTh, d }: any) => {
if (!d) return null;
const isSelected = selectedDistricts.has(ampTh);
const isHovered = hoveredDistrict?.properties?.amp_th === ampTh;
const stats = districtSalesMap.get(ampTh) || { revenue: 0, orders: 0 };
const fillColor = getDistrictColor(stats.revenue, maxDistrictRevenue, isSelected);
return (
<path
key={feature.properties.amp_code || ampTh}
d={d}
fill={fillColor}
stroke={isSelected ? '#ffffff' : isHovered ? '#34d399' : '#0f172a'}
strokeWidth={isSelected ? 2 : isHovered ? 1.5 : 0.8}
className="transition-all duration-150 cursor-pointer"
onMouseEnter={() => setHoveredDistrict(feature)}
onClick={() => handleToggleDistrict(ampTh)}
/>
);
})}
</svg>
)}
{/* District Hover Tooltip */}
{hoveredDistrict && tooltipPos && (
<div
className="absolute pointer-events-none z-30 bg-slate-900/95 backdrop-blur-md border border-emerald-500/30 rounded-xl px-3 py-2 shadow-2xl text-xs flex flex-col gap-0.5"
style={{
left: `${Math.min(tooltipPos.x + 10, (containerRef.current?.clientWidth || 300) - 150)}px`,
top: `${Math.min(tooltipPos.y - 10, (containerRef.current?.clientHeight || 300) - 70)}px`,
}}
>
<span className="font-bold text-white">
อ.{hoveredDistrict.properties.amp_th}
</span>
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-slate-400">ยอดขาย:</span>
<span className="font-bold text-emerald-400 font-mono">
{fmtBaht(districtSalesMap.get(hoveredDistrict.properties.amp_th)?.revenue || 0)}
</span>
</div>
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-slate-400">คำสั่งซื้อ:</span>
<span className="font-bold text-white font-mono">
{districtSalesMap.get(hoveredDistrict.properties.amp_th)?.orders || 0} ออเดอร์
</span>
</div>
</div>
)}
</div>
{/* Bottom Scale & Attribution */}
<div className="mt-3 flex items-center justify-between text-[10px] text-slate-500">
<div className="flex items-center gap-2">
<span>ยอดน้อย</span>
<div className="w-20 h-2 rounded bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-400" />
<span>ยอดมาก</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-orange-500 inline-block" />
<span className="text-orange-400 font-medium">เลือกแล้ว</span>
</div>
</div>
<p className="text-[9px] text-slate-600 text-center mt-2 font-mono">
ขอบเขต ADM2: mapthai / OCHA CODs - CC BY 4.0
</p>
</div>
{/* Column 2: Selected Districts Summary (3 Cols) */}
<div className="lg:col-span-3 flex flex-col bg-slate-900/60 rounded-2xl border border-white/10 p-4">
<div className="flex items-center justify-between pb-3 border-b border-white/10">
<div>
<p className="text-sm font-bold text-white">
{selectedDistricts.size} อำเภอที่เลือก
</p>
<p className="text-[11px] text-slate-400">แตะพื้นที่เพื่อดูข้อมูล</p>
</div>
{selectedDistricts.size > 0 && (
<button
onClick={handleClearSelected}
className="flex items-center gap-1 text-xs text-rose-400 hover:text-rose-300 transition-colors bg-rose-500/10 px-2.5 py-1 rounded-lg border border-rose-500/20"
>
<RotateCcw className="w-3 h-3" /> ล้าง
</button>
)}
</div>
<div className="flex-1 overflow-y-auto py-3 space-y-2 max-h-[380px]">
{selectedDistricts.size === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-6 text-slate-500 text-xs gap-2">
<Tag className="w-6 h-6 opacity-30 text-emerald-400" />
<p>ยังไม่ได้เลือกอำเภอ</p>
<p className="text-[11px] text-slate-600">คลิกบนแผนที่เพื่อกรองข้อมูลเฉพาะอำเภอ</p>
</div>
) : (
Array.from(selectedDistricts).map((dist) => {
const st = districtSalesMap.get(dist) || { revenue: 0, orders: 0 };
return (
<div
key={dist}
className="flex items-center justify-between p-2.5 rounded-xl bg-slate-800/80 border border-orange-500/30 text-xs"
>
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-orange-500" />
<span className="font-bold text-white">อ.{dist}</span>
<span className="text-[10px] text-slate-400 font-mono">
({st.orders} ออเดอร์)
</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-orange-400">
{fmtBaht(st.revenue)}
</span>
<button
onClick={() => handleToggleDistrict(dist)}
className="text-slate-500 hover:text-rose-400 transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})
)}
</div>
{selectedDistricts.size > 0 && (
<div className="pt-3 border-t border-white/10 flex items-center justify-between text-xs">
<span className="text-slate-400 font-medium">รวมยอดที่เลือก:</span>
<span className="text-sm font-bold text-orange-400 font-mono">
{fmtBaht(selectedDistrictsRevenue)}
</span>
</div>
)}
</div>
{/* Column 3: Orders List (4 Cols) */}
<div className="lg:col-span-4 flex flex-col bg-slate-900/60 rounded-2xl border border-white/10 p-4">
<div className="flex items-center justify-between pb-3 border-b border-white/10">
<div className="flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-white">
คำสั่งซื้อ TikTok ในพื้นที่ ({filteredOrders.length})
</span>
</div>
{selectedDistricts.size > 0 && (
<span className="text-[10px] bg-orange-500/20 text-orange-400 px-2 py-0.5 rounded-full border border-orange-500/30 font-bold">
กรอง {selectedDistricts.size} อำเภอ
</span>
)}
</div>
{/* Search Box */}
<div className="relative my-3">
<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={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหา Order ID, สินค้า, SKU..."
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-emerald-500/50"
/>
</div>
{/* Orders Scrollable List */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[360px] pr-1">
{filteredOrders.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-center p-8 text-slate-500 text-xs gap-2">
<Package className="w-6 h-6 opacity-30 text-slate-400" />
<p>ไม่พบรายการคำสั่งซื้อในเงื่อนไขที่เลือก</p>
</div>
) : (
filteredOrders.map((o: any) => (
<div
key={o.orderId}
className="p-3 rounded-xl bg-slate-950/60 border border-white/5 hover:border-white/15 transition-all text-xs flex flex-col gap-1.5"
>
<div className="flex items-center justify-between">
<span className="font-mono font-bold text-rose-400 text-[11px]">
#{o.orderId}
</span>
<span className="font-mono font-bold text-emerald-400 text-xs">
{fmtBaht(o.orderAmount)}
</span>
</div>
<div className="text-slate-300 line-clamp-2 text-[11px] leading-relaxed">
{o.items?.[0]?.productName || 'คำสั่งซื้อสินค้า'}
{o.items?.[0]?.variation && (
<span className="text-slate-400 ml-1 font-mono text-[10px]">
({o.items[0].variation})
</span>
)}
</div>
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px] text-slate-400">
<span className="flex items-center gap-1 text-emerald-400/90">
<MapPin className="w-3 h-3" />
อ.{o.district || 'ไม่ระบุ'}
</span>
<span className="font-mono">
{o.createdTime?.slice(0, 16) || ''}
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-4 text-xs">
<span className="text-slate-400">
ยอดรวมทั้งจังหวัด:{' '}
<span className="text-emerald-400 font-bold font-mono">
{fmtBaht(districtData?.summary?.totalRevenue || 0)}
</span>{' '}
({fmtNum(districtData?.summary?.totalOrders || 0)} คำสั่งซื้อ)
</span>
<span className="text-slate-500 hidden sm:inline">•</span>
<span className="text-slate-400 hidden sm:inline">
คำสั่งซื้อทั้งหมดที่ดึงมา:{' '}
<span className="text-white font-bold font-mono">
{fmtNum(districtData?.orders?.length || 0)}
</span>{' '}
รายการ
</span>
</div>
<button
onClick={onClose}
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors"
>
ปิดหน้าต่าง
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,253 @@
import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { fmtBaht, fmtNum, getChoroplethColor } from '../../utils/thaiGeoUtils';
interface ProvinceData {
code: string;
nameTh: string;
nameEn: string;
region: string;
regionTh: string;
revenue: number;
orders: number;
}
interface ThailandMapSvgProps {
provincesData: Record<string, ProvinceData>;
selectedProvince: string | null;
onSelectProvince: (nameTh: string) => void;
}
export default function ThailandMapSvg({
provincesData,
selectedProvince,
onSelectProvince,
}: ThailandMapSvgProps) {
const [geoData, setGeoData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [hoveredFeature, setHoveredFeature] = useState<any>(null);
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null);
// Zoom & Pan state
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const containerRef = useRef<HTMLDivElement>(null);
// Width & height of SVG coordinate system
const width = 500;
const height = 750;
useEffect(() => {
fetch('/maps/thailand-provinces.json')
.then((r) => r.json())
.then((data) => {
setGeoData(data);
})
.catch((err) => console.error('Failed to load Thailand GeoJSON:', err))
.finally(() => setLoading(false));
}, []);
// Compute maximum revenue among provinces for color scaling
const maxRevenue = useMemo(() => {
let max = 0;
for (const p of Object.values(provincesData)) {
if (p.revenue > max) max = p.revenue;
}
return max || 1;
}, [provincesData]);
// D3 Projection and Path Generator
const paths = useMemo(() => {
if (!geoData || !geoData.features) return [];
// Fit projection to the 500x750 viewport with padding
const proj = d3Geo.geoMercator().fitExtent(
[
[20, 20],
[width - 20, height - 20],
],
geoData
);
const pathGen = d3Geo.geoPath().projection(proj);
return geoData.features.map((feature: any) => {
const nameTh = feature.properties.pro_th;
const d = pathGen(feature);
return {
feature,
nameTh,
d,
};
});
}, [geoData]);
// Zoom controls
const handleZoomIn = () => setZoom((z) => Math.min(4, z + 0.3));
const handleZoomOut = () => setZoom((z) => Math.max(0.8, z - 0.3));
const handleResetZoom = () => {
setZoom(1);
setPan({ x: 0, y: 0 });
};
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 0) return;
setIsDragging(true);
dragStartRef.current = { x: e.clientX - pan.x, y: e.clientY - pan.y };
};
const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) {
setPan({
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
});
}
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setTooltipPos({
x: e.clientX - rect.left,
y: e.clientY - rect.top,
});
}
};
const handleMouseUp = () => setIsDragging(false);
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-[520px] bg-slate-900/40 rounded-2xl border border-white/5 animate-pulse text-slate-400">
<div className="w-8 h-8 border-2 border-emerald-400 border-t-transparent rounded-full animate-spin mb-3" />
<p className="text-sm">กำลังโหลดแผนที่ประเทศไทย...</p>
</div>
);
}
return (
<div
ref={containerRef}
className="relative w-full h-[540px] md:h-[620px] bg-gradient-to-b from-slate-950/60 to-slate-900/40 rounded-2xl border border-white/10 overflow-hidden flex items-center justify-center select-none"
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={() => {
setIsDragging(false);
setHoveredFeature(null);
}}
>
{/* Zoom / Pan Controls Overlay */}
<div className="absolute top-4 right-4 z-20 flex flex-col gap-1.5 bg-slate-900/80 backdrop-blur-md p-1.5 rounded-xl border border-white/10 shadow-xl">
<button
onClick={handleZoomIn}
title="ซูมเข้า"
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomOut}
title="ซูมออก"
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors"
>
<ZoomOut className="w-4 h-4" />
</button>
<button
onClick={handleResetZoom}
title="รีเซ็ตมุมมอง"
className="p-2 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-300 hover:text-rose-400 transition-colors"
>
<RotateCcw className="w-4 h-4" />
</button>
</div>
{/* SVG Canvas */}
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-full cursor-grab active:cursor-grabbing transition-transform duration-75"
>
<g
transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}
transform-origin={`${width / 2} ${height / 2}`}
>
{paths.map(({ feature, nameTh, d }: any) => {
if (!d) return null;
const data = provincesData[nameTh] || {
revenue: 0,
orders: 0,
nameTh,
nameEn: feature.properties.pro_en,
};
const isSelected = selectedProvince === nameTh;
const isHovered = hoveredFeature?.properties?.pro_th === nameTh;
const fillColor = isSelected
? '#10b981'
: getChoroplethColor(data.revenue, maxRevenue);
return (
<path
key={feature.properties.pro_code || nameTh}
d={d}
fill={fillColor}
stroke={isSelected ? '#34d399' : isHovered ? '#ffffff' : '#0f172a'}
strokeWidth={isSelected ? 2.5 / zoom : isHovered ? 1.8 / zoom : 0.6 / zoom}
className="transition-all duration-150 cursor-pointer"
style={{
filter: isSelected
? 'drop-shadow(0 0 6px rgba(16, 185, 129, 0.8))'
: isHovered
? 'brightness(1.2)'
: undefined,
}}
onMouseEnter={() => setHoveredFeature(feature)}
onClick={(e) => {
e.stopPropagation();
onSelectProvince(nameTh);
}}
/>
);
})}
</g>
</svg>
{/* Hover Tooltip */}
{hoveredFeature && tooltipPos && (
<div
className="absolute pointer-events-none z-30 bg-slate-900/95 backdrop-blur-md border border-emerald-500/30 rounded-xl px-3.5 py-2.5 shadow-2xl text-xs flex flex-col gap-1 transition-transform duration-75"
style={{
left: `${Math.min(tooltipPos.x + 15, (containerRef.current?.clientWidth || 500) - 170)}px`,
top: `${Math.min(tooltipPos.y - 10, (containerRef.current?.clientHeight || 600) - 90)}px`,
}}
>
<div className="flex items-center justify-between gap-3">
<span className="font-bold text-white text-sm">
{hoveredFeature.properties.pro_th}
</span>
<span className="text-[10px] text-slate-400 font-mono">
{hoveredFeature.properties.pro_en}
</span>
</div>
<div className="text-[10px] text-emerald-400 font-medium">
{provincesData[hoveredFeature.properties.pro_th]?.regionTh || hoveredFeature.properties.reg_royin || 'ไม่ระบุภาค'}
</div>
<div className="h-px bg-white/10 my-0.5" />
<div className="flex items-center justify-between gap-4">
<span className="text-slate-400">ยอดขาย:</span>
<span className="font-bold text-emerald-400 font-mono">
{fmtBaht(provincesData[hoveredFeature.properties.pro_th]?.revenue || 0)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-slate-400">คำสั่งซื้อ:</span>
<span className="font-bold text-white font-mono">
{fmtNum(provincesData[hoveredFeature.properties.pro_th]?.orders || 0)} ออเดอร์
</span>
</div>
</div>
)}
</div>
);
}
+3 -1
View File
@@ -1,6 +1,7 @@
@import "tailwindcss";
@theme {
--font-sans: 'Sarabun', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
/* HomAroma Admin - Professional Dark Palette */
--color-night-950: #050608;
--color-night-900: #0B0C10;
@@ -16,11 +17,12 @@
@layer base {
body {
@apply bg-night-950 text-silver-metallic font-sans antialiased;
font-family: 'Sarabun', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
min-height: 100vh;
}
h1, h2, h3, h4 {
@apply text-white tracking-tight;
@apply text-white tracking-tight font-bold;
}
}
+523
View File
@@ -0,0 +1,523 @@
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 { TIKTOK_SHOPS } from './TiktokOrderPage';
type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
export default function TiktokSalesMapPage() {
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}/tiktok-order/sales-map?${params.toString()}`)
.then((r) => r.json())
.then((data) => setMapData(data))
.catch((err) => console.error('Failed to load 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-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-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">
แผนที่ยอดขายประเทศไทย (Thailand Sales Map)
</h1>
</div>
<p className="text-xs text-slate-400 mt-0.5">
วิเคราะห์การกระจายตัวของยอดขาย TikTok และศักยภาพลูกค้าแยกตาม 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-emerald-500/20 text-emerald-400 border border-emerald-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-emerald-500/20 text-emerald-400 border border-emerald-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-emerald-500/50"
>
<option value="all">ทั้งหมด (ทุกร้าน)</option>
{TIKTOK_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-emerald-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-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-slate-950 font-bold text-xs transition-all shadow-lg shadow-emerald-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-emerald-400 font-bold text-[11px] bg-emerald-500/10 px-1.5 py-0.5 rounded-full border border-emerald-500/20">
↗ YoY
</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">TikTok Shop 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-rose-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">รวมทุกสถานะคำสั่งซื้อ</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-rose-400 font-bold text-[10px] bg-rose-500/10 px-2 py-0.5 rounded-full border border-rose-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">แผนที่ยอดขายประเทศไทย</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-emerald-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-emerald-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-emerald-500 hover:bg-emerald-600 text-slate-950 font-bold text-xs transition-all shadow-lg shadow-emerald-500/20"
>
<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-amber-400" />
<h2 className="text-base font-bold text-white">จังหวัดยอดขายสูง</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-emerald-500/20 border border-emerald-500/40 text-emerald-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-amber-500/20 text-amber-400 border border-amber-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-emerald-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">
ตารางสรุปยอดขายแยกราย 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-emerald-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-emerald-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-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 rounded-lg text-xs font-bold border border-emerald-500/20 transition-colors inline-flex items-center gap-1"
>
<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}
onClose={() => setIsModalOpen(false)}
/>
)}
</div>
);
}
+48
View File
@@ -0,0 +1,48 @@
// Utility for formatting, region mapping and color scale for Thailand Map
export function fmtBaht(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00';
return '฿' + new Intl.NumberFormat('th-TH', {
minimumFractionDigits: 1,
maximumFractionDigits: 2,
}).format(val);
}
export function fmtNum(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '0';
return new Intl.NumberFormat('th-TH').format(val);
}
export function getChoroplethColor(value: number, max: number): string {
if (!value || value <= 0) {
return '#1e293b'; // slate-800 for 0 sales / unmapped
}
if (max <= 0) return '#10b981';
const ratio = Math.min(1, Math.max(0.05, value / max));
// High contrast emerald-teal gradient shades
if (ratio < 0.1) return '#064e3b'; // very low (dark emerald)
if (ratio < 0.25) return '#047857';
if (ratio < 0.45) return '#059669';
if (ratio < 0.65) return '#10b981';
if (ratio < 0.85) return '#34d399';
return '#6ee7b7'; // top revenue (bright emerald)
}
export function getDistrictColor(value: number, max: number, isSelected: boolean): string {
if (isSelected) {
return '#f97316'; // Active orange highlight
}
if (!value || value <= 0) {
return '#1e293b'; // slate-800
}
if (max <= 0) return '#10b981';
const ratio = Math.min(1, Math.max(0.05, value / max));
if (ratio < 0.15) return '#064e3b';
if (ratio < 0.35) return '#047857';
if (ratio < 0.6) return '#059669';
if (ratio < 0.85) return '#10b981';
return '#34d399';
}
+208
View File
@@ -1,5 +1,6 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { THAILAND_PROVINCES, findProvince, cleanDistrictName } from "../utils/thaiProvinces";
const toNum = (val: any): number => {
if (val === null || val === undefined || val === "") return 0;
@@ -390,4 +391,211 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
}
};
})
// ── GET Thailand Sales Map (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.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
const orders = await prisma.tiktokOrder.findMany({
where,
select: {
orderId: true,
province: true,
district: true,
orderAmount: true,
status: true,
createdTime: true,
},
});
// Map each order to standard province
const provSalesMap = new Map<string, { code: string; nameTh: string; nameEn: string; region: string; regionTh: string; revenue: number; orders: number }>();
// Initialize with all 77 provinces
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.orderAmount;
const matched = findProvince(o.province);
if (matched) {
const p = provSalesMap.get(matched.nameTh)!;
p.revenue += o.orderAmount;
p.orders += 1;
} else {
unmappedOrders += 1;
unmappedRevenue += o.orderAmount;
}
}
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: 'Northeast', 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 ──────────────────────────────────
.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.createdTime = {};
if (dateFrom) where.createdTime.gte = dateFrom;
if (dateTo) where.createdTime.lte = dateTo + " 23:59";
}
// Fetch all orders matching filter
const allOrders = await prisma.tiktokOrder.findMany({
where,
include: {
items: true,
},
orderBy: { createdTime: "desc" },
});
// Filter in JS using findProvince to match variants
const matchedOrders = allOrders.filter(o => {
const p = findProvince(o.province);
return p ? p.nameTh === provNameTh : (o.province?.includes(provNameTh) || provNameTh.includes(o.province || ''));
});
// Group by cleaned district name
const districtMap = new Map<string, { districtName: string; revenue: number; orders: number }>();
let provRevenue = 0;
for (const o of matchedOrders) {
provRevenue += o.orderAmount;
const dName = cleanDistrictName(o.district) || "ไม่ระบุอำเภอ";
if (!districtMap.has(dName)) {
districtMap.set(dName, { districtName: dName, revenue: 0, orders: 0 });
}
const d = districtMap.get(dName)!;
d.revenue += o.orderAmount;
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 => ({
orderId: o.orderId,
shop: o.shop,
status: o.status,
subStatus: o.subStatus,
buyerUsername: o.buyerUsername,
createdTime: o.createdTime,
orderAmount: o.orderAmount,
shippingFee: o.shippingFee,
province: o.province,
district: cleanDistrictName(o.district) || o.district || 'ไม่ระบุอำเภอ',
itemsCount: o.items.reduce((s, i) => s + i.quantity, 0),
items: o.items.map(i => ({
productName: i.productName,
variation: i.variation,
sellerSku: i.sellerSku,
quantity: i.quantity,
subtotalAfterDiscount: i.subtotalAfterDiscount,
unitOriginalPrice: i.unitOriginalPrice,
})),
})),
};
});
+146
View File
@@ -0,0 +1,146 @@
export interface ProvinceMeta {
code: string;
nameTh: string;
nameEn: string;
region: 'North' | 'Northeast' | 'Central' | 'East' | 'West' | 'South';
regionTh: string;
}
export const REGION_TH_MAP: Record<string, string> = {
North: 'ภาคเหนือ',
Northeast: 'ภาคอีสาน',
Central: 'ภาคกลาง',
East: 'ภาคตะวันออก',
West: 'ภาคตะวันตก',
South: 'ภาคใต้',
};
export const THAILAND_PROVINCES: ProvinceMeta[] = [
{ code: '10', nameTh: 'กรุงเทพมหานคร', nameEn: 'Bangkok', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '11', nameTh: 'สมุทรปราการ', nameEn: 'Samut Prakan', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '12', nameTh: 'นนทบุรี', nameEn: 'Nonthaburi', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '13', nameTh: 'ปทุมธานี', nameEn: 'Pathum Thani', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '14', nameTh: 'พระนครศรีอยุธยา', nameEn: 'Phra Nakhon Si Ayutthaya', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '15', nameTh: 'อ่างทอง', nameEn: 'Ang Thong', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '16', nameTh: 'ลพบุรี', nameEn: 'Lop Buri', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '17', nameTh: 'สิงห์บุรี', nameEn: 'Sing Buri', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '18', nameTh: 'ชัยนาท', nameEn: 'Chai Nat', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '19', nameTh: 'สระบุรี', nameEn: 'Saraburi', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '20', nameTh: 'ชลบุรี', nameEn: 'Chon Buri', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '21', nameTh: 'ระยอง', nameEn: 'Rayong', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '22', nameTh: 'จันทบุรี', nameEn: 'Chanthaburi', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '23', nameTh: 'ตราด', nameEn: 'Trat', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '24', nameTh: 'ฉะเชิงเทรา', nameEn: 'Chachoengsao', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '25', nameTh: 'ปราจีนบุรี', nameEn: 'Prachin Buri', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '26', nameTh: 'นครนายก', nameEn: 'Nakhon Nayok', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '27', nameTh: 'สระแก้ว', nameEn: 'Sa Kaeo', region: 'East', regionTh: 'ภาคตะวันออก' },
{ code: '30', nameTh: 'นครราชสีมา', nameEn: 'Nakhon Ratchasima', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '31', nameTh: 'บุรีรัมย์', nameEn: 'Buri Ram', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '32', nameTh: 'สุรินทร์', nameEn: 'Surin', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '33', nameTh: 'ศรีสะเกษ', nameEn: 'Si Sa Ket', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '34', nameTh: 'อุบลราชธานี', nameEn: 'Ubon Ratchathani', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '35', nameTh: 'ยโสธร', nameEn: 'Yasothon', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '36', nameTh: 'ชัยภูมิ', nameEn: 'Chaiyaphum', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '37', nameTh: 'อำนาจเจริญ', nameEn: 'Amnat Charoen', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '38', nameTh: 'บึงกาฬ', nameEn: 'Bueng Kan', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '39', nameTh: 'หนองบัวลำภู', nameEn: 'Nong Bua Lam Phu', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '40', nameTh: 'ขอนแก่น', nameEn: 'Khon Kaen', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '41', nameTh: 'อุดรธานี', nameEn: 'Udon Thani', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '42', nameTh: 'เลย', nameEn: 'Loei', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '43', nameTh: 'หนองคาย', nameEn: 'Nong Khai', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '44', nameTh: 'มหาสารคาม', nameEn: 'Maha Sarakham', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '45', nameTh: 'ร้อยเอ็ด', nameEn: 'Roi Et', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '46', nameTh: 'กาฬสินธุ์', nameEn: 'Kalasin', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '47', nameTh: 'สกลนคร', nameEn: 'Sakon Nakhon', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '48', nameTh: 'นครพนม', nameEn: 'Nakhon Phanom', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '49', nameTh: 'มุกดาหาร', nameEn: 'Mukdahan', region: 'Northeast', regionTh: 'ภาคอีสาน' },
{ code: '50', nameTh: 'เชียงใหม่', nameEn: 'Chiang Mai', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '51', nameTh: 'ลำพูน', nameEn: 'Lamphun', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '52', nameTh: 'ลำปาง', nameEn: 'Lampang', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '53', nameTh: 'อุตรดิตถ์', nameEn: 'Uttaradit', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '54', nameTh: 'แพร่', nameEn: 'Phrae', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '55', nameTh: 'น่าน', nameEn: 'Nan', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '56', nameTh: 'พะเยา', nameEn: 'Phayao', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '57', nameTh: 'เชียงราย', nameEn: 'Chiang Rai', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '58', nameTh: 'แม่ฮ่องสอน', nameEn: 'Mae Hong Son', region: 'North', regionTh: 'ภาคเหนือ' },
{ code: '60', nameTh: 'นครสวรรค์', nameEn: 'Nakhon Sawan', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '61', nameTh: 'อุทัยธานี', nameEn: 'Uthai Thani', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '62', nameTh: 'กำแพงเพชร', nameEn: 'Kamphaeng Phet', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '63', nameTh: 'ตาก', nameEn: 'Tak', region: 'West', regionTh: 'ภาคตะวันตก' },
{ code: '64', nameTh: 'สุโขทัย', nameEn: 'Sukhothai', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '65', nameTh: 'พิษณุโลก', nameEn: 'Phitsanulok', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '66', nameTh: 'พิจิตร', nameEn: 'Phichit', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '67', nameTh: 'เพชรบูรณ์', nameEn: 'Phetchabun', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '70', nameTh: 'ราชบุรี', nameEn: 'Ratchaburi', region: 'West', regionTh: 'ภาคตะวันตก' },
{ code: '71', nameTh: 'กาญจนบุรี', nameEn: 'Kanchanaburi', region: 'West', regionTh: 'ภาคตะวันตก' },
{ code: '72', nameTh: 'สุพรรณบุรี', nameEn: 'Suphan Buri', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '73', nameTh: 'นครปฐม', nameEn: 'Nakhon Pathom', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '74', nameTh: 'สมุทรสาคร', nameEn: 'Samut Sakhon', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '75', nameTh: 'สมุทรสงคราม', nameEn: 'Samut Songkhram', region: 'Central', regionTh: 'ภาคกลาง' },
{ code: '76', nameTh: 'เพชรบุรี', nameEn: 'Phetchaburi', region: 'West', regionTh: 'ภาคตะวันตก' },
{ code: '77', nameTh: 'ประจวบคีรีขันธ์', nameEn: 'Prachuap Khiri Khan', region: 'West', regionTh: 'ภาคตะวันตก' },
{ code: '80', nameTh: 'นครศรีธรรมราช', nameEn: 'Nakhon Si Thammarat', region: 'South', regionTh: 'ภาคใต้' },
{ code: '81', nameTh: 'กระบี่', nameEn: 'Krabi', region: 'South', regionTh: 'ภาคใต้' },
{ code: '82', nameTh: 'พังงา', nameEn: 'Phangnga', region: 'South', regionTh: 'ภาคใต้' },
{ code: '83', nameTh: 'ภูเก็ต', nameEn: 'Phuket', region: 'South', regionTh: 'ภาคใต้' },
{ code: '84', nameTh: 'สุราษฎร์ธานี', nameEn: 'Surat Thani', region: 'South', regionTh: 'ภาคใต้' },
{ code: '85', nameTh: 'ระนอง', nameEn: 'Ranong', region: 'South', regionTh: 'ภาคใต้' },
{ code: '86', nameTh: 'ชุมพร', nameEn: 'Chumphon', region: 'South', regionTh: 'ภาคใต้' },
{ code: '90', nameTh: 'สงขลา', nameEn: 'Songkhla', region: 'South', regionTh: 'ภาคใต้' },
{ code: '91', nameTh: 'สตูล', nameEn: 'Satun', region: 'South', regionTh: 'ภาคใต้' },
{ code: '92', nameTh: 'ตรัง', nameEn: 'Trang', region: 'South', regionTh: 'ภาคใต้' },
{ code: '93', nameTh: 'พัทลุง', nameEn: 'Phatthalung', region: 'South', regionTh: 'ภาคใต้' },
{ code: '94', nameTh: 'ปัตตานี', nameEn: 'Pattani', region: 'South', regionTh: 'ภาคใต้' },
{ code: '95', nameTh: 'ยะลา', nameEn: 'Yala', region: 'South', regionTh: 'ภาคใต้' },
{ code: '96', nameTh: 'นราธิวาส', nameEn: 'Narathiwat', region: 'South', regionTh: 'ภาคใต้' },
];
const ALIAS_MAP: Record<string, string> = {
'กทม': 'กรุงเทพมหานคร',
'กทม.': 'กรุงเทพมหานคร',
'กรุงเทพ': 'กรุงเทพมหานคร',
'กรุงเทพฯ': 'กรุงเทพมหานคร',
'bangkok': 'กรุงเทพมหานคร',
'bkk': 'กรุงเทพมหานคร',
'korat': 'นครราชสีมา',
'โคราช': 'นครราชสีมา',
'ayutthaya': 'พระนครศรีอยุธยา',
'อยุธยา': 'พระนครศรีอยุธยา',
};
const cleanStr = (s: string) => s.toLowerCase().replace(/[\s\-_.,()จ\.\/\\]/g, '').trim();
const PROVINCE_LOOKUP = new Map<string, ProvinceMeta>();
for (const p of THAILAND_PROVINCES) {
PROVINCE_LOOKUP.set(cleanStr(p.nameTh), p);
PROVINCE_LOOKUP.set(cleanStr(p.nameEn), p);
PROVINCE_LOOKUP.set(p.code, p);
}
for (const [alias, targetName] of Object.entries(ALIAS_MAP)) {
const target = THAILAND_PROVINCES.find(p => p.nameTh === targetName);
if (target) {
PROVINCE_LOOKUP.set(cleanStr(alias), target);
}
}
export function findProvince(input: string | null | undefined): ProvinceMeta | null {
if (!input) return null;
const cleaned = cleanStr(input);
if (PROVINCE_LOOKUP.has(cleaned)) return PROVINCE_LOOKUP.get(cleaned)!;
for (const [key, val] of PROVINCE_LOOKUP.entries()) {
if (key.length >= 3 && (cleaned.includes(key) || key.includes(cleaned))) {
return val;
}
}
return null;
}
export function cleanDistrictName(input: string | null | undefined): string {
if (!input) return '';
return input
.replace(/^(อำเภอ|อ\.|เขต|แขวง|ตำบล|ต\.)\s*/, '')
.trim();
}