x
This commit is contained in:
1 parent
037ee775db
commit
abee5249ca
14 files changed
+128241
-2
No files matched your search
@@ -4,7 +4,10 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@uiw/react-md-editor": "^4.1.1",
|
"@uiw/react-md-editor": "^4.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@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
@@ -35,6 +35,7 @@ import ShopeeOrderDetailPage from './pages/ShopeeOrderDetailPage'
|
|||||||
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 TiktokSalesMapPage from './pages/TiktokSalesMapPage'
|
||||||
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
|
import TiktokAnalyticsPage from './pages/TiktokAnalyticsPage'
|
||||||
import TiktokOverviewPage from './pages/TiktokOverviewPage'
|
import TiktokOverviewPage from './pages/TiktokOverviewPage'
|
||||||
import LazadaOrderPage from './pages/LazadaOrderPage'
|
import LazadaOrderPage from './pages/LazadaOrderPage'
|
||||||
@@ -105,6 +106,7 @@ function App() {
|
|||||||
<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-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 />} />
|
||||||
<Route path="/lazada-order" element={<LazadaOrderPage />} />
|
<Route path="/lazada-order" element={<LazadaOrderPage />} />
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
DatabaseBackup,
|
DatabaseBackup,
|
||||||
FileJson,
|
FileJson,
|
||||||
FolderArchive,
|
FolderArchive,
|
||||||
|
Map,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const MAIN_MENU = [
|
const MAIN_MENU = [
|
||||||
@@ -55,6 +56,7 @@ const SALES_MENU = [
|
|||||||
|
|
||||||
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: BarChart3, label: 'TikTok Analytics', to: '/tiktok-analytics' },
|
{ icon: BarChart3, label: 'TikTok Analytics', to: '/tiktok-analytics' },
|
||||||
{ icon: Layers, label: 'TikTok ทุกร้าน', to: '/tiktok-overview' },
|
{ icon: Layers, label: 'TikTok ทุกร้าน', to: '/tiktok-overview' },
|
||||||
{ icon: Calculator, label: 'TikTok Price Simulator', to: '/tiktok-price-simulator' },
|
{ 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
--font-sans: 'Sarabun', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
/* HomAroma Admin - Professional Dark Palette */
|
/* HomAroma Admin - Professional Dark Palette */
|
||||||
--color-night-950: #050608;
|
--color-night-950: #050608;
|
||||||
--color-night-900: #0B0C10;
|
--color-night-900: #0B0C10;
|
||||||
@@ -16,11 +17,12 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-night-950 text-silver-metallic font-sans antialiased;
|
@apply bg-night-950 text-silver-metallic font-sans antialiased;
|
||||||
|
font-family: 'Sarabun', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4 {
|
h1, h2, h3, h4 {
|
||||||
@apply text-white tracking-tight;
|
@apply text-white tracking-tight font-bold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -390,4 +391,211 @@ export const tiktokOrderRoute = new Elysia({ prefix: "/tiktok-order" })
|
|||||||
aov: completedOrders > 0 ? totalRevenue / completedOrders : 0,
|
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,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@uiw/react-md-editor": "^4.1.1",
|
"@uiw/react-md-editor": "^4.1.1",
|
||||||
|
"d3-geo": "^3.1.1",
|
||||||
"lucide-react": "^1.7.0",
|
"lucide-react": "^1.7.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.39.4",
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@types/node": "^24.12.0",
|
"@types/node": "^24.12.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
@@ -592,6 +594,8 @@
|
|||||||
|
|
||||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||||
|
|
||||||
|
"@types/d3-geo": ["@types/d3-geo@3.1.1", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w=="],
|
||||||
|
|
||||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||||
|
|
||||||
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
"@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
|
||||||
@@ -612,6 +616,8 @@
|
|||||||
|
|
||||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||||
|
|
||||||
|
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
||||||
|
|
||||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||||
|
|
||||||
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
@@ -884,6 +890,8 @@
|
|||||||
|
|
||||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||||
|
|
||||||
|
"d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
|
||||||
|
|
||||||
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
"d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
|
||||||
|
|
||||||
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
"d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
|
||||||
|
|||||||
Reference in new issue
Block a user