This commit is contained in:
Your Name committed 2026-09-06 18:47:24 +07:00
1 parent 2c8da0ae2a
commit b7004b63a3
44 files changed
+7873 -1884

No files matched your search

+2
View File
@@ -15,10 +15,12 @@
"@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", "d3-geo": "^3.1.1",
"i18next": "^26.0.4",
"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",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-quill-new": "^3.8.3", "react-quill-new": "^3.8.3",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",
+19 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Outlet } from 'react-router-dom' import { Outlet } from 'react-router-dom'
import Sidebar from './Sidebar' import Sidebar from './Sidebar'
import LanguageSelector from './LanguageSelector'
export default function AdminLayout() { export default function AdminLayout() {
const [isCollapsed, setIsCollapsed] = useState(false) const [isCollapsed, setIsCollapsed] = useState(false)
@@ -8,11 +9,28 @@ export default function AdminLayout() {
return ( return (
<div className="flex min-h-screen bg-night-950"> <div className="flex min-h-screen bg-night-950">
<Sidebar isCollapsed={isCollapsed} onToggle={() => setIsCollapsed(!isCollapsed)} /> <Sidebar isCollapsed={isCollapsed} onToggle={() => setIsCollapsed(!isCollapsed)} />
<main className={`flex-1 p-10 transition-all duration-300 ${isCollapsed ? 'ml-20' : 'ml-72'}`}> <div className={`flex-1 flex flex-col min-w-0 transition-all duration-300 ${isCollapsed ? 'ml-20' : 'ml-72'}`}>
{/* Top Header Bar */}
<header className="sticky top-0 z-40 h-16 border-b border-white/5 bg-night-950/80 backdrop-blur-xl px-8 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-xs text-slate-400 font-medium hidden sm:inline">HomAroma Control Center</span>
</div>
</div>
<div className="flex items-center gap-4">
<LanguageSelector />
</div>
</header>
{/* Main Content Area */}
<main className="flex-1 p-8 lg:p-10">
<div className="w-full max-w-[1600px] mx-auto space-y-10"> <div className="w-full max-w-[1600px] mx-auto space-y-10">
<Outlet /> <Outlet />
</div> </div>
</main> </main>
</div> </div>
</div>
) )
} }
@@ -0,0 +1,96 @@
import { Copy } from 'lucide-react'
export interface LanguageOption {
code: string
label: string
flag: string
nativeName: string
}
export const DEFAULT_CONTENT_LANGUAGES: LanguageOption[] = [
{ code: 'th', label: 'TH', nativeName: 'ภาษาไทย', flag: '🇹🇭' },
{ code: 'en', label: 'EN', nativeName: 'English', flag: '🇬🇧' },
{ code: 'ja', label: 'JA', nativeName: '日本語', flag: '🇯🇵' },
{ code: 'zh', label: 'ZH', nativeName: '简体中文', flag: '🇨🇳' },
]
interface ContentLanguageTabsProps {
activeLang: string
onSelectLang: (lang: string) => void
translationsMap?: Record<string, any>
onCopyFrom?: (fromLang: string) => void
languages?: LanguageOption[]
className?: string
}
export default function ContentLanguageTabs({
activeLang,
onSelectLang,
translationsMap = {},
onCopyFrom,
languages = DEFAULT_CONTENT_LANGUAGES,
className = '',
}: ContentLanguageTabsProps) {
return (
<div className={`flex flex-wrap items-center justify-between gap-3 p-2 bg-night-900/60 backdrop-blur-md rounded-2xl border border-white/10 ${className}`}>
{/* Language Tabs */}
<div className="flex flex-wrap items-center gap-1.5">
{languages.map((lang) => {
const isActive = activeLang === lang.code
const rawVal = translationsMap[lang.code]
const isTranslated =
rawVal !== undefined &&
rawVal !== null &&
(typeof rawVal === 'string' ? rawVal.trim().length > 0 : Object.keys(rawVal).length > 0)
return (
<button
key={lang.code}
type="button"
onClick={() => onSelectLang(lang.code)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
isActive
? 'bg-cyan-500 text-night-950 shadow-lg shadow-cyan-500/25 scale-[1.02]'
: 'bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/5'
}`}
>
<span className="text-sm">{lang.flag}</span>
<span>{lang.nativeName}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-bold uppercase ${
isActive ? 'bg-night-950/20 text-night-950' : 'bg-white/10 text-slate-400'
}`}>
{lang.label}
</span>
{/* Status Indicator */}
{isTranslated && (
<span className={`w-2 h-2 rounded-full ${isActive ? 'bg-night-950' : 'bg-emerald-400'}`} title="Translated" />
)}
</button>
)
})}
</div>
{/* Quick Action: Copy from other lang */}
{onCopyFrom && (
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<Copy className="w-3.5 h-3.5 text-slate-500" />
<span className="text-[11px]">Copy from:</span>
{languages
.filter((l) => l.code !== activeLang)
.map((lang) => (
<button
key={lang.code}
type="button"
onClick={() => onCopyFrom(lang.code)}
className="px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 text-[11px] font-medium border border-white/5 transition-all cursor-pointer"
title={`Copy data from ${lang.nativeName}`}
>
{lang.flag} {lang.label}
</button>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,117 @@
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Globe, ChevronDown, Check } from 'lucide-react'
import { SUPPORTED_LANGUAGES, type SupportedLanguageCode } from '../i18n'
interface LanguageSelectorProps {
isCompact?: boolean
className?: string
}
export default function LanguageSelector({ isCompact = false, className = '' }: LanguageSelectorProps) {
const { i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const currentLang = SUPPORTED_LANGUAGES.find((l) => l.code === i18n.language) || SUPPORTED_LANGUAGES[0]
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelectLanguage = (code: SupportedLanguageCode) => {
i18n.changeLanguage(code)
localStorage.setItem('admin_lang', code)
setIsOpen(false)
}
if (isCompact) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
title={`Language: ${currentLang.label}`}
className="w-10 h-10 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 flex items-center justify-center text-sm transition-all cursor-pointer"
>
<span>{currentLang.flag}</span>
</button>
{isOpen && (
<div className="absolute left-full bottom-0 ml-2 w-44 rounded-xl bg-night-900 border border-white/10 shadow-2xl p-1.5 z-50 animate-in fade-in zoom-in-95 duration-150">
<div className="text-[10px] uppercase font-semibold text-slate-400 px-2 py-1 tracking-wider">
Select Language
</div>
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = i18n.language === lang.code
return (
<button
key={lang.code}
type="button"
onClick={() => handleSelectLanguage(lang.code)}
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-xs font-medium transition-all text-left cursor-pointer ${
isSelected
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-semibold'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="text-base">{lang.flag}</span>
<span className="flex-1">{lang.label}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-cyan-400" />}
</button>
)
})}
</div>
)}
</div>
)
}
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-200 transition-all cursor-pointer"
>
<Globe className="w-3.5 h-3.5 text-slate-400" />
<span className="text-sm">{currentLang.flag}</span>
<span className="font-medium text-slate-200">{currentLang.label}</span>
<ChevronDown className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-2 w-48 rounded-xl bg-night-900 border border-white/10 shadow-2xl p-1.5 z-50 animate-in fade-in zoom-in-95 duration-150">
<div className="text-[10px] uppercase font-semibold text-slate-400 px-2 py-1 tracking-wider">
Language / ภาษา
</div>
{SUPPORTED_LANGUAGES.map((lang) => {
const isSelected = i18n.language === lang.code
return (
<button
key={lang.code}
type="button"
onClick={() => handleSelectLanguage(lang.code)}
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-xs font-medium transition-all text-left cursor-pointer ${
isSelected
? 'bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 font-semibold'
: 'text-slate-300 hover:bg-white/5 hover:text-white'
}`}
>
<span className="text-base">{lang.flag}</span>
<span className="flex-1">{lang.label}</span>
{isSelected && <Check className="w-3.5 h-3.5 text-cyan-400" />}
</button>
)
})}
</div>
)}
</div>
)
}
+217 -213
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { NavLink, useNavigate, useLocation } from 'react-router-dom' import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { import {
LayoutDashboard, LayoutDashboard,
Package, Package,
@@ -36,105 +37,16 @@ import {
Map, Map,
} from 'lucide-react' } from 'lucide-react'
const MAIN_MENU = [
{ icon: LayoutDashboard, label: 'Dashboard', to: '/' },
]
const PRODUCT_MENU = [
{ icon: Tags, label: 'Categories', to: '/categories' },
{ icon: Package, label: 'Products', to: '/products' },
{ icon: Ruler, label: 'Product Attributes', to: '/product-attributes' },
{ icon: PackageOpen, label: 'Raw Materials', to: '/raw-materials' },
]
const SALES_MENU = [
{ icon: ShoppingCart, label: 'Orders', to: '/orders' },
{ icon: Ticket, label: 'Promotions', to: '/promotions' },
{ icon: Ticket, label: 'Coupons', to: '/coupons' },
{ icon: Globe2, label: 'All Analytics', to: '/global-analytics' },
]
const BUSINESS_ANALYTICS_MENU = [
{ icon: Globe2, label: 'Omnichannel Sales Map', to: '/business-analytics/sales-map' },
{ icon: Package, label: 'Product Sales Map', to: '/business-analytics/sales-map-products' },
]
const TOOL_MENU = [
{ icon: Package, label: 'Marketplace Products', to: '/tools/marketplace-products' },
]
const TIKTOK_MENU = [
{ icon: ShoppingCart, label: 'TikTok Orders', to: '/tiktok-order' },
{ icon: Map, label: 'Thailand Sales Map', to: '/tiktok-sales-map' },
{ 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' },
{ icon: Info, label: 'TikTok Shop Case', to: '/tiktok-shop-case' },
]
const TIKTOK_LEGACY_MENU = [
{ icon: TrendingUp, label: 'TikTok Report Import', to: '/tiktok' },
{ icon: TrendingUp, label: 'Product SKU Analysis', to: '/tiktok-analysis' },
]
const SHOPEE_MENU = [
{ icon: ShoppingBag, label: 'Shopee (Hom Aroma)', to: '/shopee' },
{ icon: Map, label: 'Thailand Sales Map', to: '/shopee-sales-map' },
{ icon: BarChart3, label: 'Shopee Analytics', to: '/shopee-analytics' },
{ icon: Layers, label: 'Shopee ทุกร้าน', to: '/shopee-overview' },
{ icon: Info, label: 'Shopee Shop Case', to: '/shopee-shop-case' },
]
const LAZADA_MENU = [
{ icon: ShoppingCart, label: 'Lazada Orders', to: '/lazada-order' },
{ icon: BarChart3, label: 'Lazada Analytics', to: '/lazada-analytics' },
{ icon: Layers, label: 'Lazada ทุกร้าน', to: '/lazada-overview' },
]
const CMS_MENU = [
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
{ icon: Sparkles, label: 'CMS Block Showcase', to: '/cms-mockup' },
{ icon: ImageIcon, label: 'Media Library', to: '/media' },
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' },
]
const CUSTOMER_MENU = [
{ icon: Users, label: 'LINE Customers', to: '/customers' },
{ icon: Users, label: 'Subscribers', to: '/subscribers' },
{ icon: MessageSquare, label: 'Contact Messages', to: '/contacts' },
]
const MENU_MANAGER_MENU = [
{ icon: Layers, label: 'Main Navigation', to: '/menu-manager/main-navigation' },
{ icon: FileText, label: 'Footer Explore', to: '/menu-manager/footer-explore' },
{ icon: MessageSquare, label: 'Footer Support', to: '/menu-manager/footer-support' },
{ icon: Store, label: 'Footer Brand', to: '/menu-manager/footer-brand' },
]
const BACKUP_MENU = [
{ icon: DatabaseBackup, label: 'Database Backup', to: '/backup/database' },
{ icon: Package, label: 'Catalog Export', to: '/backup/catalog' },
{ icon: ShoppingCart, label: 'Orders Export', to: '/backup/orders' },
{ icon: FileJson, label: 'CMS Export', to: '/backup/cms' },
{ icon: FolderArchive, label: 'Media Files', to: '/backup/media' },
]
const SETTINGS_MENU = [
{ icon: Store, label: 'Store Settings', to: '/settings' },
{ icon: CreditCard, label: 'Payment & Shipping',to: '/settings/payments' },
{ icon: Palette, label: 'Theme & Design', to: '/settings/theme' },
{ icon: FileText, label: 'Content', to: '/settings/content' },
{ icon: Wrench, label: 'System', to: '/settings/system' },
]
interface SidebarProps { interface SidebarProps {
isCollapsed: boolean isCollapsed: boolean
onToggle: () => void onToggle: () => void
} }
export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const { t } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p)) const isInProduct = ['/categories', '/products', '/product-attributes', '/raw-materials'].some(p => location.pathname.startsWith(p))
const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p)) const isInSales = ['/orders', '/promotions', '/coupons', '/global-analytics'].some(p => location.pathname.startsWith(p))
const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics') const isInBusinessAnalytics = location.pathname.startsWith('/business-analytics')
@@ -163,6 +75,97 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
const [backupOpen, setBackupOpen] = useState(isInBackup) const [backupOpen, setBackupOpen] = useState(isInBackup)
const [settingsOpen, setSettingsOpen] = useState(isInSettings) const [settingsOpen, setSettingsOpen] = useState(isInSettings)
const MAIN_MENU = [
{ icon: LayoutDashboard, label: t('nav.dashboard', 'Dashboard'), to: '/' },
]
const PRODUCT_MENU = [
{ icon: Tags, label: t('nav.categories', 'Categories'), to: '/categories' },
{ icon: Package, label: t('nav.products', 'Products'), to: '/products' },
{ icon: Ruler, label: t('nav.productAttributes', 'Product Attributes'), to: '/product-attributes' },
{ icon: PackageOpen, label: t('nav.rawMaterials', 'Raw Materials'), to: '/raw-materials' },
]
const SALES_MENU = [
{ icon: ShoppingCart, label: t('nav.orders', 'Orders'), to: '/orders' },
{ icon: Ticket, label: t('nav.promotions', 'Promotions'), to: '/promotions' },
{ icon: Ticket, label: t('nav.coupons', 'Coupons'), to: '/coupons' },
{ icon: Globe2, label: t('nav.globalAnalytics', 'All Analytics'), to: '/global-analytics' },
]
const BUSINESS_ANALYTICS_MENU = [
{ icon: Globe2, label: t('nav.omnichannelSalesMap', 'Omnichannel Sales Map'), to: '/business-analytics/sales-map' },
{ icon: Package, label: t('nav.productSalesMap', 'Product Sales Map'), to: '/business-analytics/sales-map-products' },
]
const TOOL_MENU = [
{ icon: Package, label: t('nav.marketplaceProducts', 'Marketplace Products'), to: '/tools/marketplace-products' },
]
const TIKTOK_MENU = [
{ icon: ShoppingCart, label: t('nav.tiktokOrders', 'TikTok Orders'), to: '/tiktok-order' },
{ icon: Map, label: t('nav.thailandSalesMap', 'Thailand Sales Map'), to: '/tiktok-sales-map' },
{ icon: BarChart3, label: t('nav.tiktokAnalytics', 'TikTok Analytics'), to: '/tiktok-analytics' },
{ icon: Layers, label: t('nav.tiktokOverview', 'TikTok All Shops'), to: '/tiktok-overview' },
{ icon: Calculator, label: t('nav.tiktokPriceSimulator', 'TikTok Price Simulator'), to: '/tiktok-price-simulator' },
{ icon: Info, label: t('nav.tiktokShopCase', 'TikTok Shop Case'), to: '/tiktok-shop-case' },
]
const TIKTOK_LEGACY_MENU = [
{ icon: TrendingUp, label: t('nav.tiktokReportImport', 'TikTok Report Import'), to: '/tiktok' },
{ icon: TrendingUp, label: t('nav.productSkuAnalysis', 'Product SKU Analysis'), to: '/tiktok-analysis' },
]
const SHOPEE_MENU = [
{ icon: ShoppingBag, label: t('nav.shopeeHomAroma', 'Shopee (Hom Aroma)'), to: '/shopee' },
{ icon: Map, label: t('nav.shopeeSalesMap', 'Thailand Sales Map'), to: '/shopee-sales-map' },
{ icon: BarChart3, label: t('nav.shopeeAnalytics', 'Shopee Analytics'), to: '/shopee-analytics' },
{ icon: Layers, label: t('nav.shopeeOverview', 'Shopee All Shops'), to: '/shopee-overview' },
{ icon: Info, label: t('nav.shopeeShopCase', 'Shopee Shop Case'), to: '/shopee-shop-case' },
]
const LAZADA_MENU = [
{ icon: ShoppingCart, label: t('nav.lazadaOrders', 'Lazada Orders'), to: '/lazada-order' },
{ icon: BarChart3, label: t('nav.lazadaAnalytics', 'Lazada Analytics'), to: '/lazada-analytics' },
{ icon: Layers, label: t('nav.lazadaOverview', 'Lazada All Shops'), to: '/lazada-overview' },
]
const CMS_MENU = [
{ icon: Layers, label: t('nav.pageBuilder', 'Page Builder (CMS)'), to: '/page-builder' },
{ icon: Sparkles, label: t('nav.cmsBlockShowcase', 'CMS Block Showcase'), to: '/cms-mockup' },
{ icon: ImageIcon, label: t('nav.mediaLibrary', 'Media Library'), to: '/media' },
{ icon: BookOpen, label: t('nav.scentGuide', 'Scent Guide'), to: '/articles' },
]
const CUSTOMER_MENU = [
{ icon: Users, label: t('nav.customerList', 'Customers'), to: '/customers' },
{ icon: Users, label: t('nav.newsletterSubscribers', 'Subscribers'), to: '/subscribers' },
{ icon: MessageSquare, label: t('nav.contactMessages', 'Contact Messages'), to: '/contacts' },
]
const MENU_MANAGER_MENU = [
{ icon: Layers, label: t('nav.mainNavigation', 'Main Navigation'), to: '/menu-manager/main-navigation' },
{ icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' },
{ icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' },
{ icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' },
]
const BACKUP_MENU = [
{ icon: DatabaseBackup, label: t('nav.backupDatabase', 'Database Backup'), to: '/backup/database' },
{ icon: Package, label: t('nav.backupCatalog', 'Catalog Backup'), to: '/backup/catalog' },
{ icon: ShoppingCart, label: t('nav.backupOrders', 'Orders Backup'), to: '/backup/orders' },
{ icon: FileJson, label: t('nav.backupCms', 'CMS Pages Backup'), to: '/backup/cms' },
{ icon: FolderArchive, label: t('nav.backupMedia', 'Media Backup'), to: '/backup/media' },
]
const SETTINGS_MENU = [
{ icon: Store, label: t('nav.storeSettings', 'Store Settings'), to: '/settings' },
{ icon: CreditCard, label: t('nav.paymentSettings', 'Payment & Shipping'), to: '/settings/payments' },
{ icon: Palette, label: t('nav.themeSettings', 'Theme & Design'), to: '/settings/theme' },
{ icon: FileText, label: t('nav.contentSettings', 'Content'), to: '/settings/content' },
{ icon: Wrench, label: t('nav.systemMaintenance', 'System'), to: '/settings/system' },
]
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('homaroma_admin_token') localStorage.removeItem('homaroma_admin_token')
localStorage.removeItem('homaroma_admin_user') localStorage.removeItem('homaroma_admin_user')
@@ -189,7 +192,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
<button <button
onClick={onToggle} onClick={onToggle}
title={isCollapsed ? 'ขยายเมนู' : 'ย่อเมนู'} title={isCollapsed ? 'Expand' : 'Collapse'}
className="p-1.5 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer" className="p-1.5 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
> >
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />} {isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
@@ -218,11 +221,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setProductOpen)} onClick={() => handleGroupClick(setProductOpen)}
title={isCollapsed ? 'Catalog' : undefined} title={isCollapsed ? t('nav.catalog', 'Catalog & Products') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInProduct ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInProduct ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Package className="w-5 h-5 shrink-0" /> <Package className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Catalog</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.catalog', 'Catalog & Products')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${productOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${productOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -248,11 +251,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setSalesOpen)} onClick={() => handleGroupClick(setSalesOpen)}
title={isCollapsed ? 'Sales & Marketing' : undefined} title={isCollapsed ? t('nav.sales', 'Sales & Marketing') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInSales ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInSales ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<ShoppingCart className="w-5 h-5 shrink-0" /> <ShoppingCart className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Sales & Marketing</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.sales', 'Sales & Marketing')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${salesOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${salesOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -278,11 +281,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setBusinessAnalyticsOpen)} onClick={() => handleGroupClick(setBusinessAnalyticsOpen)}
title={isCollapsed ? 'Business Analytics' : undefined} title={isCollapsed ? t('nav.businessAnalytics', 'Business Analytics') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInBusinessAnalytics ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInBusinessAnalytics ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<BarChart3 className="w-5 h-5 shrink-0 text-orange-400" /> <BarChart3 className="w-5 h-5 shrink-0 text-orange-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-orange-300">Business Analytics</span>} {!isCollapsed && <span className="flex-1 text-left font-bold text-orange-300">{t('nav.businessAnalytics', 'Business Analytics')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${businessAnalyticsOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${businessAnalyticsOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -308,17 +311,107 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setToolsOpen)} onClick={() => handleGroupClick(setToolsOpen)}
title={isCollapsed ? 'Tool' : undefined} title={isCollapsed ? t('nav.tools', 'Tools') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTools ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInTools ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Wrench className="w-5 h-5 shrink-0 text-amber-400" /> <Wrench className="w-5 h-5 shrink-0 text-emerald-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-amber-300">Tool</span>} {!isCollapsed && <span className="flex-1 text-left font-bold text-emerald-300">{t('nav.tools', 'Tools')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${toolsOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${toolsOpen ? 'rotate-180' : ''}`} />}
</button> </button>
{!isCollapsed && toolsOpen && ( {!isCollapsed && toolsOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1"> <div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TOOL_MENU.map((item) => ( {TOOL_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0 text-emerald-400" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* TikTok Channel Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setTiktokOpen)}
title={isCollapsed ? t('nav.tiktok', 'TikTok Channel') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTiktok ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<ShoppingCart className="w-5 h-5 shrink-0 text-pink-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-pink-300">{t('nav.tiktok', 'TikTok Channel')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${tiktokOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && tiktokOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TIKTOK_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0 text-pink-400" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* TikTok Report / Analysis (Legacy) */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setTiktokLegacyOpen)}
title={isCollapsed ? 'TikTok Legacy' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTiktokLegacy ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<TrendingUp className="w-5 h-5 shrink-0 text-pink-300/80" />
{!isCollapsed && <span className="flex-1 text-left text-pink-200/80">TikTok Report/Analysis</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${tiktokLegacyOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && tiktokLegacyOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TIKTOK_LEGACY_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0 text-pink-300/80" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Shopee Channel Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setShopeeOpen)}
title={isCollapsed ? t('nav.shopee', 'Shopee Channel') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInShopee ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<ShoppingBag className="w-5 h-5 shrink-0 text-amber-400" />
{!isCollapsed && <span className="flex-1 text-left font-bold text-amber-300">{t('nav.shopee', 'Shopee Channel')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${shopeeOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && shopeeOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{SHOPEE_MENU.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -334,105 +427,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
</div> </div>
{/* TikTok Group */} {/* Lazada Channel Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setTiktokOpen)}
title={isCollapsed ? 'TikTok' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTiktok ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<TrendingUp className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">TikTok</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${tiktokOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && tiktokOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TIKTOK_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* TikTok Legacy Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setTiktokLegacyOpen)}
title={isCollapsed ? 'TikTok Legacy' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInTiktokLegacy ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<TrendingUp className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">TikTok Legacy</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${tiktokLegacyOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && tiktokLegacyOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{TIKTOK_LEGACY_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Shopee Group */}
<div className="pt-1">
<button
onClick={() => handleGroupClick(setShopeeOpen)}
title={isCollapsed ? 'Shopee' : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInShopee ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
>
<ShoppingBag className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Shopee</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${shopeeOpen ? 'rotate-180' : ''}`} />}
</button>
{!isCollapsed && shopeeOpen && (
<div className="mt-1 ml-4 pl-4 border-l border-white/10 space-y-1">
{SHOPEE_MENU.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
}
>
<item.icon className="w-4 h-4 shrink-0" />
<span className="flex-1">{item.label}</span>
</NavLink>
))}
</div>
)}
</div>
{/* Lazada Group */}
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setLazadaOpen)} onClick={() => handleGroupClick(setLazadaOpen)}
title={isCollapsed ? 'Lazada' : undefined} title={isCollapsed ? t('nav.lazada', 'Lazada Channel') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInLazada ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInLazada ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<ShoppingCart className="w-5 h-5 shrink-0" /> <ShoppingCart className="w-5 h-5 shrink-0 text-blue-400" />
{!isCollapsed && <span className="flex-1 text-left">Lazada</span>} {!isCollapsed && <span className="flex-1 text-left font-bold text-blue-300">{t('nav.lazada', 'Lazada Channel')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${lazadaOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${lazadaOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -446,7 +449,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
`sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}` `sidebar-link text-sm ${isActive ? 'sidebar-link-active' : 'sidebar-link-inactive'}`
} }
> >
<item.icon className="w-4 h-4 shrink-0" /> <item.icon className="w-4 h-4 shrink-0 text-blue-400" />
<span className="flex-1">{item.label}</span> <span className="flex-1">{item.label}</span>
</NavLink> </NavLink>
))} ))}
@@ -454,15 +457,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
</div> </div>
{/* Content (CMS) Group */} {/* CMS Group */}
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setCmsOpen)} onClick={() => handleGroupClick(setCmsOpen)}
title={isCollapsed ? 'Content (CMS)' : undefined} title={isCollapsed ? t('nav.cms', 'Content & CMS') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInCms ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInCms ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Layers className="w-5 h-5 shrink-0" /> <Layers className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Content (CMS)</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.cms', 'Content & CMS')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${cmsOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${cmsOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -484,15 +487,15 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
)} )}
</div> </div>
{/* Customers & Messages Group */} {/* Customers Group */}
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setCustomerOpen)} onClick={() => handleGroupClick(setCustomerOpen)}
title={isCollapsed ? 'Customers & Messages' : undefined} title={isCollapsed ? t('nav.customers', 'Customers') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInCustomer ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInCustomer ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Users className="w-5 h-5 shrink-0" /> <Users className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Customers & Messages</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.customers', 'Customers')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${customerOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${customerOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -518,11 +521,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setMenuManagerOpen)} onClick={() => handleGroupClick(setMenuManagerOpen)}
title={isCollapsed ? 'Menu Manager' : undefined} title={isCollapsed ? t('nav.menuManager', 'Menu Manager') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInMenuManager ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInMenuManager ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Layers className="w-5 h-5 shrink-0" /> <Layers className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Menu Manager</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.menuManager', 'Menu Manager')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${menuManagerOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${menuManagerOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -548,11 +551,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setBackupOpen)} onClick={() => handleGroupClick(setBackupOpen)}
title={isCollapsed ? 'Backup & Restore' : undefined} title={isCollapsed ? t('nav.backup', 'Backup & Restore') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInBackup ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInBackup ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<DatabaseBackup className="w-5 h-5 shrink-0" /> <DatabaseBackup className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Backup & Restore</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.backup', 'Backup & Restore')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${backupOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${backupOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -578,11 +581,11 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
<div className="pt-1"> <div className="pt-1">
<button <button
onClick={() => handleGroupClick(setSettingsOpen)} onClick={() => handleGroupClick(setSettingsOpen)}
title={isCollapsed ? 'Settings' : undefined} title={isCollapsed ? t('nav.settings', 'Settings') : undefined}
className={`sidebar-link sidebar-link-inactive w-full ${isInSettings ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`} className={`sidebar-link sidebar-link-inactive w-full ${isInSettings ? 'text-white' : ''} ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<Settings className="w-5 h-5 shrink-0" /> <Settings className="w-5 h-5 shrink-0" />
{!isCollapsed && <span className="flex-1 text-left">Settings</span>} {!isCollapsed && <span className="flex-1 text-left">{t('nav.settings', 'Settings')}</span>}
{!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${settingsOpen ? 'rotate-180' : ''}`} />} {!isCollapsed && <ChevronDown className={`w-4 h-4 transition-transform duration-200 ${settingsOpen ? 'rotate-180' : ''}`} />}
</button> </button>
@@ -608,25 +611,26 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
{/* User / Bottom */} {/* User / Bottom */}
<div className={`p-4 border-t border-white/5 space-y-2 ${isCollapsed ? 'items-center justify-center' : ''}`}> <div className={`p-4 border-t border-white/5 space-y-2 ${isCollapsed ? 'items-center justify-center' : ''}`}>
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-all group ${isCollapsed ? 'justify-center px-0' : ''}`} title={isCollapsed ? 'Admin Account' : undefined}>
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl hover:bg-white/5 transition-all group ${isCollapsed ? 'justify-center px-0' : ''}`} title={isCollapsed ? t('common.adminAccount', 'Admin Account') : undefined}>
<div className="w-9 h-9 rounded-full bg-cyan-glow/20 flex items-center justify-center text-cyan-glow font-bold text-xs border border-cyan-glow/30 shrink-0"> <div className="w-9 h-9 rounded-full bg-cyan-glow/20 flex items-center justify-center text-cyan-glow font-bold text-xs border border-cyan-glow/30 shrink-0">
A A
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<p className="text-sm font-bold text-white truncate">Admin Account</p> <p className="text-sm font-bold text-white truncate">{t('common.adminAccount', 'Admin Account')}</p>
<p className="text-[10px] uppercase tracking-wider text-slate-500">System Manager</p> <p className="text-[10px] uppercase tracking-wider text-slate-500">{t('common.systemManager', 'System Manager')}</p>
</div> </div>
)} )}
</div> </div>
<button <button
onClick={handleLogout} onClick={handleLogout}
title={isCollapsed ? 'Logout' : undefined} title={isCollapsed ? t('common.logout', 'Logout') : undefined}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-rose-500 hover:bg-rose-500/10 transition-all cursor-pointer ${isCollapsed ? 'justify-center px-0' : ''}`} className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium text-rose-500 hover:bg-rose-500/10 transition-all cursor-pointer ${isCollapsed ? 'justify-center px-0' : ''}`}
> >
<LogOut className="w-5 h-5 shrink-0" /> <LogOut className="w-5 h-5 shrink-0" />
{!isCollapsed && <span>Logout</span>} {!isCollapsed && <span>{t('common.logout', 'Logout')}</span>}
</button> </button>
</div> </div>
</aside> </aside>
@@ -1,7 +1,8 @@
import { useEffect, useState, useMemo, useRef } from 'react'; import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo'; import * as d3Geo from 'd3-geo';
import { X, Search, RotateCcw, MapPin, ExternalLink } from 'lucide-react'; import { X, Search, RotateCcw, MapPin, ExternalLink } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; import { useTranslation } from 'react-i18next';
import { fmtBaht, fmtNum, getDistrictColor, getLocalizedProvinceName, getLocalizedDistrictName } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -28,6 +29,7 @@ export default function OmnichannelDistrictModal({
dateTo, dateTo,
onClose, onClose,
}: OmnichannelDistrictModalProps) { }: OmnichannelDistrictModalProps) {
const { t, i18n } = useTranslation();
const [districtGeo, setDistrictGeo] = useState<any>(null); const [districtGeo, setDistrictGeo] = useState<any>(null);
const [loadingMap, setLoadingMap] = useState(true); const [loadingMap, setLoadingMap] = useState(true);
const [loadingData, setLoadingData] = useState(true); const [loadingData, setLoadingData] = useState(true);
@@ -118,6 +120,7 @@ export default function OmnichannelDistrictModal({
return provinceDistrictsGeo.map((feature: any) => { return provinceDistrictsGeo.map((feature: any) => {
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim(); const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
const dEn = feature.properties.amp_en || '';
const pathData = generator(feature); const pathData = generator(feature);
const sales = districtSalesMap.get(dName); const sales = districtSalesMap.get(dName);
const revenue = sales?.revenue || 0; const revenue = sales?.revenue || 0;
@@ -131,6 +134,7 @@ export default function OmnichannelDistrictModal({
feature, feature,
pathData, pathData,
districtName: dName, districtName: dName,
districtEn: dEn,
revenue, revenue,
orders, orders,
tiktokRevenue, tiktokRevenue,
@@ -209,13 +213,15 @@ export default function OmnichannelDistrictModal({
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2> <h2 className="text-xl font-bold text-white tracking-tight">
{i18n.language === 'th' ? `จังหวัด${provinceName}` : getLocalizedProvinceName(provinceName, i18n.language)}
</h2>
<span className="px-2.5 py-0.5 rounded-full text-xs font-mono bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30"> <span className="px-2.5 py-0.5 rounded-full text-xs font-mono bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
Omnichannel (TikTok + Shopee + Lazada) Omnichannel (TikTok + Shopee + Lazada)
</span> </span>
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม {t('salesMap.subtitle', 'วิเคราะห์ยอดขายแยกรายอำเภอ และตรวจสอบคำสั่งซื้อข้ามแพลตฟอร์ม')}
</p> </p>
</div> </div>
</div> </div>
@@ -233,9 +239,8 @@ export default function OmnichannelDistrictModal({
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative"> <div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5"> <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-orange-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ) <MapPin className="w-3.5 h-3.5 text-orange-400" /> {t('salesMap.districtBreakdown', 'แผนที่รายอำเภอ')} ({paths.length})
</span> </span>
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
</div> </div>
<div <div
@@ -245,11 +250,11 @@ export default function OmnichannelDistrictModal({
> >
{loadingMap || loadingData ? ( {loadingMap || loadingData ? (
<div className="text-center text-slate-400 text-sm"> <div className="text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ... {t('salesMap.loadingDistricts', 'กำลังโหลดแผนที่อำเภอ...')}
</div> </div>
) : paths.length === 0 ? ( ) : paths.length === 0 ? (
<div className="text-center text-slate-500 text-xs p-6"> <div className="text-center text-slate-500 text-xs p-6">
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ {t('salesMap.noData', 'ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้')}
</div> </div>
) : ( ) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
@@ -285,10 +290,10 @@ export default function OmnichannelDistrictModal({
> >
<p className="font-bold text-white flex items-center gap-1.5"> <p className="font-bold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} /> <span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
อ.{hoveredDistrict.districtName} {getLocalizedDistrictName(hoveredDistrict.districtName, hoveredDistrict.districtEn, i18n.language)}
</p> </p>
<p className="text-emerald-400 font-mono font-bold mt-1"> <p className="text-emerald-400 font-mono font-bold mt-1">
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.orders} ออเดอร์)</span> {fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.orders} {t('salesMap.orders', 'ออเดอร์')})</span>
</p> </p>
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap"> <div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
@@ -315,7 +320,7 @@ export default function OmnichannelDistrictModal({
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-orange-500" /> <span className="w-2.5 h-2.5 rounded bg-orange-500" />
<span>เลือกอยู่</span> <span>Active</span>
</div> </div>
</div> </div>
</div> </div>
@@ -334,7 +339,7 @@ export default function OmnichannelDistrictModal({
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ทุกช่องทาง {t('salesMap.allChannels', 'ทุกช่องทาง')}
</button> </button>
<button <button
onClick={() => setChannelFilter('tiktok')} onClick={() => setChannelFilter('tiktok')}
@@ -373,7 +378,7 @@ export default function OmnichannelDistrictModal({
onClick={clearSelectedDistricts} onClick={clearSelectedDistricts}
className="flex items-center gap-1 text-xs text-orange-400 hover:text-orange-300 transition-colors" className="flex items-center gap-1 text-xs text-orange-400 hover:text-orange-300 transition-colors"
> >
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size}) <RotateCcw className="w-3.5 h-3.5" /> {t('salesMap.clear', 'ล้าง')} ({selectedDistricts.size})
</button> </button>
)} )}
</div> </div>
@@ -381,12 +386,12 @@ export default function OmnichannelDistrictModal({
{/* KPI Cards for Selection */} {/* KPI Cards for Selection */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5"> <div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('salesMap.totalRevenue', 'ยอดขายรวม')}</p>
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p> <p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5"> <div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">คำสั่งซื้อ</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('salesMap.totalOrders', 'คำสั่งซื้อ')}</p>
<p className="text-base font-bold text-white font-mono">{selectionSummary.orders} ออเดอร์</p> <p className="text-base font-bold text-white font-mono">{selectionSummary.orders} {t('salesMap.orders', 'ออเดอร์')}</p>
</div> </div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5"> <div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p> <p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
@@ -405,13 +410,13 @@ export default function OmnichannelDistrictModal({
{/* District Tags */} {/* District Tags */}
{selectedDistricts.size > 0 && ( {selectedDistricts.size > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-slate-400">กรองเฉพาะ:</span> <span className="text-xs text-slate-400">Filter:</span>
{Array.from(selectedDistricts).map((dName) => ( {Array.from(selectedDistricts).map((dName) => (
<span <span
key={dName} key={dName}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-orange-500/20 text-orange-300 border border-orange-500/30" className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-orange-500/20 text-orange-300 border border-orange-500/30"
> >
อ.{dName} {getLocalizedDistrictName(dName, undefined, i18n.language)}
<button onClick={() => toggleDistrict(dName)} className="hover:text-white"> <button onClick={() => toggleDistrict(dName)} className="hover:text-white">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
@@ -425,7 +430,7 @@ export default function OmnichannelDistrictModal({
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือชื่อสินค้า..." placeholder={t('salesMap.searchDistrict', 'ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือชื่อสินค้า...')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40" className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
@@ -436,10 +441,10 @@ export default function OmnichannelDistrictModal({
{/* Orders Table */} {/* Orders Table */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loadingData ? ( {loadingData ? (
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div> <div className="py-12 text-center text-slate-500 text-sm">{t('salesMap.loadingMap', 'กำลังโหลดข้อมูลคำสั่งซื้อ...')}</div>
) : filteredOrders.length === 0 ? ( ) : filteredOrders.length === 0 ? (
<div className="py-12 text-center text-slate-500 text-xs"> <div className="py-12 text-center text-slate-500 text-xs">
ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก {t('salesMap.noData', 'ไม่พบคำสั่งซื้อตามเงื่อนไขที่เลือก')}
</div> </div>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
@@ -1,7 +1,8 @@
import { useEffect, useState, useMemo, useRef } from 'react'; import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo'; import * as d3Geo from 'd3-geo';
import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react'; import { X, Search, RotateCcw, MapPin, ExternalLink, Package } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; import { useTranslation } from 'react-i18next';
import { fmtBaht, fmtNum, getDistrictColor, getLocalizedProvinceName, getLocalizedDistrictName } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@@ -30,6 +31,7 @@ export default function ProductDistrictModal({
dateTo, dateTo,
onClose, onClose,
}: ProductDistrictModalProps) { }: ProductDistrictModalProps) {
const { t, i18n } = useTranslation();
const [districtGeo, setDistrictGeo] = useState<any>(null); const [districtGeo, setDistrictGeo] = useState<any>(null);
const [loadingMap, setLoadingMap] = useState(true); const [loadingMap, setLoadingMap] = useState(true);
const [loadingData, setLoadingData] = useState(true); const [loadingData, setLoadingData] = useState(true);
@@ -119,6 +121,7 @@ export default function ProductDistrictModal({
return provinceDistrictsGeo.map((feature: any) => { return provinceDistrictsGeo.map((feature: any) => {
const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim(); const dName = (feature.properties.amp_th || feature.properties.amp_en || '').replace(/^(อำเภอ|เขต)/, '').trim();
const dEn = feature.properties.amp_en || '';
const pathData = generator(feature); const pathData = generator(feature);
const sales = districtSalesMap.get(dName); const sales = districtSalesMap.get(dName);
const revenue = sales?.revenue || 0; const revenue = sales?.revenue || 0;
@@ -133,6 +136,7 @@ export default function ProductDistrictModal({
feature, feature,
pathData, pathData,
districtName: dName, districtName: dName,
districtEn: dEn,
revenue, revenue,
unitsSold, unitsSold,
orders, orders,
@@ -210,14 +214,16 @@ export default function ProductDistrictModal({
</div> </div>
<div> <div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h2 className="text-xl font-bold text-white tracking-tight">จังหวัด{provinceName}</h2> <h2 className="text-xl font-bold text-white tracking-tight">
{i18n.language === 'th' ? `จังหวัด${provinceName}` : getLocalizedProvinceName(provinceName, i18n.language)}
</h2>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 flex items-center gap-1"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30 flex items-center gap-1">
<Package className="w-3 h-3" /> <Package className="w-3 h-3" />
{productName} {variantName ? `(${variantName})` : ''} {productName} {variantName ? `(${variantName})` : ''}
</span> </span>
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม {t('salesMap.productSubtitle', 'สถิติยอดสั่งซื้อสินค้านี้แยกรายอำเภอ และตรวจสอบคำสั่งซื้อจริงข้ามแพลตฟอร์ม')}
</p> </p>
</div> </div>
</div> </div>
@@ -235,9 +241,8 @@ export default function ProductDistrictModal({
<div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative"> <div className="lg:col-span-5 p-5 border-b lg:border-b-0 lg:border-r border-white/10 flex flex-col bg-slate-950/40 relative">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5"> <span className="text-xs font-semibold uppercase tracking-wider text-slate-400 flex items-center gap-1.5">
<MapPin className="w-3.5 h-3.5 text-indigo-400" /> แผนที่รายอำเภอ ({paths.length} อำเภอ) <MapPin className="w-3.5 h-3.5 text-indigo-400" /> {t('salesMap.districtBreakdown', 'แผนที่รายอำเภอ')} ({paths.length})
</span> </span>
<span className="text-[11px] text-slate-500">คลิกที่อำเภอเพื่อเลือกหลายพื้นที่</span>
</div> </div>
<div <div
@@ -247,11 +252,11 @@ export default function ProductDistrictModal({
> >
{loadingMap || loadingData ? ( {loadingMap || loadingData ? (
<div className="text-center text-slate-400 text-sm"> <div className="text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังโหลดแผนที่อำเภอ... {t('salesMap.loadingDistricts', 'กำลังโหลดแผนที่อำเภอ...')}
</div> </div>
) : paths.length === 0 ? ( ) : paths.length === 0 ? (
<div className="text-center text-slate-500 text-xs p-6"> <div className="text-center text-slate-500 text-xs p-6">
ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้ {t('salesMap.noData', 'ไม่พบข้อมูลภูมิศาสตร์อำเภอของจังหวัดนี้')}
</div> </div>
) : ( ) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px] p-2">
@@ -287,10 +292,10 @@ export default function ProductDistrictModal({
> >
<p className="font-bold text-white flex items-center gap-1.5"> <p className="font-bold text-white flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} /> <span className="w-2 h-2 rounded-full" style={{ backgroundColor: hoveredDistrict.color }} />
อ.{hoveredDistrict.districtName} {getLocalizedDistrictName(hoveredDistrict.districtName, hoveredDistrict.districtEn, i18n.language)}
</p> </p>
<p className="text-emerald-400 font-mono font-bold mt-1"> <p className="text-emerald-400 font-mono font-bold mt-1">
{fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.unitsSold} ชิ้น)</span> {fmtBaht(hoveredDistrict.revenue)} <span className="text-slate-400 font-normal">({hoveredDistrict.unitsSold} {t('salesMap.unitsSold', 'ชิ้น')})</span>
</p> </p>
{(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && ( {(hoveredDistrict.tiktokRevenue > 0 || hoveredDistrict.shopeeRevenue > 0 || hoveredDistrict.lazadaRevenue > 0) && (
<div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap"> <div className="flex items-center gap-1.5 text-[10px] text-slate-400 mt-1 pt-1 border-t border-white/10 font-mono flex-wrap">
@@ -317,7 +322,7 @@ export default function ProductDistrictModal({
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-indigo-500" /> <span className="w-2.5 h-2.5 rounded bg-indigo-500" />
<span>เลือกอยู่</span> <span>Active</span>
</div> </div>
</div> </div>
</div> </div>
@@ -336,7 +341,7 @@ export default function ProductDistrictModal({
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ทุกช่องทาง {t('salesMap.allChannels', 'ทุกช่องทาง')}
</button> </button>
<button <button
onClick={() => setChannelFilter('tiktok')} onClick={() => setChannelFilter('tiktok')}
@@ -375,7 +380,7 @@ export default function ProductDistrictModal({
onClick={clearSelectedDistricts} onClick={clearSelectedDistricts}
className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors" className="flex items-center gap-1 text-xs text-indigo-400 hover:text-indigo-300 transition-colors"
> >
<RotateCcw className="w-3.5 h-3.5" /> ล้างพื้นที่ที่เลือก ({selectedDistricts.size}) <RotateCcw className="w-3.5 h-3.5" /> {t('salesMap.clear', 'ล้าง')} ({selectedDistricts.size})
</button> </button>
)} )}
</div> </div>
@@ -383,12 +388,12 @@ export default function ProductDistrictModal({
{/* KPI Cards for Selection */} {/* KPI Cards for Selection */}
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2"> <div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5"> <div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายรวม</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('salesMap.totalRevenue', 'ยอดขายรวม')}</p>
<p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p> <p className="text-base font-bold text-emerald-400 font-mono">{fmtBaht(selectionSummary.revenue)}</p>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-2.5"> <div className="bg-white/5 border border-white/10 rounded-xl p-2.5">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">จำนวนชิ้นที่ขาย</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('salesMap.unitsSold', 'จำนวนชิ้นที่ขาย')}</p>
<p className="text-base font-bold text-amber-400 font-mono">{selectionSummary.units} ชิ้น</p> <p className="text-base font-bold text-amber-400 font-mono">{selectionSummary.units} {t('salesMap.unitsSold', 'ชิ้น')}</p>
</div> </div>
<div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5"> <div className="bg-rose-500/10 border border-rose-500/20 rounded-xl p-2.5">
<p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p> <p className="text-[10px] text-rose-300 uppercase tracking-wider">TikTok</p>
@@ -407,13 +412,13 @@ export default function ProductDistrictModal({
{/* District Tags */} {/* District Tags */}
{selectedDistricts.size > 0 && ( {selectedDistricts.size > 0 && (
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs text-slate-400">กรองเฉพาะ:</span> <span className="text-xs text-slate-400">Filter:</span>
{Array.from(selectedDistricts).map((dName) => ( {Array.from(selectedDistricts).map((dName) => (
<span <span
key={dName} key={dName}
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-indigo-500/20 text-indigo-300 border border-indigo-500/30" className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-lg text-xs bg-indigo-500/20 text-indigo-300 border border-indigo-500/30"
> >
อ.{dName} {getLocalizedDistrictName(dName, undefined, i18n.language)}
<button onClick={() => toggleDistrict(dName)} className="hover:text-white"> <button onClick={() => toggleDistrict(dName)} className="hover:text-white">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
</button> </button>
@@ -427,7 +432,7 @@ export default function ProductDistrictModal({
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือ SKU..." placeholder={t('salesMap.searchDistrict', 'ค้นหาหมายเลขคำสั่งซื้อ, ผู้ซื้อ, หรือ SKU...')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" className="w-full pl-9 pr-4 py-1.5 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
@@ -438,10 +443,10 @@ export default function ProductDistrictModal({
{/* Orders Table */} {/* Orders Table */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{loadingData ? ( {loadingData ? (
<div className="py-12 text-center text-slate-500 text-sm">กำลังโหลดข้อมูลคำสั่งซื้อ...</div> <div className="py-12 text-center text-slate-500 text-sm">{t('salesMap.loadingMap', 'กำลังโหลดข้อมูลคำสั่งซื้อ...')}</div>
) : filteredOrders.length === 0 ? ( ) : filteredOrders.length === 0 ? (
<div className="py-12 text-center text-slate-500 text-xs"> <div className="py-12 text-center text-slate-500 text-xs">
ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก {t('salesMap.noData', 'ไม่พบคำสั่งซื้อที่มีสินค้านี้ตามเงื่อนไขที่เลือก')}
</div> </div>
) : ( ) : (
<div className="space-y-2.5"> <div className="space-y-2.5">
@@ -1,7 +1,8 @@
import { useEffect, useState, useMemo, useRef } from 'react'; import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo'; import * as d3Geo from 'd3-geo';
import { X, Search, Package, ShoppingBag, RotateCcw, MapPin, Tag } from 'lucide-react'; import { X, Search, Package, ShoppingBag, RotateCcw, MapPin, Tag } from 'lucide-react';
import { fmtBaht, fmtNum, getDistrictColor } from '../../utils/thaiGeoUtils'; import { useTranslation } from 'react-i18next';
import { fmtBaht, fmtNum, getDistrictColor, getLocalizedDistrictName, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
interface DistrictMapModalProps { interface DistrictMapModalProps {
@@ -23,6 +24,7 @@ export default function DistrictMapModal({
apiEndpoint = '/tiktok-order/sales-map/district', apiEndpoint = '/tiktok-order/sales-map/district',
onClose, onClose,
}: DistrictMapModalProps) { }: DistrictMapModalProps) {
const { t, i18n } = useTranslation();
const [districtGeo, setDistrictGeo] = useState<any>(null); const [districtGeo, setDistrictGeo] = useState<any>(null);
const [loadingMap, setLoadingMap] = useState(true); const [loadingMap, setLoadingMap] = useState(true);
const [loadingData, setLoadingData] = useState(true); const [loadingData, setLoadingData] = useState(true);
@@ -182,6 +184,9 @@ export default function DistrictMapModal({
return sum; return sum;
}, [selectedDistricts, districtSalesMap]); }, [selectedDistricts, districtSalesMap]);
const locProvinceName = getLocalizedProvinceName(provinceName, i18n.language);
const locRegionName = getLocalizedRegionName(districtData?.province?.regionTh, i18n.language);
return ( 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="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"> <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">
@@ -190,19 +195,19 @@ export default function DistrictMapModal({
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-emerald-400 font-bold uppercase tracking-wider"> <span className="text-xs text-emerald-400 font-bold uppercase tracking-wider">
จังหวัดที่เลือก • {districtData?.province?.regionTh || 'ภาคอีสาน'} {t('districtModal.selectedProvinceRegion', { region: locRegionName || '' })}
</span> </span>
</div> </div>
<h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5"> <h2 className="text-xl md:text-2xl font-bold text-white tracking-tight flex items-center gap-2 mt-0.5">
แผนที่อำเภอ & คำสั่งซื้อ {platformName} • <span className="text-emerald-400">{provinceName}</span> {t('districtModal.title', { platform: platformName, province: locProvinceName })}
</h2> </h2>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
คลิกอำเภอบนแผนที่เพื่อไฮไลต์และกรองข้อมูลฝั่งขวาอัตโนมัติ (สีส้มคืออำเภอที่ถูกเลือก) {t('districtModal.subtitle')}
</p> </p>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors" className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
@@ -216,10 +221,10 @@ export default function DistrictMapModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-emerald-400" /> <MapPin className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-white"> <span className="text-sm font-bold text-white">
แผนที่อำเภอ ({provinceFeatures.length} อำเภอ) {t('districtModal.districtMapTitle', { count: provinceFeatures.length })}
</span> </span>
</div> </div>
<span className="text-[11px] text-slate-400">คลิกเพื่อเลือกหลายพื้นที่</span> <span className="text-[11px] text-slate-400">{t('districtModal.multiSelectHint')}</span>
</div> </div>
<div <div
@@ -236,11 +241,11 @@ export default function DistrictMapModal({
{loadingMap || loadingData ? ( {loadingMap || loadingData ? (
<div className="flex flex-col items-center gap-2 text-slate-500 animate-pulse"> <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" /> <div className="w-6 h-6 border-2 border-emerald-400 border-t-transparent rounded-full animate-spin" />
<span className="text-xs">กำลังเตรียมแผนที่อำเภอ...</span> <span className="text-xs">{t('districtModal.loadingMap')}</span>
</div> </div>
) : provinceFeatures.length === 0 ? ( ) : provinceFeatures.length === 0 ? (
<div className="text-xs text-slate-500 text-center px-4"> <div className="text-xs text-slate-500 text-center px-4">
ไม่พบข้อมูลพิกัดอำเภอของจังหวัด {provinceName} {t('districtModal.geoNotFound', { province: locProvinceName })}
</div> </div>
) : ( ) : (
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px]"> <svg viewBox={`0 0 ${width} ${height}`} className="w-full h-full max-h-[380px]">
@@ -277,18 +282,18 @@ export default function DistrictMapModal({
}} }}
> >
<span className="font-bold text-white"> <span className="font-bold text-white">
อ.{hoveredDistrict.properties.amp_th} {t('tiktokOrders.districtPrefix')}{getLocalizedDistrictName(hoveredDistrict.properties.amp_th, hoveredDistrict.properties.amp_en, i18n.language)}
</span> </span>
<div className="flex items-center justify-between gap-3 text-[11px]"> <div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-slate-400">ยอดขาย:</span> <span className="text-slate-400">{t('districtModal.revenueLabel')}</span>
<span className="font-bold text-emerald-400 font-mono"> <span className="font-bold text-emerald-400 font-mono">
{fmtBaht(districtSalesMap.get(hoveredDistrict.properties.amp_th)?.revenue || 0)} {fmtBaht(districtSalesMap.get(hoveredDistrict.properties.amp_th)?.revenue || 0)}
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-3 text-[11px]"> <div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-slate-400">คำสั่งซื้อ:</span> <span className="text-slate-400">{t('districtModal.ordersLabel')}</span>
<span className="font-bold text-white font-mono"> <span className="font-bold text-white font-mono">
{districtSalesMap.get(hoveredDistrict.properties.amp_th)?.orders || 0} ออเดอร์ {t('districtModal.ordersCount', { count: districtSalesMap.get(hoveredDistrict.properties.amp_th)?.orders || 0 })}
</span> </span>
</div> </div>
</div> </div>
@@ -298,17 +303,17 @@ export default function DistrictMapModal({
{/* Bottom Scale & Attribution */} {/* Bottom Scale & Attribution */}
<div className="mt-3 flex items-center justify-between text-[10px] text-slate-500"> <div className="mt-3 flex items-center justify-between text-[10px] text-slate-500">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span>ยอดน้อย</span> <span>{t('districtModal.lowSales')}</span>
<div className="w-20 h-2 rounded bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-400" /> <div className="w-20 h-2 rounded bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-400" />
<span>ยอดมาก</span> <span>{t('districtModal.highSales')}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-orange-500 inline-block" /> <span className="w-2.5 h-2.5 rounded bg-orange-500 inline-block" />
<span className="text-orange-400 font-medium">เลือกแล้ว</span> <span className="text-orange-400 font-medium">{t('districtModal.selected')}</span>
</div> </div>
</div> </div>
<p className="text-[9px] text-slate-600 text-center mt-2 font-mono"> <p className="text-[9px] text-slate-600 text-center mt-2 font-mono">
ขอบเขต ADM2: mapthai / OCHA CODs - CC BY 4.0 {t('districtModal.attribution')}
</p> </p>
</div> </div>
@@ -317,16 +322,16 @@ export default function DistrictMapModal({
<div className="flex items-center justify-between pb-3 border-b border-white/10"> <div className="flex items-center justify-between pb-3 border-b border-white/10">
<div> <div>
<p className="text-sm font-bold text-white"> <p className="text-sm font-bold text-white">
{selectedDistricts.size} อำเภอที่เลือก {t('districtModal.selectedDistrictsCount', { count: selectedDistricts.size })}
</p> </p>
<p className="text-[11px] text-slate-400">แตะพื้นที่เพื่อดูข้อมูล</p> <p className="text-[11px] text-slate-400">{t('districtModal.touchHint')}</p>
</div> </div>
{selectedDistricts.size > 0 && ( {selectedDistricts.size > 0 && (
<button <button
onClick={handleClearSelected} 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" 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 cursor-pointer"
> >
<RotateCcw className="w-3 h-3" /> ล้าง <RotateCcw className="w-3 h-3" /> {t('common.clear')}
</button> </button>
)} )}
</div> </div>
@@ -335,12 +340,13 @@ export default function DistrictMapModal({
{selectedDistricts.size === 0 ? ( {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"> <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" /> <Tag className="w-6 h-6 opacity-30 text-emerald-400" />
<p>ยังไม่ได้เลือกอำเภอ</p> <p>{t('districtModal.noDistrictsSelected')}</p>
<p className="text-[11px] text-slate-600">คลิกบนแผนที่เพื่อกรองข้อมูลเฉพาะอำเภอ</p> <p className="text-[11px] text-slate-600">{t('districtModal.clickMapToFilter')}</p>
</div> </div>
) : ( ) : (
Array.from(selectedDistricts).map((dist) => { Array.from(selectedDistricts).map((dist) => {
const st = districtSalesMap.get(dist) || { revenue: 0, orders: 0 }; const st = districtSalesMap.get(dist) || { revenue: 0, orders: 0 };
const locDist = getLocalizedDistrictName(dist, undefined, i18n.language);
return ( return (
<div <div
key={dist} key={dist}
@@ -348,9 +354,9 @@ export default function DistrictMapModal({
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-orange-500" /> <span className="w-2 h-2 rounded-full bg-orange-500" />
<span className="font-bold text-white">อ.{dist}</span> <span className="font-bold text-white">{t('tiktokOrders.districtPrefix')}{locDist}</span>
<span className="text-[10px] text-slate-400 font-mono"> <span className="text-[10px] text-slate-400 font-mono">
({st.orders} ออเดอร์) {t('districtModal.ordersCountSub', { orders: st.orders })}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -359,7 +365,7 @@ export default function DistrictMapModal({
</span> </span>
<button <button
onClick={() => handleToggleDistrict(dist)} onClick={() => handleToggleDistrict(dist)}
className="text-slate-500 hover:text-rose-400 transition-colors" className="text-slate-500 hover:text-rose-400 transition-colors cursor-pointer"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
</button> </button>
@@ -372,7 +378,7 @@ export default function DistrictMapModal({
{selectedDistricts.size > 0 && ( {selectedDistricts.size > 0 && (
<div className="pt-3 border-t border-white/10 flex items-center justify-between text-xs"> <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-slate-400 font-medium">{t('districtModal.totalSelectedRevenue')}</span>
<span className="text-sm font-bold text-orange-400 font-mono"> <span className="text-sm font-bold text-orange-400 font-mono">
{fmtBaht(selectedDistrictsRevenue)} {fmtBaht(selectedDistrictsRevenue)}
</span> </span>
@@ -386,12 +392,12 @@ export default function DistrictMapModal({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingBag className="w-4 h-4 text-emerald-400" /> <ShoppingBag className="w-4 h-4 text-emerald-400" />
<span className="text-sm font-bold text-white"> <span className="text-sm font-bold text-white">
คำสั่งซื้อ {platformName} ในพื้นที่ ({filteredOrders.length}) {t('districtModal.platformOrdersTitle', { platform: platformName, count: filteredOrders.length })}
</span> </span>
</div> </div>
{selectedDistricts.size > 0 && ( {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"> <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} อำเภอ {t('districtModal.filterDistrictsBadge', { count: selectedDistricts.size })}
</span> </span>
)} )}
</div> </div>
@@ -403,7 +409,7 @@ export default function DistrictMapModal({
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder="ค้นหา Order ID, สินค้า, SKU..." placeholder={t('districtModal.searchOrdersPlaceholder')}
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" 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>
@@ -413,7 +419,7 @@ export default function DistrictMapModal({
{filteredOrders.length === 0 ? ( {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"> <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" /> <Package className="w-6 h-6 opacity-30 text-slate-400" />
<p>ไม่พบรายการคำสั่งซื้อในเงื่อนไขที่เลือก</p> <p>{t('districtModal.noOrdersFound')}</p>
</div> </div>
) : ( ) : (
filteredOrders.map((o: any) => ( filteredOrders.map((o: any) => (
@@ -431,7 +437,7 @@ export default function DistrictMapModal({
</div> </div>
<div className="text-slate-300 line-clamp-2 text-[11px] leading-relaxed"> <div className="text-slate-300 line-clamp-2 text-[11px] leading-relaxed">
{o.items?.[0]?.productName || 'คำสั่งซื้อสินค้า'} {o.items?.[0]?.productName || t('districtModal.defaultItemTitle')}
{o.items?.[0]?.variation && ( {o.items?.[0]?.variation && (
<span className="text-slate-400 ml-1 font-mono text-[10px]"> <span className="text-slate-400 ml-1 font-mono text-[10px]">
({o.items[0].variation}) ({o.items[0].variation})
@@ -442,7 +448,7 @@ export default function DistrictMapModal({
<div className="flex items-center justify-between pt-1 border-t border-white/5 text-[10px] text-slate-400"> <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"> <span className="flex items-center gap-1 text-emerald-400/90">
<MapPin className="w-3 h-3" /> <MapPin className="w-3 h-3" />
อ.{o.district || 'ไม่ระบุ'} {t('tiktokOrders.districtPrefix')}{getLocalizedDistrictName(o.district, undefined, i18n.language) || o.district || t('tiktokOrderDetail.unspecified')}
</span> </span>
<span className="font-mono"> <span className="font-mono">
{o.createdTime?.slice(0, 16) || ''} {o.createdTime?.slice(0, 16) || ''}
@@ -459,27 +465,27 @@ export default function DistrictMapModal({
<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 justify-between px-6 py-4 border-t border-white/10 bg-white/[0.02]">
<div className="flex items-center gap-4 text-xs"> <div className="flex items-center gap-4 text-xs">
<span className="text-slate-400"> <span className="text-slate-400">
ยอดรวมทั้งจังหวัด:{' '} {t('districtModal.provinceTotalLabel')}{' '}
<span className="text-emerald-400 font-bold font-mono"> <span className="text-emerald-400 font-bold font-mono">
{fmtBaht(districtData?.summary?.totalRevenue || 0)} {fmtBaht(districtData?.summary?.totalRevenue || 0)}
</span>{' '} </span>{' '}
({fmtNum(districtData?.summary?.totalOrders || 0)} คำสั่งซื้อ) {t('districtModal.ordersCountParen', { count: fmtNum(districtData?.summary?.totalOrders || 0) })}
</span> </span>
<span className="text-slate-500 hidden sm:inline">•</span> <span className="text-slate-500 hidden sm:inline">•</span>
<span className="text-slate-400 hidden sm:inline"> <span className="text-slate-400 hidden sm:inline">
คำสั่งซื้อทั้งหมดที่ดึงมา:{' '} {t('districtModal.totalFetchedOrdersLabel')}{' '}
<span className="text-white font-bold font-mono"> <span className="text-white font-bold font-mono">
{fmtNum(districtData?.orders?.length || 0)} {fmtNum(districtData?.orders?.length || 0)}
</span>{' '} </span>{' '}
รายการ {t('districtModal.itemsUnit')}
</span> </span>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors" className="px-6 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-white font-bold text-xs transition-colors cursor-pointer"
> >
ปิดหน้าต่าง {t('common.close')}
</button> </button>
</div> </div>
</div> </div>
@@ -1,7 +1,8 @@
import { useEffect, useState, useMemo, useRef } from 'react'; import { useEffect, useState, useMemo, useRef } from 'react';
import * as d3Geo from 'd3-geo'; import * as d3Geo from 'd3-geo';
import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react';
import { fmtBaht, fmtNum, getChoroplethColor } from '../../utils/thaiGeoUtils'; import { useTranslation } from 'react-i18next';
import { fmtBaht, fmtNum, getChoroplethColor, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils';
interface ProvinceData { interface ProvinceData {
code: string; code: string;
@@ -24,6 +25,7 @@ export default function ThailandMapSvg({
selectedProvince, selectedProvince,
onSelectProvince, onSelectProvince,
}: ThailandMapSvgProps) { }: ThailandMapSvgProps) {
const { t, i18n } = useTranslation();
const [geoData, setGeoData] = useState<any>(null); const [geoData, setGeoData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hoveredFeature, setHoveredFeature] = useState<any>(null); const [hoveredFeature, setHoveredFeature] = useState<any>(null);
@@ -100,13 +102,6 @@ export default function ThailandMapSvg({
}; };
const handleMouseMove = (e: React.MouseEvent) => { const handleMouseMove = (e: React.MouseEvent) => {
if (isDragging) {
setPan({
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
});
}
if (containerRef.current) { if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect(); const rect = containerRef.current.getBoundingClientRect();
setTooltipPos({ setTooltipPos({
@@ -114,9 +109,17 @@ export default function ThailandMapSvg({
y: e.clientY - rect.top, y: e.clientY - rect.top,
}); });
} }
if (!isDragging) return;
setPan({
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
});
}; };
const handleMouseUp = () => setIsDragging(false); const handleMouseUp = () => {
setIsDragging(false);
};
if (loading) { if (loading) {
return ( return (
@@ -143,21 +146,21 @@ export default function ThailandMapSvg({
<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"> <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 <button
onClick={handleZoomIn} onClick={handleZoomIn}
title="ซูมเข้า" title={t('salesMap.zoomIn', 'Zoom In')}
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors" 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" /> <ZoomIn className="w-4 h-4" />
</button> </button>
<button <button
onClick={handleZoomOut} onClick={handleZoomOut}
title="ซูมออก" title={t('salesMap.zoomOut', 'Zoom Out')}
className="p-2 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-slate-300 hover:text-emerald-400 transition-colors" 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" /> <ZoomOut className="w-4 h-4" />
</button> </button>
<button <button
onClick={handleResetZoom} onClick={handleResetZoom}
title="รีเซ็ตมุมมอง" title={t('salesMap.resetZoom', 'Reset View')}
className="p-2 rounded-lg bg-white/5 hover:bg-rose-500/20 text-slate-300 hover:text-rose-400 transition-colors" 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" /> <RotateCcw className="w-4 h-4" />
@@ -214,7 +217,14 @@ export default function ThailandMapSvg({
</svg> </svg>
{/* Hover Tooltip */} {/* Hover Tooltip */}
{hoveredFeature && tooltipPos && ( {hoveredFeature && tooltipPos && (() => {
const rawTh = hoveredFeature.properties.pro_th;
const localizedName = getLocalizedProvinceName(rawTh, i18n.language);
const subName = i18n.language === 'th' ? hoveredFeature.properties.pro_en : rawTh;
const regionRaw = provincesData[rawTh]?.region || hoveredFeature.properties.reg_royin || '';
const localizedRegion = getLocalizedRegionName(regionRaw, i18n.language);
return (
<div <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" 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={{ style={{
@@ -224,30 +234,33 @@ export default function ThailandMapSvg({
> >
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<span className="font-bold text-white text-sm"> <span className="font-bold text-white text-sm">
{hoveredFeature.properties.pro_th} {localizedName}
</span> </span>
<span className="text-[10px] text-slate-400 font-mono"> <span className="text-[10px] text-slate-400 font-mono">
{hoveredFeature.properties.pro_en} {subName}
</span> </span>
</div> </div>
{localizedRegion && (
<div className="text-[10px] text-emerald-400 font-medium"> <div className="text-[10px] text-emerald-400 font-medium">
{provincesData[hoveredFeature.properties.pro_th]?.regionTh || hoveredFeature.properties.reg_royin || 'ไม่ระบุภาค'} {localizedRegion}
</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>
)} )}
<div className="h-px bg-white/10 my-0.5" />
<div className="flex items-center justify-between gap-4">
<span className="text-slate-400">{t('salesMap.revenue', 'Revenue')}:</span>
<span className="font-bold text-emerald-400 font-mono">
{fmtBaht(provincesData[rawTh]?.revenue || 0)}
</span>
</div>
<div className="flex items-center justify-between gap-4">
<span className="text-slate-400">{t('salesMap.orders', 'Orders')}:</span>
<span className="font-bold text-white font-mono">
{fmtNum(provincesData[rawTh]?.orders || 0)} {t('salesMap.orders', 'orders')}
</span>
</div>
</div>
);
})()}
</div> </div>
); );
} }
+37
View File
@@ -0,0 +1,37 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import enTranslation from '../locales/en/translation.json'
import thTranslation from '../locales/th/translation.json'
import jaTranslation from '../locales/ja/translation.json'
import zhTranslation from '../locales/zh/translation.json'
export const SUPPORTED_LANGUAGES = [
{ code: 'th', label: 'ไทย', flag: '🇹🇭' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'ja', label: '日本語', flag: '🇯🇵' },
{ code: 'zh', label: '中文', flag: '🇨🇳' },
] as const
export type SupportedLanguageCode = typeof SUPPORTED_LANGUAGES[number]['code']
// Retrieve default lang from localStorage if set, default to 'th'
const savedLang = localStorage.getItem('admin_lang') || 'th'
i18n
.use(initReactI18next)
.init({
resources: {
en: { translation: enTranslation },
th: { translation: thTranslation },
ja: { translation: jaTranslation },
zh: { translation: zhTranslation },
},
lng: savedLang,
fallbackLng: 'en',
interpolation: {
escapeValue: false, // react already escapes from xss
},
})
export default i18n
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './i18n'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
+5 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom' import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE } from '@homaroma/types' import { API_BASE } from '@homaroma/types'
import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react' import { ArrowLeft, Save, Loader2, Image as ImageIcon } from 'lucide-react'
import ReactQuill from 'react-quill-new' import ReactQuill from 'react-quill-new'
@@ -7,6 +8,7 @@ import 'react-quill-new/dist/quill.snow.css'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
export default function ArticleEditPage() { export default function ArticleEditPage() {
const { t } = useTranslation()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const isNew = id === 'new' const isNew = id === 'new'
const navigate = useNavigate() const navigate = useNavigate()
@@ -181,10 +183,10 @@ export default function ArticleEditPage() {
/> />
<span className="text-sm font-bold uppercase tracking-widest">Publish to Website</span> <span className="text-sm font-bold uppercase tracking-widest">Publish to Website</span>
</label> </label>
<Link to="/articles" className="btn-secondary px-8 py-3 font-bold text-sm">Cancel</Link> <Link to="/articles" className="btn-secondary px-8 py-3 font-bold text-sm">{t('common.cancel', 'Cancel')}</Link>
<button type="submit" disabled={saving} className="btn-primary px-10 py-3 flex items-center gap-2 text-sm font-bold"> <button type="submit" disabled={saving} className="btn-primary px-10 py-3 flex items-center gap-2 text-sm font-bold cursor-pointer">
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />} {saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Save Article {saving ? t('common.saving', 'Saving...') : t('common.save', 'Save Article')}
</button> </button>
</div> </div>
+56 -41
View File
@@ -1,16 +1,18 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { API_BASE, getCategoryImage, type Category } from '@homaroma/types' import { API_BASE, getCategoryImage, type Category } from '@homaroma/types'
import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react' import { Edit3, Package, Trash2, Plus, X, Image as ImageIcon, UploadCloud, Loader2, ClipboardPaste, LayoutDashboard } from 'lucide-react'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
export default function CategoriesPage() { export default function CategoriesPage() {
const { t, i18n } = useTranslation()
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [isModalOpen, setIsModalOpen] = useState(false) const [isModalOpen, setIsModalOpen] = useState(false)
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null) const [editingCategory, setEditingCategory] = useState<Partial<Category> | null>(null)
const [activeLang, setActiveLang] = useState('th') const [activeLang, setActiveLang] = useState('th')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
@@ -48,7 +50,7 @@ export default function CategoriesPage() {
body: JSON.stringify({ body: JSON.stringify({
name: typeof editingCategory.name === 'string' name: typeof editingCategory.name === 'string'
? editingCategory.name ? editingCategory.name
: (editingCategory.name?.th || 'Untitled'), : (editingCategory.name?.th || editingCategory.name?.en || 'Untitled'),
imageUrl: editingCategory.imageUrl || null imageUrl: editingCategory.imageUrl || null
}) })
}) })
@@ -79,7 +81,7 @@ export default function CategoriesPage() {
} }
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!confirm('Are you sure you want to delete this category?')) return if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this category?'))) return
try { try {
const res = await fetch(`${API_BASE}/categories/${id}`, { const res = await fetch(`${API_BASE}/categories/${id}`, {
method: 'DELETE' method: 'DELETE'
@@ -137,7 +139,7 @@ export default function CategoriesPage() {
const openAddModal = () => { const openAddModal = () => {
setEditingCategory({ setEditingCategory({
name: { th: '', en: '', ja: '', zh: '', vi: '', ko: '' } as any, name: { th: '', en: '', ja: '', zh: '' } as any,
imageUrl: '' imageUrl: ''
}) })
setActiveLang('th') setActiveLang('th')
@@ -153,18 +155,37 @@ export default function CategoriesPage() {
setIsModalOpen(true) setIsModalOpen(true)
} }
const handleCopyName = (fromLang: string) => {
if (!editingCategory?.name || typeof editingCategory.name !== 'object') return
const sourceText = (editingCategory.name as any)[fromLang] || ''
if (!sourceText) return
setEditingCategory({
...editingCategory,
name: {
...(editingCategory.name as any),
[activeLang]: sourceText,
} as any,
})
}
const getLocalizedCategoryName = (cat: Category) => {
if (typeof cat.name === 'string') return cat.name
const lang = i18n.language
return cat.name?.[lang] || cat.name?.th || cat.name?.en || 'Unnamed'
}
return ( return (
<div className="space-y-10 animate-fade-in"> <div className="space-y-10 animate-fade-in">
{!isModalOpen ? ( {!isModalOpen ? (
<> <>
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Category Management</h1> <h1 className="text-3xl font-bold">{t('categories.title', 'Category Management')}</h1>
<p className="text-slate-500 text-sm">Managing scent classification and product groups.</p> <p className="text-slate-500 text-sm">{t('categories.subtitle', 'Organize products into multilingual categories')}</p>
</div> </div>
<button onClick={openAddModal} className="btn-primary flex items-center gap-2"> <button onClick={openAddModal} className="btn-primary flex items-center gap-2">
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Create Category {t('categories.addNew', 'Create Category')}
</button> </button>
</div> </div>
@@ -187,13 +208,15 @@ export default function CategoriesPage() {
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity relative z-10"> <div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity relative z-10">
<button <button
onClick={() => openEditModal(cat)} onClick={() => openEditModal(cat)}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-cyan-glow hover:border-cyan-glow/30 transition-all" title={t('common.edit', 'Edit')}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-cyan-glow hover:border-cyan-glow/30 transition-all cursor-pointer"
> >
<Edit3 className="w-4 h-4" /> <Edit3 className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => handleDelete(cat.id)} onClick={() => handleDelete(cat.id)}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-rose-500 hover:border-rose-500/30 transition-all" title={t('common.delete', 'Delete')}
className="p-2 rounded-lg bg-night-800 border border-white/5 text-slate-500 hover:text-rose-500 hover:border-rose-500/30 transition-all cursor-pointer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -202,11 +225,11 @@ export default function CategoriesPage() {
<div className="space-y-1 relative z-10"> <div className="space-y-1 relative z-10">
<h3 className="text-xl font-bold text-white tracking-tight"> <h3 className="text-xl font-bold text-white tracking-tight">
{typeof cat.name === 'string' ? cat.name : (cat.name?.th || cat.name?.en || 'Unnamed')} {getLocalizedCategoryName(cat)}
</h3> </h3>
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500"> <div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-widest text-slate-500">
<Package className="w-3 h-3 text-cyan-glow/50" /> <Package className="w-3 h-3 text-cyan-glow/50" />
<span>{cat._count?.products || 0} Products</span> <span>{cat._count?.products || 0} {t('categories.itemCount', 'Products')}</span>
</div> </div>
</div> </div>
@@ -222,11 +245,11 @@ export default function CategoriesPage() {
<div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]"> <div className="p-6 border-b border-white/5 flex flex-shrink-0 items-center justify-between bg-white/[0.02]">
<h3 className="text-xl font-bold flex items-center gap-2"> <h3 className="text-xl font-bold flex items-center gap-2">
<LayoutDashboard className="w-5 h-5 text-cyan-glow" /> <LayoutDashboard className="w-5 h-5 text-cyan-glow" />
{editingCategory.id ? 'Edit Category' : 'Create Category'} {editingCategory.id ? t('categories.editCategory', 'Edit Category') : t('categories.addNew', 'Create Category')}
</h3> </h3>
<button <button
onClick={() => setIsModalOpen(false)} onClick={() => setIsModalOpen(false)}
className="p-2 rounded-xl hover:bg-white/5 text-slate-500 hover:text-white transition-all" className="p-2 rounded-xl hover:bg-white/5 text-slate-500 hover:text-white transition-all cursor-pointer"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
@@ -238,11 +261,11 @@ export default function CategoriesPage() {
{/* Image Upload Area */} {/* Image Upload Area */}
<div className="md:col-span-2 space-y-3"> <div className="md:col-span-2 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Category Image</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('categories.image', 'Category Image')}</label>
<button <button
type="button" type="button"
onClick={handlePasteFromClipboard} onClick={handlePasteFromClipboard}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20" className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow text-[10px] font-bold uppercase tracking-wider transition-all border border-cyan-glow/20 cursor-pointer"
> >
<ClipboardPaste className="w-3.5 h-3.5" /> <ClipboardPaste className="w-3.5 h-3.5" />
Paste Image Paste Image
@@ -277,26 +300,18 @@ export default function CategoriesPage() {
</div> </div>
</div> </div>
<div className="md:col-span-2 space-y-2"> {/* Multilingual Name Field */}
<div className="flex justify-between items-end mb-2"> <div className="md:col-span-2 space-y-3">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Category Name</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
<div className="flex bg-white/5 rounded-lg p-1"> {t('categories.name', 'Category Name')} (4 Languages: TH, EN, JA, ZH)
{languages.map(lang => ( </label>
<button
key={lang} <ContentLanguageTabs
type="button" activeLang={activeLang}
onClick={() => setActiveLang(lang)} onSelectLang={setActiveLang}
className={`px-3 py-1 rounded-md text-[10px] font-bold uppercase transition-all ${ translationsMap={typeof editingCategory.name === 'object' ? editingCategory.name : { th: editingCategory.name }}
activeLang === lang onCopyFrom={handleCopyName}
? 'bg-cyan-glow text-night-900 shadow-lg' />
: 'text-slate-500 hover:text-white'
}`}
>
{lang}
</button>
))}
</div>
</div>
<input <input
type="text" type="text"
@@ -306,8 +321,8 @@ export default function CategoriesPage() {
...editingCategory, ...editingCategory,
name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any name: { ...(editingCategory.name as any), [activeLang]: e.target.value } as any
})} })}
className="admin-input w-full" className="admin-input w-full mt-2"
placeholder={`Category name in ${activeLang.toUpperCase()}`} placeholder={`Category name in ${activeLang.toUpperCase()} (${activeLang === 'th' ? 'เช่น เทียนหอม' : activeLang === 'en' ? 'e.g. Scented Candle' : activeLang === 'ja' ? '例: アロマキャンドル' : '例如: 香薰蜡烛'})`}
/> />
</div> </div>
</div> </div>
@@ -316,16 +331,16 @@ export default function CategoriesPage() {
<button <button
type="submit" type="submit"
disabled={isUploading} disabled={isUploading}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2" className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer"
> >
{editingCategory.id ? 'Save Changes' : 'Create Category'} {editingCategory.id ? t('common.saveChanges', 'Save Changes') : t('categories.addNew', 'Create Category')}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsModalOpen(false)} onClick={() => setIsModalOpen(false)}
className="px-8 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all" className="px-8 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-xl transition-all cursor-pointer"
> >
Cancel {t('common.cancel', 'Cancel')}
</button> </button>
</div> </div>
</form> </form>
+56 -40
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Package, Tag, Layers, TrendingUp } from 'lucide-react' import { Package, Tag, Layers, TrendingUp } from 'lucide-react'
import { API_BASE, type Category, type Product } from '@homaroma/types' import { API_BASE, type Category, type Product } from '@homaroma/types'
export default function DashboardPage() { export default function DashboardPage() {
const { t } = useTranslation()
const [stats, setStats] = useState({ const [stats, setStats] = useState({
products: 0, products: 0,
categories: 0, categories: 0,
@@ -61,18 +63,18 @@ export default function DashboardPage() {
}, []) }, [])
const cards = [ const cards = [
{ label: 'Total Revenue', value: `฿${stats.revenue.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' }, { label: t('dashboard.totalRevenue', 'Total Revenue'), value: `฿${stats.revenue.toLocaleString()}`, icon: TrendingUp, color: 'text-emerald-400' },
{ label: 'Total Orders', value: stats.orders, icon: Package, color: 'text-blue-400' }, { label: t('dashboard.totalOrders', 'Total Orders'), value: stats.orders, icon: Package, color: 'text-blue-400' },
{ label: 'Total Customers', value: stats.customers, icon: Tag, color: 'text-purple-400' }, { label: t('dashboard.totalCustomers', 'Total Customers'), value: stats.customers, icon: Tag, color: 'text-purple-400' },
{ label: 'Total Products', value: stats.products, icon: Layers, color: 'text-cyan-glow' }, { label: t('dashboard.totalProducts', 'Total Products'), value: stats.products, icon: Layers, color: 'text-cyan-glow' },
] ]
return ( return (
<div className="space-y-10 animate-fade-in"> <div className="space-y-10 animate-fade-in">
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">System Overview</h1> <h1 className="text-3xl font-bold">{t('dashboard.title', 'System Overview')}</h1>
<p className="text-slate-500 text-sm">Real-time stats from the HomAroma platform.</p> <p className="text-slate-500 text-sm">{t('dashboard.subtitle', 'Real-time stats from the HomAroma platform.')}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xs uppercase tracking-widest text-slate-500">Last updated</p> <p className="text-xs uppercase tracking-widest text-slate-500">Last updated</p>
@@ -101,53 +103,67 @@ export default function DashboardPage() {
))} ))}
</div> </div>
{/* Placeholder for recent activities */} {/* Analytics Highlights */}
<div className="grid md:grid-cols-2 gap-8"> <div className="grid md:grid-cols-2 gap-8">
<div className="admin-card space-y-6"> <div className="admin-card space-y-6">
<h3 className="font-bold text-lg">Inventory Summary</h3> <div className="flex justify-between items-center">
<h3 className="font-bold text-lg text-white">{t('dashboard.salesTrend', 'Sales Channels')}</h3>
<span className="text-xs text-cyan-glow uppercase tracking-wider font-semibold">Live status</span>
</div>
<div className="space-y-4"> <div className="space-y-4">
{/* Dummy chart-like bars */} <div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/5">
{[ <span className="text-sm text-slate-300">Website & SPA Direct</span>
{ label: 'Oil Ozone', val: 0.8 }, <span className="text-sm font-bold text-emerald-400">Active</span>
{ label: 'Aroma Oil', val: 0.6 },
{ label: 'Reed Diffuser', val: 0.4 },
{ label: 'Car Diffuser', val: 0.2 },
].map(i => (
<div key={i.label} className="space-y-2">
<div className="flex justify-between text-xs font-bold uppercase tracking-widest">
<span className="text-slate-400">{i.label}</span>
<span className="text-cyan-glow">{Math.round(i.val * 100)}% Stock</span>
</div> </div>
<div className="h-1.5 w-full bg-night-800 rounded-full overflow-hidden"> <div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/5">
<div <span className="text-sm text-slate-300">TikTok Shop Thailand</span>
className="h-full bg-cyan-glow shadow-[0_0_10px_rgba(102,252,241,0.5)] rounded-full transition-all duration-1000" <span className="text-sm font-bold text-pink-400">Integrated</span>
style={{ width: `${i.val * 100}%` }}
/>
</div> </div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/5">
<span className="text-sm text-slate-300">Shopee Mall</span>
<span className="text-sm font-bold text-amber-400">Connected</span>
</div>
<div className="flex items-center justify-between p-4 rounded-xl bg-white/[0.02] border border-white/5">
<span className="text-sm text-slate-300">Lazada Channel</span>
<span className="text-sm font-bold text-blue-400">Connected</span>
</div> </div>
))}
</div> </div>
</div> </div>
<div className="admin-card space-y-6"> <div className="admin-card space-y-6">
<h3 className="font-bold text-lg">System Health</h3> <div className="flex justify-between items-center">
<div className="space-y-5"> <h3 className="font-bold text-lg text-white">{t('common.language', 'Internationalization (4 Languages)')}</h3>
{[ <span className="text-xs text-emerald-400 uppercase tracking-wider font-semibold">Online</span>
{ label: 'API Backend', status: 'Healthy', time: '12ms' },
{ label: 'Database Service', status: 'Healthy', time: '4ms' },
{ label: 'Admin App Bundle', status: 'Optimized', time: '128kb' },
].map(i => (
<div key={i.label} className="flex items-center justify-between p-4 bg-night-950/50 rounded-xl border border-white/5">
<div className="space-y-1">
<p className="text-sm font-bold text-white">{i.label}</p>
<p className="text-[10px] uppercase font-bold text-emerald-500 tracking-widest">{i.status}</p>
</div> </div>
<div className="text-right"> <div className="space-y-3">
<p className="text-xs text-slate-500">Performance</p> <div className="flex items-center justify-between p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<p className="text-sm font-bold text-white">{i.time}</p> <div className="flex items-center gap-2.5">
<span className="text-lg">🇹🇭</span>
<span className="text-sm font-medium text-white">ภาษาไทย (Thai)</span>
</div> </div>
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">Primary</span>
</div>
<div className="flex items-center justify-between p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex items-center gap-2.5">
<span className="text-lg">🇬🇧</span>
<span className="text-sm font-medium text-white">English (UK/Global)</span>
</div>
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">Supported</span>
</div>
<div className="flex items-center justify-between p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex items-center gap-2.5">
<span className="text-lg">🇯🇵</span>
<span className="text-sm font-medium text-white">日本語 (Japanese)</span>
</div>
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">Supported</span>
</div>
<div className="flex items-center justify-between p-3.5 rounded-xl bg-white/[0.02] border border-white/5">
<div className="flex items-center gap-2.5">
<span className="text-lg">🇨🇳</span>
<span className="text-sm font-medium text-white">简体中文 (Chinese)</span>
</div>
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-cyan-500/10 text-cyan-400 border border-cyan-500/20">Supported</span>
</div> </div>
))}
</div> </div>
</div> </div>
</div> </div>
+63 -38
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, Legend, PieChart, Pie, Cell,
} from 'recharts'; } from 'recharts';
import { import {
TrendingUp, DollarSign, Package, BadgePercent, ShoppingCart, Loader2, Store, Calendar, Globe2 TrendingUp, DollarSign, Package, BadgePercent, ShoppingCart, Loader2, Store, Calendar, Globe2
@@ -9,7 +10,6 @@ import {
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '');
// We adjust TikTok to Cyan/Pink mix or just Cyan, Lazada to Blue, Shopee to Orange.
const COLORS = { const COLORS = {
Shopee: '#f97316', Shopee: '#f97316',
TikTok: '#06b6d4', TikTok: '#06b6d4',
@@ -32,17 +32,6 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; }
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: '7 วัน' },
{ key: '30d', label: '30 วัน' },
{ key: '90d', label: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' },
];
function authHeaders() { function authHeaders() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` };
@@ -82,6 +71,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function GlobalAnalyticsPage() { export default function GlobalAnalyticsPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -91,6 +81,18 @@ export default function GlobalAnalyticsPage() {
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: t('analytics.preset7d', '7 Days') },
{ key: '30d', label: t('analytics.preset30d', '30 Days') },
{ key: '90d', label: t('analytics.preset90d', '90 Days') },
{ key: 'thisMonth', label: t('analytics.presetThisMonth', 'This Month') },
{ key: 'lastMonth', label: t('analytics.presetLastMonth', 'Last Month') },
{ key: 'year', label: t('analytics.presetThisYear', 'This Year') },
{ key: 'lastYear', label: t('analytics.presetLastYear', 'Last Year') },
{ key: 'last12m', label: t('analytics.presetLast12m', 'Last 12 Months') },
{ key: 'all', label: t('analytics.presetAll', 'All Time') },
];
const loadData = async (from: string, to: string) => { const loadData = async (from: string, to: string) => {
setLoading(true); setError(null); setData(null); setLoading(true); setError(null); setData(null);
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -180,7 +182,7 @@ export default function GlobalAnalyticsPage() {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className="w-8 h-8 text-emerald-500 animate-spin" /> <Loader2 className="w-8 h-8 text-emerald-500 animate-spin" />
<span className="text-slate-500">กำลังรวมข้อมูลระดับ Global (ทุกแพลตฟอร์ม)...</span> <span className="text-slate-500">{t('analytics.loadingGlobal', 'Aggregating global platform data...')}</span>
</div> </div>
); );
} }
@@ -189,7 +191,7 @@ export default function GlobalAnalyticsPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<Globe2 className="w-14 h-14 text-slate-700" /> <Globe2 className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'เกิดข้อผิดพลาดในการโหลดข้อมูล'}</p> <p className="text-slate-500">{error || t('common.error', 'Error loading data')}</p>
</div> </div>
); );
} }
@@ -207,8 +209,8 @@ export default function GlobalAnalyticsPage() {
<Globe2 className="w-6 h-6 text-white" /> <Globe2 className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Global Overview</p> <p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">{t('analytics.globalOverview', 'Global Overview')}</p>
<h1 className="text-3xl font-bold text-white tracking-tight">ภาพรวมทุกแพลตฟอร์ม</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('analytics.allPlatformsOverview', 'All Platforms Overview')}</h1>
</div> </div>
</div> </div>
</div> </div>
@@ -221,7 +223,7 @@ export default function GlobalAnalyticsPage() {
<button <button
key={p.key} key={p.key}
onClick={() => applyPreset(p.key)} onClick={() => applyPreset(p.key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${ className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all cursor-pointer ${
preset === p.key preset === p.key
? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30' ? 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
: 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white'
@@ -235,21 +237,21 @@ export default function GlobalAnalyticsPage() {
<div className="hidden sm:block h-6 w-px bg-white/10" /> <div className="hidden sm:block h-6 w-px bg-white/10" />
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('analytics.customRange', 'Custom:')}</span>
<input <input
type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none"
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('analytics.to', 'to')}</span>
<input <input
type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-emerald-500/40 focus:outline-none"
/> />
<button <button
onClick={applyCustom} onClick={applyCustom}
className="px-4 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium hover:bg-emerald-500/20 transition-all" className="px-4 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-medium hover:bg-emerald-500/20 transition-all cursor-pointer"
> >
ดูข้อมูล {t('analytics.apply', 'Apply')}
</button> </button>
</div> </div>
@@ -260,9 +262,9 @@ export default function GlobalAnalyticsPage() {
</span> </span>
<button <button
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors cursor-pointer"
> >
✕ ล้าง ✕ {t('analytics.clear', 'Clear')}
</button> </button>
</div> </div>
)} )}
@@ -270,10 +272,34 @@ export default function GlobalAnalyticsPage() {
{/* Grand KPI Row */} {/* Grand KPI Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard label="ยอดขายรวมระดับบริษัท" value={fmtB(summary.totalRevenue)} sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} color="text-emerald-400" icon={DollarSign} /> <KPICard
<KPICard label="AOV รวม (เฉลี่ย)" value={fmtB(summary.aov)} sub={`จากออเดอร์สำเร็จ ${fmt(summary.completedOrders)}`} color="text-blue-400" icon={ShoppingCart} /> label={t('analytics.totalCompanyRevenue', 'Total Company Revenue')}
<KPICard label="อัตรายกเลิกรวม" value={`${fmt(summary.cancelRate, 1)}%`} sub={`${fmt(summary.cancelledOrders)} รายการยกเลิก`} color="text-rose-400" icon={BadgePercent} /> value={fmtB(summary.totalRevenue)}
<KPICard label="ค่าจัดส่งรวม" value={fmtB(summary.totalShipping)} sub="ค่าจัดส่งทุกแพลตฟอร์ม" color="text-amber-400" icon={Package} /> sub={`${fmt(summary.totalOrders)} ${t('analytics.orders', 'orders')}`}
color="text-emerald-400"
icon={DollarSign}
/>
<KPICard
label={t('analytics.averageOrderValue', 'Overall AOV (Avg)')}
value={fmtB(summary.aov)}
sub={`${t('analytics.fromCompletedOrders', 'From completed orders')} (${fmt(summary.completedOrders)})`}
color="text-blue-400"
icon={ShoppingCart}
/>
<KPICard
label={t('analytics.totalCancelRate', 'Total Cancel Rate')}
value={`${fmt(summary.cancelRate, 1)}%`}
sub={`${fmt(summary.cancelledOrders)} ${t('analytics.cancelledOrders', 'cancelled orders')}`}
color="text-rose-400"
icon={BadgePercent}
/>
<KPICard
label={t('analytics.totalShipping', 'Total Shipping Fee')}
value={fmtB(summary.totalShipping)}
sub={t('analytics.allPlatformsShipping', 'Across all platforms')}
color="text-amber-400"
icon={Package}
/>
</div> </div>
{/* Revenue Share Pie + Comparison table */} {/* Revenue Share Pie + Comparison table */}
@@ -283,7 +309,7 @@ export default function GlobalAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Globe2 className="w-5 h-5 text-amber-400" /> <Globe2 className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนยอดขายตามแพลตฟอร์ม</h2> <h2 className="text-lg font-bold text-white">{t('analytics.revenueShareByPlatform', 'Revenue Share by Platform')}</h2>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="h-[220px] flex-1"> <div className="h-[220px] flex-1">
@@ -320,16 +346,16 @@ export default function GlobalAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Store className="w-5 h-5 text-slate-400" /> <Store className="w-5 h-5 text-slate-400" />
<h2 className="text-lg font-bold text-white">เปรียบเทียบแพลตฟอร์ม</h2> <h2 className="text-lg font-bold text-white">{t('analytics.platformComparison', 'Platform Comparison')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="text-slate-500 uppercase border-b border-white/10"> <thead className="text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left">แพลตฟอร์ม</th> <th className="pb-3 text-left">{t('analytics.platform', 'Platform')}</th>
<th className="pb-3 text-right">ยอดขาย</th> <th className="pb-3 text-right">{t('analytics.revenue', 'Revenue')}</th>
<th className="pb-3 text-right">สัดส่วน</th> <th className="pb-3 text-right">{t('analytics.share', 'Share')}</th>
<th className="pb-3 text-right">ออเดอร์</th> <th className="pb-3 text-right">{t('analytics.orders', 'Orders')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -360,7 +386,7 @@ export default function GlobalAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className="w-5 h-5 text-emerald-500" /> <TrendingUp className="w-5 h-5 text-emerald-500" />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน (แยกตามแพลตฟอร์ม)</h2> <h2 className="text-lg font-bold text-white">{t('analytics.monthlyRevenueByPlatform', 'Monthly Revenue (by Platform)')}</h2>
</div> </div>
{monthly.length > 0 ? ( {monthly.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -370,9 +396,8 @@ export default function GlobalAnalyticsPage() {
<XAxis dataKey="month" stroke="#64748b" tick={{ fontSize: 11 }} /> <XAxis dataKey="month" stroke="#64748b" tick={{ fontSize: 11 }} />
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => v >= 1000 ? `฿${(v / 1000).toFixed(0)}k` : `฿${v}`} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => v >= 1000 ? `฿${(v / 1000).toFixed(0)}k` : `฿${v}`} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
{platforms.map((p: any, i: number) => { {platforms.map((p: any, i: number) => {
const isTop = i === platforms.length - 1; // Simplification const isTop = i === platforms.length - 1;
return ( return (
<Bar key={p.name} dataKey={p.name} name={p.name} stackId="rev" fill={COLORS[p.name as keyof typeof COLORS]} radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]} /> <Bar key={p.name} dataKey={p.name} name={p.name} stackId="rev" fill={COLORS[p.name as keyof typeof COLORS]} radius={isTop ? [4, 4, 0, 0] : [0, 0, 0, 0]} />
); );
@@ -381,7 +406,7 @@ export default function GlobalAnalyticsPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('analytics.noMonthlyData', 'No monthly data available')}</p>
)} )}
</div> </div>
+96 -53
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, LineChart, Line, Legend, PieChart, Pie, Cell, LineChart, Line, Legend,
@@ -68,17 +69,7 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [ const PRESET_KEYS: Preset[] = ['7d', '30d', '90d', 'thisMonth', 'lastMonth', 'year', 'lastYear', 'last12m', 'all'];
{ key: '7d', label: '7 วัน' },
{ key: '30d', label: '30 วัน' },
{ key: '90d', label: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' },
];
function StatCard({ function StatCard({
label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5', label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5',
@@ -112,6 +103,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function LazadaAnalyticsPage() { export default function LazadaAnalyticsPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -166,7 +158,7 @@ export default function LazadaAnalyticsPage() {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className={`w-8 h-8 ${shopCfg.color} animate-spin`} /> <Loader2 className={`w-8 h-8 ${shopCfg.color} animate-spin`} />
<span className="text-slate-500">กำลังคำนวณข้อมูลวิเคราะห์ Lazada...</span> <span className="text-slate-500">{t('lazadaAnalytics.loading', 'กำลังคำนวณข้อมูลวิเคราะห์ Lazada...')}</span>
</div> </div>
); );
} }
@@ -175,9 +167,9 @@ export default function LazadaAnalyticsPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<ShoppingBag className="w-14 h-14 text-slate-700" /> <ShoppingBag className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Lazada ก่อน'}</p> <p className="text-slate-500">{error || t('lazadaAnalytics.noData', 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Lazada ก่อน')}</p>
<Link to="/lazada-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm"> <Link to="/lazada-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm">
<ArrowLeft className="w-4 h-4" /> กลับหน้า Lazada Orders <ArrowLeft className="w-4 h-4" /> {t('lazadaAnalytics.backToOrders', 'กลับหน้า Lazada Orders')}
</Link> </Link>
</div> </div>
); );
@@ -204,12 +196,11 @@ export default function LazadaAnalyticsPage() {
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1"> <div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/lazada-order" className={`hover:${shopCfg.color} transition-colors`}>Lazada</Link> <Link to="/lazada-order" className={`hover:${shopCfg.color} transition-colors`}>Lazada</Link>
<span>/</span> <span>/</span>
<span className="text-white">Analytics</span> <span className="text-white">{t('lazadaAnalytics.breadcrumb', 'Analytics')}</span>
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
<TrendingUp className={`w-6 h-6 ${shopCfg.color}`} /> <TrendingUp className={`w-6 h-6 ${shopCfg.color}`} />
วิเคราะห์ยอดขาย — {t('lazadaAnalytics.title', { shop: shopCfg.label, defaultValue: `วิเคราะห์ยอดขาย — ${shopCfg.label}` })}
<span className={shopCfg.color}>{shopCfg.label}</span>
</h1> </h1>
</div> </div>
</div> </div>
@@ -238,17 +229,17 @@ export default function LazadaAnalyticsPage() {
{/* Preset Buttons */} {/* Preset Buttons */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" /> <Calendar className="w-4 h-4 text-slate-500 mr-1" />
{PRESETS.map(p => ( {PRESET_KEYS.map(p => (
<button <button
key={p.key} key={p}
onClick={() => applyPreset(p.key)} onClick={() => applyPreset(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${ className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
preset === p.key preset === p
? `${shopCfg.activeBg} ${shopCfg.color} ${shopCfg.activeBorder}` ? `${shopCfg.activeBg} ${shopCfg.color} ${shopCfg.activeBorder}`
: 'bg-white/5 text-slate-400 border border-white/10 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-slate-400 border border-white/10 hover:bg-white/10 hover:text-white'
}`} }`}
> >
{p.label} {t(`lazadaAnalytics.presets.${p}`)}
</button> </button>
))} ))}
</div> </div>
@@ -258,14 +249,14 @@ export default function LazadaAnalyticsPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('lazadaAnalytics.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={e => setDateFrom(e.target.value)} onChange={e => setDateFrom(e.target.value)}
className={`px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:${shopCfg.activeBorder} focus:outline-none`} className={`px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:${shopCfg.activeBorder} focus:outline-none`}
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('lazadaAnalytics.to', 'ถึง')}</span>
<input <input
type="date" type="date"
value={dateTo} value={dateTo}
@@ -276,7 +267,7 @@ export default function LazadaAnalyticsPage() {
onClick={applyCustom} onClick={applyCustom}
className={`px-4 py-1.5 rounded-lg ${shopCfg.activeBg} ${shopCfg.activeBorder} ${shopCfg.color} text-xs font-medium hover:opacity-80 transition-all`} className={`px-4 py-1.5 rounded-lg ${shopCfg.activeBg} ${shopCfg.activeBorder} ${shopCfg.color} text-xs font-medium hover:opacity-80 transition-all`}
> >
ดูข้อมูล {t('lazadaAnalytics.btnApply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -290,7 +281,7 @@ export default function LazadaAnalyticsPage() {
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors"
> >
✕ ล้าง {t('lazadaAnalytics.btnClear', '✕ ล้าง')}
</button> </button>
</div> </div>
)} )}
@@ -298,25 +289,77 @@ export default function LazadaAnalyticsPage() {
{/* KPI Cards Row 1 */} {/* KPI Cards Row 1 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="ยอดขายรวม (Paid Price)" value={fmtCurrency(summary.totalRevenue)} icon={DollarSign} color="text-emerald-400" bgColor="from-emerald-500/10" sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} /> <StatCard
<StatCard label="ยอดสุทธิ (Net from shipping)" value={fmtCurrency(summary.netRevenue)} icon={TrendingUp} color="text-cyan-glow" bgColor="from-cyan-500/10" sub={`รายได้หลังหักค่าส่งจัดส่ง`} /> label={t('lazadaAnalytics.kpiGrossRevenue', 'ยอดขายรวม (Paid Price)')}
<StatCard label="ส่วนลด Platform (Lazada)" value={fmtCurrency(summary.totalPlatformDisc)} icon={BadgePercent} color="text-purple-400" bgColor="from-purple-500/10" sub="Lazada อุดหนุนส่วนลดให้" /> value={fmtCurrency(summary.totalRevenue)}
<StatCard label="ส่วนลดร้านค้า (เราออก)" value={fmtCurrency(summary.totalSellerDisc)} icon={ReceiptText} color="text-rose-400" bgColor="from-rose-500/10" sub="ส่วนลดที่เราให้ลูกค้า" /> icon={DollarSign}
color="text-emerald-400"
bgColor="from-emerald-500/10"
sub={t('lazadaAnalytics.kpiGrossSub', { count: fmt(summary.totalOrders), defaultValue: `${fmt(summary.totalOrders)} คำสั่งซื้อ` })}
/>
<StatCard
label={t('lazadaAnalytics.kpiNetRevenue', 'ยอดสุทธิ (Net from shipping)')}
value={fmtCurrency(summary.netRevenue)}
icon={TrendingUp}
color="text-cyan-glow"
bgColor="from-cyan-500/10"
sub={t('lazadaAnalytics.kpiNetSub', 'รายได้หลังหักค่าส่งจัดส่ง')}
/>
<StatCard
label={t('lazadaAnalytics.kpiPlatformDiscount', 'ส่วนลด Platform (Lazada)')}
value={fmtCurrency(summary.totalPlatformDisc)}
icon={BadgePercent}
color="text-purple-400"
bgColor="from-purple-500/10"
sub={t('lazadaAnalytics.kpiPlatformDiscSub', 'Lazada อุดหนุนส่วนลดให้')}
/>
<StatCard
label={t('lazadaAnalytics.kpiSellerDiscount', 'ส่วนลดร้านค้า (เราออก)')}
value={fmtCurrency(summary.totalSellerDisc)}
icon={ReceiptText}
color="text-rose-400"
bgColor="from-rose-500/10"
sub={t('lazadaAnalytics.kpiSellerDiscSub', 'ส่วนลดที่เราให้ลูกค้า')}
/>
</div> </div>
{/* KPI Cards Row 2 */} {/* KPI Cards Row 2 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} /> <StatCard
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} /> label={t('lazadaAnalytics.kpiCompletedOrders', 'คำสั่งซื้อสำเร็จ')}
<StatCard label="ค่าจัดส่งรวม" value={fmtCurrency(summary.totalShipping)} icon={Package} color="text-amber-400" sub="ค่าจัดส่งทั้งหมด" /> value={fmt(summary.completedOrders)}
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" /> icon={CheckCircle2}
color="text-emerald-400"
sub={t('lazadaAnalytics.kpiCompletedSub', { total: fmt(summary.totalOrders), defaultValue: `จากทั้งหมด ${fmt(summary.totalOrders)}` })}
/>
<StatCard
label={t('lazadaAnalytics.kpiCancelledOrders', 'คำสั่งซื้อยกเลิก')}
value={fmt(summary.cancelledOrders)}
icon={XCircle}
color="text-rose-400"
sub={t('lazadaAnalytics.kpiCancelledSub', { rate: fmt(summary.cancelRate, 1), defaultValue: `${fmt(summary.cancelRate, 1)}% ของทั้งหมด` })}
/>
<StatCard
label={t('lazadaAnalytics.kpiShipping', 'ค่าจัดส่งรวม')}
value={fmtCurrency(summary.totalShipping)}
icon={Package}
color="text-amber-400"
sub={t('lazadaAnalytics.kpiShippingSub', 'ค่าจัดส่งทั้งหมด')}
/>
<StatCard
label={t('lazadaAnalytics.kpiAov', 'AOV (เฉลี่ยต่อออเดอร์)')}
value={fmtCurrency(summary.aov)}
icon={ShoppingCart}
color="text-blue-400"
sub={t('lazadaAnalytics.kpiAovSub', 'Average Order Value')}
/>
</div> </div>
{/* Monthly Revenue Chart */} {/* Monthly Revenue Chart */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className={`w-5 h-5 ${shopCfg.color}`} /> <TrendingUp className={`w-5 h-5 ${shopCfg.color}`} />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaAnalytics.monthlyRevenueTitle', 'ยอดขายรายเดือน')}</h2>
</div> </div>
{monthlyChart.length > 0 ? ( {monthlyChart.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -327,14 +370,14 @@ export default function LazadaAnalyticsPage() {
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} /> <Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
<Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" name={t('lazadaAnalytics.barGrossRevenue', '฿ ยอดขายรวม')} fill="#4ade80" radius={[4, 4, 0, 0]} />
<Bar dataKey="platformDiscount" name="฿ ส่วนลด Platform" fill="#c084fc" radius={[4, 4, 0, 0]} /> <Bar dataKey="platformDiscount" name={t('lazadaAnalytics.barPlatformDiscount', '฿ ส่วนลด Platform')} fill="#c084fc" radius={[4, 4, 0, 0]} />
<Bar dataKey="sellerDiscount" name="฿ ส่วนลดร้านค้า" fill="#fb7185" radius={[4, 4, 0, 0]} /> <Bar dataKey="sellerDiscount" name={t('lazadaAnalytics.barSellerDiscount', '฿ ส่วนลดร้านค้า')} fill="#fb7185" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('lazadaAnalytics.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}</p>
)} )}
</div> </div>
@@ -343,7 +386,7 @@ export default function LazadaAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<ShoppingBag className="w-5 h-5 text-cyan-glow" /> <ShoppingBag className="w-5 h-5 text-cyan-glow" />
<h2 className="text-lg font-bold text-white">จำนวนคำสั่งซื้อรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaAnalytics.monthlyOrdersTitle', 'จำนวนคำสั่งซื้อรายเดือน')}</h2>
</div> </div>
<div className="h-[220px]"> <div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -352,8 +395,8 @@ export default function LazadaAnalyticsPage() {
<XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} /> <XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} />
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="orders" name="คำสั่งซื้อ" stroke={shop === 'hom-aroma' ? '#3b82f6' : '#a78bfa'} strokeWidth={2} dot={{ r: 4 }} /> <Line type="monotone" dataKey="orders" name={t('lazadaAnalytics.lineOrders', 'คำสั่งซื้อ')} stroke={shop === 'hom-aroma' ? '#3b82f6' : '#a78bfa'} strokeWidth={2} dot={{ r: 4 }} />
<Line type="monotone" dataKey="items" name="จำนวนชิ้น" stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} /> <Line type="monotone" dataKey="items" name={t('lazadaAnalytics.lineItems', 'จำนวนชิ้น')} stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -367,7 +410,7 @@ export default function LazadaAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<CheckCircle2 className="w-5 h-5 text-emerald-400" /> <CheckCircle2 className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนสถานะคำสั่งซื้อ</h2> <h2 className="text-lg font-bold text-white">{t('lazadaAnalytics.statusBreakdownTitle', 'สัดส่วนสถานะคำสั่งซื้อ')}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-4 items-center"> <div className="grid grid-cols-2 gap-4 items-center">
<div className="h-[200px]"> <div className="h-[200px]">
@@ -399,7 +442,7 @@ export default function LazadaAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<DollarSign className="w-5 h-5 text-amber-400" /> <DollarSign className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">ช่องทางการชำระเงิน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaAnalytics.paymentMethodTitle', 'ช่องทางการชำระเงิน')}</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{paymentBreakdown.map((p: any, i: number) => { {paymentBreakdown.map((p: any, i: number) => {
@@ -407,8 +450,8 @@ export default function LazadaAnalyticsPage() {
return ( return (
<div key={i} className="space-y-1"> <div key={i} className="space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method || 'ไม่ระบุ'}</span> <span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method || t('lazadaAnalytics.unspecifiedPayment', 'ไม่ระบุ')}</span>
<span className="text-slate-400 ml-2">{p.count} ออเดอร์ · {fmt(pct, 0)}%</span> <span className="text-slate-400 ml-2">{t('lazadaAnalytics.ordersCountAndPct', { count: p.count, pct: fmt(pct, 0), defaultValue: `${p.count} ออเดอร์ · ${fmt(pct, 0)}%` })}</span>
</div> </div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden"> <div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div <div
@@ -427,18 +470,18 @@ export default function LazadaAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี (by ยอดเงิน)</h2> <h2 className="text-lg font-bold text-white">{t('lazadaAnalytics.topProductsTitle', 'Top 10 สินค้าขายดี (by ยอดเงิน)')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10"> <thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10">
<tr> <tr>
<th className="px-4 py-3">#</th> <th className="px-4 py-3">{t('lazadaAnalytics.thIndex', '#')}</th>
<th className="px-4 py-3">สินค้า</th> <th className="px-4 py-3">{t('lazadaAnalytics.thProduct', 'สินค้า')}</th>
<th className="px-4 py-3">ตัวเลือก</th> <th className="px-4 py-3">{t('lazadaAnalytics.thVariation', 'ตัวเลือก')}</th>
<th className="px-4 py-3 text-right">ชิ้น</th> <th className="px-4 py-3 text-right">{t('lazadaAnalytics.thUnits', 'ชิ้น')}</th>
<th className="px-4 py-3 text-right">ยอดรายได้</th> <th className="px-4 py-3 text-right">{t('lazadaAnalytics.thRevenue', 'ยอดรายได้')}</th>
<th className="px-4 py-3 text-right">% จากยอดรวม</th> <th className="px-4 py-3 text-right">{t('lazadaAnalytics.thPercentTotal', '% จากยอดรวม')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
+36 -34
View File
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { Upload, ShoppingBag, CheckCircle, AlertTriangle, ChevronLeft, ChevronRight, Search, Loader2, DollarSign, Package, Trash2 } from 'lucide-react'; import { Upload, ShoppingBag, CheckCircle, AlertTriangle, ChevronLeft, ChevronRight, Search, Loader2, DollarSign, Package, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '');
@@ -11,8 +12,6 @@ export const LAZADA_SHOPS = [
{ id: 'homaromashop', label: 'Relax', color: 'text-yellow-400', activeBg: 'bg-yellow-500/15', activeBorder: 'border-yellow-500/40' }, { id: 'homaromashop', label: 'Relax', color: 'text-yellow-400', activeBg: 'bg-yellow-500/15', activeBorder: 'border-yellow-500/40' },
{ id: 'conner', label: 'Conner', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' }, { id: 'conner', label: 'Conner', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' },
{ id: 'december', label: 'December', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' }, { id: 'december', label: 'December', color: 'text-white', activeBg: 'bg-white/15', activeBorder: 'border-white/40' },
]; ];
function getShop(id: string) { return LAZADA_SHOPS.find(s => s.id === id) || LAZADA_SHOPS[0]; } function getShop(id: string) { return LAZADA_SHOPS.find(s => s.id === id) || LAZADA_SHOPS[0]; }
@@ -23,6 +22,7 @@ function authHeaders() {
// ── Summary Cards ───────────────────────────────────────────────────────────── // ── Summary Cards ─────────────────────────────────────────────────────────────
function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number }) { function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number }) {
const { t } = useTranslation();
const [data, setData] = useState({ orderCount: 0, totalAmount: 0, totalShipping: 0 }); const [data, setData] = useState({ orderCount: 0, totalAmount: 0, totalShipping: 0 });
useEffect(() => { useEffect(() => {
@@ -32,9 +32,9 @@ function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number }
}, [shop, refreshKey]); }, [shop, refreshKey]);
const cards = [ const cards = [
{ label: 'คำสั่งซื้อทั้งหมด', value: data.orderCount.toLocaleString(), icon: ShoppingBag, color: 'text-white' }, { label: t('lazadaOrder.totalOrders', 'คำสั่งซื้อทั้งหมด'), value: data.orderCount.toLocaleString(), icon: ShoppingBag, color: 'text-white' },
{ label: 'ยอดขายรวม (Gross)', value: `฿${data.totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: DollarSign, color: 'text-emerald-400' }, { label: t('lazadaOrder.grossSales', 'ยอดขายรวม (Gross)'), value: `฿${data.totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: DollarSign, color: 'text-emerald-400' },
{ label: 'ค่าส่งรวม', value: `฿${data.totalShipping.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: Package, color: 'text-amber-400' }, { label: t('lazadaOrder.totalShipping', 'ค่าส่งรวม'), value: `฿${data.totalShipping.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, icon: Package, color: 'text-amber-400' },
]; ];
return ( return (
@@ -57,6 +57,7 @@ function SummaryCards({ shop, refreshKey }: { shop: string; refreshKey: number }
// ── Orders Table ────────────────────────────────────────────────────────────── // ── Orders Table ──────────────────────────────────────────────────────────────
function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) { function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) {
const { t } = useTranslation();
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -85,7 +86,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm text-slate-400">ทั้งหมด <span className="text-white font-bold">{total.toLocaleString()}</span> คำสั่งซื้อ</p> <p className="text-sm text-slate-400">{t('lazadaOrder.totalCount', { total: total.toLocaleString() })}</p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button disabled={page <= 1} onClick={() => setPage(p => p - 1)} className="p-1.5 rounded-lg bg-white/5 text-slate-300 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"><ChevronLeft className="w-4 h-4" /></button> <button disabled={page <= 1} onClick={() => setPage(p => p - 1)} className="p-1.5 rounded-lg bg-white/5 text-slate-300 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"><ChevronLeft className="w-4 h-4" /></button>
<span className="text-slate-400 text-xs">{page} / {totalPages}</span> <span className="text-slate-400 text-xs">{page} / {totalPages}</span>
@@ -97,21 +98,21 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-400 uppercase bg-white/5 border-b border-white/10"> <thead className="text-xs text-slate-400 uppercase bg-white/5 border-b border-white/10">
<tr> <tr>
<th className="px-4 py-3 whitespace-nowrap">Order ID</th> <th className="px-4 py-3 whitespace-nowrap">{t('lazadaOrder.thOrderId', 'Order ID')}</th>
<th className="px-4 py-3 whitespace-nowrap">สถานะ</th> <th className="px-4 py-3 whitespace-nowrap">{t('lazadaOrder.thStatus', 'สถานะ')}</th>
<th className="px-4 py-3 whitespace-nowrap">ผู้ซื้อ</th> <th className="px-4 py-3 whitespace-nowrap">{t('lazadaOrder.thCustomer', 'ผู้ซื้อ')}</th>
<th className="px-4 py-3 whitespace-nowrap">วันที่สร้าง</th> <th className="px-4 py-3 whitespace-nowrap">{t('lazadaOrder.thDate', 'วันที่สร้าง')}</th>
<th className="px-4 py-3 whitespace-nowrap">SKUs</th> <th className="px-4 py-3 whitespace-nowrap">{t('lazadaOrder.thSkus', 'SKUs')}</th>
<th className="px-4 py-3 whitespace-nowrap text-right">ยอดรวม (฿)</th> <th className="px-4 py-3 whitespace-nowrap text-right">{t('lazadaOrder.thTotal', 'ยอดรวม (฿)')}</th>
<th className="px-4 py-3 whitespace-nowrap text-right">ค่าส่ง (฿)</th> <th className="px-4 py-3 whitespace-nowrap text-right">{t('lazadaOrder.thShipping', 'ค่าส่ง (฿)')}</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{loading ? ( {loading ? (
<tr><td colSpan={8} className="px-4 py-10 text-center text-slate-500">กำลังโหลด...</td></tr> <tr><td colSpan={8} className="px-4 py-10 text-center text-slate-500">{t('lazadaOrder.loading', 'กำลังโหลด...')}</td></tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr><td colSpan={8} className="px-4 py-16 text-center text-slate-500">ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน</td></tr> <tr><td colSpan={8} className="px-4 py-16 text-center text-slate-500">{t('lazadaOrder.noData', 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน')}</td></tr>
) : items.map((order) => ( ) : items.map((order) => (
<tr key={order.orderNumber} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors group"> <tr key={order.orderNumber} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors group">
<td className="px-4 py-3 font-mono text-xs text-blue-400 font-bold">{order.orderNumber}</td> <td className="px-4 py-3 font-mono text-xs text-blue-400 font-bold">{order.orderNumber}</td>
@@ -132,7 +133,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">฿{order.orderAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td> <td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">฿{order.orderAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td className="px-4 py-3 text-right font-mono text-slate-400">฿{order.shippingFee.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td> <td className="px-4 py-3 text-right font-mono text-slate-400">฿{order.shippingFee.toLocaleString(undefined, { minimumFractionDigits: 2 })}</td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button className="text-[10px] px-2 py-1 bg-white/5 hover:bg-white/10 text-slate-300 rounded border border-white/10 transition-colors">รายละเอียด</button> <button className="text-[10px] px-2 py-1 bg-white/5 hover:bg-white/10 text-slate-300 rounded border border-white/10 transition-colors">{t('lazadaOrder.btnDetails', 'รายละเอียด')}</button>
</td> </td>
</tr> </tr>
))} ))}
@@ -145,6 +146,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
// ── Main Page ───────────────────────────────────────────────────────────────── // ── Main Page ─────────────────────────────────────────────────────────────────
export default function LazadaOrderPage() { export default function LazadaOrderPage() {
const { t } = useTranslation();
const [shop, setShop] = useState(LAZADA_SHOPS[0].id); const [shop, setShop] = useState(LAZADA_SHOPS[0].id);
const [mode, setMode] = useState<'list' | 'import'>('list'); const [mode, setMode] = useState<'list' | 'import'>('list');
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
@@ -180,7 +182,7 @@ export default function LazadaOrderPage() {
const rows = XLSX.utils.sheet_to_json(sheet); const rows = XLSX.utils.sheet_to_json(sheet);
if (rows.length === 0) { if (rows.length === 0) {
throw new Error("ไฟล์ไม่มีข้อมูล"); throw new Error(t('lazadaOrder.errNoData', 'ไฟล์ไม่มีข้อมูล'));
} }
const res = await fetch(`${API_URL}/api/lazada-order/import`, { const res = await fetch(`${API_URL}/api/lazada-order/import`, {
@@ -196,23 +198,23 @@ export default function LazadaOrderPage() {
if (fileRef.current) fileRef.current.value = ''; if (fileRef.current) fileRef.current.value = '';
setRefreshKey(k => k + 1); // trigger list refresh setRefreshKey(k => k + 1); // trigger list refresh
} else { } else {
setImportStatus({ type: 'error', msg: resData.message || 'นำเข้าไม่สำเร็จ' }); setImportStatus({ type: 'error', msg: resData.message || t('lazadaOrder.errImportFail', 'นำเข้าไม่สำเร็จ') });
} }
} catch (err: any) { } catch (err: any) {
setImportStatus({ type: 'error', msg: err.message || 'เกิดข้อผิดพลาดในการอ่านไฟล์ Excel' }); setImportStatus({ type: 'error', msg: err.message || t('lazadaOrder.errReadExcel', 'เกิดข้อผิดพลาดในการอ่านไฟล์ Excel') });
} finally { } finally {
setImporting(false); setImporting(false);
} }
}; };
reader.onerror = () => { reader.onerror = () => {
setImportStatus({ type: 'error', msg: 'เกิดข้อผิดพลาดในการอ่านไฟล์' }); setImportStatus({ type: 'error', msg: t('lazadaOrder.errReadFile', 'เกิดข้อผิดพลาดในการอ่านไฟล์') });
setImporting(false); setImporting(false);
}; };
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
}; };
const handleClearData = async () => { const handleClearData = async () => {
if (!confirm(`ยืนยันการลบข้อมูล Lazada ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; if (!confirm(t('lazadaOrder.clearConfirm', { shop: shopCfg.label, defaultValue: `ยืนยันการลบข้อมูล Lazada ทั้งหมดของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้` }))) return;
setClearing(true); setClearing(true);
setImportStatus(null); setImportStatus(null);
try { try {
@@ -225,10 +227,10 @@ export default function LazadaOrderPage() {
setImportStatus({ type: 'success', msg: data.message }); setImportStatus({ type: 'success', msg: data.message });
setRefreshKey(k => k + 1); setRefreshKey(k => k + 1);
} else { } else {
setImportStatus({ type: 'error', msg: data.message || 'ลบข้อมูลไม่สำเร็จ' }); setImportStatus({ type: 'error', msg: data.message || t('lazadaOrder.errClearFail', 'ลบข้อมูลไม่สำเร็จ') });
} }
} catch (err: any) { } catch (err: any) {
setImportStatus({ type: 'error', msg: err.message || 'เกิดข้อผิดพลาดในการเชื่อมต่อ' }); setImportStatus({ type: 'error', msg: err.message || t('lazadaOrder.errConn', 'เกิดข้อผิดพลาดในการเชื่อมต่อ') });
} finally { } finally {
setClearing(false); setClearing(false);
} }
@@ -243,10 +245,10 @@ export default function LazadaOrderPage() {
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1"> <div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<span className={shopCfg.color}>Lazada</span> <span className={shopCfg.color}>Lazada</span>
<span>/</span> <span>/</span>
<span className="text-white">Orders</span> <span className="text-white">{t('lazadaOrder.breadcrumb', 'Orders')}</span>
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
คำสั่งซื้อ — {t('lazadaOrder.breadcrumb', 'คำสั่งซื้อ')} —
<span className={shopCfg.color}>{shopCfg.label}</span> <span className={shopCfg.color}>{shopCfg.label}</span>
</h1> </h1>
</div> </div>
@@ -274,23 +276,23 @@ export default function LazadaOrderPage() {
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${mode === 'list' ? `bg-white/10 text-white` : 'text-slate-400 hover:text-white hover:bg-white/5' className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${mode === 'list' ? `bg-white/10 text-white` : 'text-slate-400 hover:text-white hover:bg-white/5'
}`} }`}
> >
<Search className="w-4 h-4" /> ดูรายการคำสั่งซื้อ <Search className="w-4 h-4" /> {t('lazadaOrder.tabList', 'ดูรายการคำสั่งซื้อ')}
</button> </button>
<button <button
onClick={() => setMode('import')} onClick={() => setMode('import')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${mode === 'import' ? `${shopCfg.activeBg} ${shopCfg.color}` : 'text-slate-400 hover:text-white hover:bg-white/5' className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-bold transition-all ${mode === 'import' ? `${shopCfg.activeBg} ${shopCfg.color}` : 'text-slate-400 hover:text-white hover:bg-white/5'
}`} }`}
> >
<Upload className="w-4 h-4" /> อัปโหลด Excel <Upload className="w-4 h-4" /> {t('lazadaOrder.tabImport', 'อัปโหลด Excel')}
</button> </button>
</div> </div>
{mode === 'import' ? ( {mode === 'import' ? (
<div className="max-w-2xl"> <div className="max-w-2xl">
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-2">นำเข้าไฟล์ยอดขาย Lazada (Excel)</h2> <h2 className="text-lg font-bold text-white mb-2">{t('lazadaOrder.importTitle', 'นำเข้าไฟล์ยอดขาย Lazada (Excel)')}</h2>
<p className="text-sm text-slate-400 mb-6"> <p className="text-sm text-slate-400 mb-6">
โหลดไฟล์ Export จาก Lazada Seller Center (ไฟล์ .xlsx หรือ .xls) {t('lazadaOrder.importSubtitle', 'โหลดไฟล์ Export จาก Lazada Seller Center (ไฟล์ .xlsx หรือ .xls)')}
</p> </p>
<div className="space-y-4"> <div className="space-y-4">
@@ -324,14 +326,14 @@ export default function LazadaOrderPage() {
}`} }`}
> >
{importing ? ( {importing ? (
<><Loader2 className="w-5 h-5 animate-spin" /> กำลังประมวลผล...</> <><Loader2 className="w-5 h-5 animate-spin" /> {t('lazadaOrder.importProcessing', 'กำลังประมวลผล...')}</>
) : ( ) : (
<><Upload className="w-5 h-5" /> นำเข้าข้อมูลเข้าฐานข้อมูล</> <><Upload className="w-5 h-5" /> {t('lazadaOrder.importBtn', 'นำเข้าข้อมูลเข้าฐานข้อมูล')}</>
)} )}
</button> </button>
<div className="pt-6 mt-6 border-t border-white/10"> <div className="pt-6 mt-6 border-t border-white/10">
<p className="text-sm text-slate-400 mb-3">ต้องการล้างข้อมูลของร้านนี้เพื่ออัปโหลดใหม่ใช่หรือไม่?</p> <p className="text-sm text-slate-400 mb-3">{t('lazadaOrder.clearPrompt', 'ต้องการล้างข้อมูลของร้านนี้เพื่ออัปโหลดใหม่ใช่หรือไม่?')}</p>
<button <button
onClick={handleClearData} onClick={handleClearData}
disabled={clearing} disabled={clearing}
@@ -342,9 +344,9 @@ export default function LazadaOrderPage() {
}`} }`}
> >
{clearing ? ( {clearing ? (
<><Loader2 className="w-5 h-5 animate-spin" /> กำลังลบข้อมูล...</> <><Loader2 className="w-5 h-5 animate-spin" /> {t('lazadaOrder.clearing', 'กำลังลบข้อมูล...')}</>
) : ( ) : (
<><Trash2 className="w-5 h-5" /> ลบข้อมูลทั้งหมดของร้าน {shopCfg.label}</> <><Trash2 className="w-5 h-5" /> {t('lazadaOrder.clearBtn', { shop: shopCfg.label, defaultValue: `ลบข้อมูลทั้งหมดของร้าน ${shopCfg.label}` })}</>
)} )}
</button> </button>
</div> </div>
+71 -54
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, Legend, PieChart, Pie, Cell, Legend,
@@ -46,17 +47,8 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; }
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: '7 วัน' }, const PRESET_KEYS: Preset[] = ['7d', '30d', '90d', 'thisMonth', 'lastMonth', 'year', 'lastYear', 'last12m', 'all'];
{ key: '30d', label: '30 วัน' },
{ key: '90d', label: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' },
];
function authHeaders() { function authHeaders() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` };
@@ -96,6 +88,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function LazadaOverviewPage() { export default function LazadaOverviewPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -136,7 +129,7 @@ export default function LazadaOverviewPage() {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className="w-8 h-8 text-blue-500 animate-spin" /> <Loader2 className="w-8 h-8 text-blue-500 animate-spin" />
<span className="text-slate-500">กำลังรวมข้อมูลทุกร้าน...</span> <span className="text-slate-500">{t('lazadaOverview.loading', 'กำลังรวมข้อมูลทุกร้าน...')}</span>
</div> </div>
); );
} }
@@ -145,9 +138,9 @@ export default function LazadaOverviewPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<Store className="w-14 h-14 text-slate-700" /> <Store className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน'}</p> <p className="text-slate-500">{error || t('lazadaOverview.noData', 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน')}</p>
<Link to="/lazada-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm"> <Link to="/lazada-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm">
← กลับไปหน้า Lazada Orders {t('lazadaOverview.backToOrders', '← กลับไปหน้า Lazada Orders')}
</Link> </Link>
</div> </div>
); );
@@ -168,8 +161,8 @@ export default function LazadaOverviewPage() {
<Store className="w-6 h-6 text-white" /> <Store className="w-6 h-6 text-white" />
</div> </div>
<div> <div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Lazada</p> <p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">{t('lazadaOverview.breadcrumb', 'Lazada')}</p>
<h1 className="text-3xl font-bold text-white tracking-tight">ภาพรวมทุกร้าน</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('lazadaOverview.title', 'ภาพรวมทุกร้าน')}</h1>
</div> </div>
</div> </div>
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
@@ -191,17 +184,17 @@ export default function LazadaOverviewPage() {
{/* Preset Buttons */} {/* Preset Buttons */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" /> <Calendar className="w-4 h-4 text-slate-500 mr-1" />
{PRESETS.map(p => ( {PRESET_KEYS.map(p => (
<button <button
key={p.key} key={p}
onClick={() => applyPreset(p.key)} onClick={() => applyPreset(p)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${ className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
preset === p.key preset === p
? 'bg-blue-500/15 text-blue-400 border-blue-500/30' ? 'bg-blue-500/15 text-blue-400 border-blue-500/30'
: 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white'
}`} }`}
> >
{p.label} {t(`lazadaOverview.presets.${p}`)}
</button> </button>
))} ))}
</div> </div>
@@ -210,12 +203,12 @@ export default function LazadaOverviewPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('lazadaOverview.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none"
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('lazadaOverview.to', 'ถึง')}</span>
<input <input
type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-blue-500/40 focus:outline-none"
@@ -224,7 +217,7 @@ export default function LazadaOverviewPage() {
onClick={applyCustom} onClick={applyCustom}
className="px-4 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-all" className="px-4 py-1.5 rounded-lg bg-blue-500/10 border border-blue-500/20 text-blue-400 text-xs font-medium hover:bg-blue-500/20 transition-all"
> >
ดูข้อมูล {t('lazadaOverview.btnApply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -238,7 +231,7 @@ export default function LazadaOverviewPage() {
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors"
> >
✕ ล้าง {t('lazadaOverview.btnClear', '✕ ล้าง')}
</button> </button>
</div> </div>
)} )}
@@ -246,10 +239,34 @@ export default function LazadaOverviewPage() {
{/* Grand KPI Row */} {/* Grand KPI Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard label="ยอดขายรวมทุกร้าน" value={fmtB(summary.totalRevenue)} sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} color="text-emerald-400" icon={DollarSign} /> <KPICard
<KPICard label="รายได้สุทธิรวม (หักค่าส่ง)" value={fmtB(summary.netRevenue)} sub={`Margin ${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%`} color="text-cyan-glow" icon={TrendingUp} /> label={t('lazadaOverview.grandRevenue', 'ยอดขายรวมทุกร้าน')}
<KPICard label="ส่วนลด Platform รวม" value={fmtB(summary.totalPlatformDisc)} sub="Lazada อุดหนุนส่วนลด" color="text-purple-400" icon={BadgePercent} /> value={fmtB(summary.totalRevenue)}
<KPICard label="AOV รวม" value={fmtB(summary.aov)} sub={`ยกเลิก ${fmt(summary.cancelRate, 1)}%`} color="text-amber-400" icon={ShoppingCart} /> sub={t('lazadaOverview.ordersCount', { count: fmt(summary.totalOrders), defaultValue: `${fmt(summary.totalOrders)} คำสั่งซื้อ` })}
color="text-emerald-400"
icon={DollarSign}
/>
<KPICard
label={t('lazadaOverview.grandNetRevenue', 'รายได้สุทธิรวม (หักค่าส่ง)')}
value={fmtB(summary.netRevenue)}
sub={t('lazadaOverview.marginSub', { margin: fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1), defaultValue: `Margin ${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%` })}
color="text-cyan-glow"
icon={TrendingUp}
/>
<KPICard
label={t('lazadaOverview.grandPlatformDiscount', 'ส่วนลด Platform รวม')}
value={fmtB(summary.totalPlatformDisc)}
sub={t('lazadaOverview.platformSub', 'Lazada อุดหนุนส่วนลด')}
color="text-purple-400"
icon={BadgePercent}
/>
<KPICard
label={t('lazadaOverview.grandAov', 'AOV รวม')}
value={fmtB(summary.aov)}
sub={t('lazadaOverview.cancelRateSub', { rate: fmt(summary.cancelRate, 1), defaultValue: `ยกเลิก ${fmt(summary.cancelRate, 1)}%` })}
color="text-amber-400"
icon={ShoppingCart}
/>
</div> </div>
{/* Per-Shop Cards */} {/* Per-Shop Cards */}
@@ -270,19 +287,19 @@ export default function LazadaOverviewPage() {
<p className="text-[10px] font-mono text-slate-500">{s.shop}</p> <p className="text-[10px] font-mono text-slate-500">{s.shop}</p>
</div> </div>
<span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}> <span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}>
{fmt(revShare, 1)}% of total {t('lazadaOverview.percentOfTotal', { pct: fmt(revShare, 1), defaultValue: `${fmt(revShare, 1)}% of total` })}
</span> </span>
</div> </div>
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
{[ {[
{ label: 'ยอดขาย', value: fmtB(s.revenue), color: 'text-emerald-400' }, { label: t('lazadaOverview.statRevenue', 'ยอดขาย'), value: fmtB(s.revenue), color: 'text-emerald-400' },
{ label: 'ส่วนลด Lazada', value: fmtB(s.platformDiscount), color: 'text-purple-400' }, { label: t('lazadaOverview.statPlatformDiscount', 'ส่วนลด Lazada'), value: fmtB(s.platformDiscount), color: 'text-purple-400' },
{ label: 'คำสั่งซื้อ', value: fmt(s.orders), color: 'text-white' }, { label: t('lazadaOverview.statOrders', 'คำสั่งซื้อ'), value: fmt(s.orders), color: 'text-white' },
{ label: 'ส่วนลดร้าน', value: fmtB(s.sellerDiscount), color: 'text-rose-400' }, { label: t('lazadaOverview.statSellerDiscount', 'ส่วนลดร้าน'), value: fmtB(s.sellerDiscount), color: 'text-rose-400' },
{ label: 'ยกเลิก', value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' }, { label: t('lazadaOverview.statCancelled', 'ยกเลิก'), value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' },
{ label: 'ค่าส่งรวม', value: fmtB(s.shippingFee), color: 'text-slate-300' }, { label: t('lazadaOverview.statShipping', 'ค่าส่งรวม'), value: fmtB(s.shippingFee), color: 'text-slate-300' },
].map(item => ( ].map(item => (
<div key={item.label}> <div key={item.label}>
<p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p> <p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p>
@@ -303,7 +320,7 @@ export default function LazadaOverviewPage() {
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90" className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90"
style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }} style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }}
> >
<TrendingUp className="w-3.5 h-3.5" /> ดู Analytics <TrendingUp className="w-3.5 h-3.5" /> {t('lazadaOverview.viewAnalytics', 'ดู Analytics')}
</Link> </Link>
</div> </div>
); );
@@ -314,7 +331,7 @@ export default function LazadaOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className="w-5 h-5 text-blue-500" /> <TrendingUp className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน (แยกตามร้าน)</h2> <h2 className="text-lg font-bold text-white">{t('lazadaOverview.monthlySalesTitle', 'ยอดขายรายเดือน (แยกตามร้าน)')}</h2>
</div> </div>
{monthly.length > 0 ? ( {monthly.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -332,7 +349,7 @@ export default function LazadaOverviewPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('lazadaOverview.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}</p>
)} )}
</div> </div>
@@ -343,7 +360,7 @@ export default function LazadaOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<BadgePercent className="w-5 h-5 text-amber-400" /> <BadgePercent className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนยอดขายตามร้าน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaOverview.salesShareTitle', 'สัดส่วนยอดขายตามร้าน')}</h2>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="h-[220px] flex-1"> <div className="h-[220px] flex-1">
@@ -375,17 +392,17 @@ export default function LazadaOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Store className="w-5 h-5 text-slate-400" /> <Store className="w-5 h-5 text-slate-400" />
<h2 className="text-lg font-bold text-white">เปรียบเทียบร้าน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaOverview.shopComparisonTitle', 'เปรียบเทียบร้าน')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="text-slate-500 uppercase border-b border-white/10"> <thead className="text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left">ร้าน</th> <th className="pb-3 text-left">{t('lazadaOverview.thShop', 'ร้าน')}</th>
<th className="pb-3 text-right">ยอดขาย</th> <th className="pb-3 text-right">{t('lazadaOverview.thRevenue', 'ยอดขาย')}</th>
<th className="pb-3 text-right">ส่วนลดแพลตฟอร์ม</th> <th className="pb-3 text-right">{t('lazadaOverview.thPlatformDiscount', 'ส่วนลดแพลตฟอร์ม')}</th>
<th className="pb-3 text-right">ออเดอร์</th> <th className="pb-3 text-right">{t('lazadaOverview.thOrders', 'ออเดอร์')}</th>
<th className="pb-3 text-right">ยกเลิก</th> <th className="pb-3 text-right">{t('lazadaOverview.thCancelled', 'ยกเลิก')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -405,7 +422,7 @@ export default function LazadaOverviewPage() {
))} ))}
{/* Total row */} {/* Total row */}
<tr className="bg-white/[0.03]"> <tr className="bg-white/[0.03]">
<td className="py-3 font-bold text-white text-xs">รวมทั้งหมด</td> <td className="py-3 font-bold text-white text-xs">{t('lazadaOverview.totalAll', 'รวมทั้งหมด')}</td>
<td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(summary.totalRevenue)}</td> <td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(summary.totalRevenue)}</td>
<td className="py-3 text-right font-mono font-bold text-purple-400">{fmtB(summary.totalPlatformDisc)}</td> <td className="py-3 text-right font-mono font-bold text-purple-400">{fmtB(summary.totalPlatformDisc)}</td>
<td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(summary.totalOrders)}</td> <td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(summary.totalOrders)}</td>
@@ -421,18 +438,18 @@ export default function LazadaOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี — รวมทุกร้าน</h2> <h2 className="text-lg font-bold text-white">{t('lazadaOverview.topProductsTitle', 'Top 10 สินค้าขายดี — รวมทุกร้าน')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-slate-500 uppercase border-b border-white/10"> <thead className="text-xs text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left w-8">#</th> <th className="pb-3 text-left w-8">{t('lazadaOverview.thIndex', '#')}</th>
<th className="pb-3 text-left">สินค้า</th> <th className="pb-3 text-left">{t('lazadaOverview.thProduct', 'สินค้า')}</th>
<th className="pb-3 text-left">ตัวเลือก</th> <th className="pb-3 text-left">{t('lazadaOverview.thVariation', 'ตัวเลือก')}</th>
<th className="pb-3 text-right">ชิ้น</th> <th className="pb-3 text-right">{t('lazadaOverview.thUnits', 'ชิ้น')}</th>
<th className="pb-3 text-right">ยอดรายได้</th> <th className="pb-3 text-right">{t('lazadaOverview.thRevenueCol', 'ยอดรายได้')}</th>
<th className="pb-3 text-right">% รวม</th> <th className="pb-3 text-right">{t('lazadaOverview.thPercentTotal', '% รวม')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
+19 -7
View File
@@ -1,9 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Lock, User, AlertCircle, ArrowRight, Loader2 } from 'lucide-react' import { Lock, User, AlertCircle, ArrowRight, Loader2 } from 'lucide-react'
import { API_BASE } from '@homaroma/types' import { API_BASE } from '@homaroma/types'
import LanguageSelector from '../components/LanguageSelector'
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
@@ -25,7 +28,7 @@ export default function LoginPage() {
const data = await response.json() const data = await response.json()
if (!response.ok || !data.success) { if (!response.ok || !data.success) {
throw new Error(data.message || 'Login failed') throw new Error(data.message || t('login.invalidCredentials', 'Login failed'))
} }
// Store token // Store token
@@ -43,6 +46,11 @@ export default function LoginPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center p-6 bg-night-950 relative overflow-hidden"> <div className="min-h-screen flex items-center justify-center p-6 bg-night-950 relative overflow-hidden">
{/* Top right language selector */}
<div className="absolute top-6 right-6 z-20">
<LanguageSelector />
</div>
{/* Background Glows */} {/* Background Glows */}
<div className="absolute top-0 right-0 w-[500px] h-[500px] bg-cyan-glow/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2" /> <div className="absolute top-0 right-0 w-[500px] h-[500px] bg-cyan-glow/5 blur-[120px] rounded-full -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-night-700/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2" /> <div className="absolute bottom-0 left-0 w-[500px] h-[500px] bg-night-700/5 blur-[120px] rounded-full translate-y-1/2 -translate-x-1/2" />
@@ -53,9 +61,9 @@ export default function LoginPage() {
Authorized Access Only Authorized Access Only
</div> </div>
<h1 className="text-4xl font-bold tracking-tighter text-white"> <h1 className="text-4xl font-bold tracking-tighter text-white">
HomAroma <span className="text-cyan-glow">Admin</span> HomAroma <span className="text-cyan-glow">{t('login.title', 'Admin')}</span>
</h1> </h1>
<p className="text-slate-500 text-sm">Welcome back to the sensory command center.</p> <p className="text-slate-500 text-sm">{t('login.subtitle', 'Welcome back to the sensory command center.')}</p>
</div> </div>
<form onSubmit={handleLogin} className="admin-card space-y-6 border-white/10 shadow-2xl"> <form onSubmit={handleLogin} className="admin-card space-y-6 border-white/10 shadow-2xl">
@@ -68,7 +76,9 @@ export default function LoginPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Username</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
{t('login.email', 'Username / Email')}
</label>
<div className="relative group"> <div className="relative group">
<User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" /> <User className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" />
<input <input
@@ -83,7 +93,9 @@ export default function LoginPage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Password</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
{t('login.password', 'Password')}
</label>
<div className="relative group"> <div className="relative group">
<Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" /> <Lock className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" />
<input <input
@@ -101,13 +113,13 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}
className="w-full btn-primary py-4 flex items-center justify-center gap-2 group transition-all" className="w-full btn-primary py-4 flex items-center justify-center gap-2 group transition-all cursor-pointer"
> >
{loading ? ( {loading ? (
<Loader2 className="w-5 h-5 animate-spin" /> <Loader2 className="w-5 h-5 animate-spin" />
) : ( ) : (
<> <>
Login to Console {t('login.signIn', 'Sign In')}
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" /> <ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</> </>
)} )}
+41 -26
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom' import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, formatPrice, type Order } from '@homaroma/types' import { API_BASE, formatPrice, type Order } from '@homaroma/types'
import { ArrowLeft, CheckCircle2, ExternalLink, Loader2, Truck, XCircle, FileImage } from 'lucide-react' import { ArrowLeft, CheckCircle2, ExternalLink, Loader2, Truck, XCircle, FileImage } from 'lucide-react'
export default function OrderDetailPage() { export default function OrderDetailPage() {
const { t, i18n } = useTranslation()
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const [order, setOrder] = useState<Order | null>(null) const [order, setOrder] = useState<Order | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@@ -42,6 +44,16 @@ export default function OrderDetailPage() {
} }
} }
const getStatusLabel = (status: string) => {
switch (status) {
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
case 'PAID': return t('orders.statusPaid', 'Paid')
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
default: return status.replace('_', ' ')
}
}
if (loading || !order) { if (loading || !order) {
return ( return (
<div className="flex items-center justify-center p-20 animate-pulse"> <div className="flex items-center justify-center p-20 animate-pulse">
@@ -54,12 +66,12 @@ export default function OrderDetailPage() {
<div className="space-y-8 animate-fade-in pb-12"> <div className="space-y-8 animate-fade-in pb-12">
{/* Header */} {/* Header */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Link to="/orders" className="p-2 glass rounded-lg hover:bg-white/10 transition-colors"> <Link to="/orders" className="p-2 glass rounded-lg hover:bg-white/10 transition-colors cursor-pointer">
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
</Link> </Link>
<div> <div>
<h1 className="text-3xl font-bold font-mono">Order #{order.id.toString().padStart(4, '0')}</h1> <h1 className="text-3xl font-bold font-mono">Order #{order.id.toString().padStart(4, '0')}</h1>
<p className="text-slate-500 text-sm">{new Date(order.createdAt).toLocaleString()}</p> <p className="text-slate-500 text-sm">{new Date(order.createdAt).toLocaleString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')}</p>
</div> </div>
<div className={`ml-auto px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest border ${ <div className={`ml-auto px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest border ${
order.status === 'PAID' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' : order.status === 'PAID' ? 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' :
@@ -67,17 +79,17 @@ export default function OrderDetailPage() {
order.status === 'CANCELLED' ? 'bg-rose-500/10 text-rose-400 border-rose-500/20' : order.status === 'CANCELLED' ? 'bg-rose-500/10 text-rose-400 border-rose-500/20' :
'bg-amber-500/10 text-amber-400 border-amber-500/20' 'bg-amber-500/10 text-amber-400 border-amber-500/20'
}`}> }`}>
{order.status.replace('_', ' ')} {getStatusLabel(order.status)}
</div> </div>
</div> </div>
<div className="grid lg:grid-cols-3 gap-8"> <div className="grid lg:grid-cols-3 gap-8">
{/* Left Col: Order Info & Items */} {/* Left Col: Order Info & Items */}
<div className="lg:col-span-2 space-y-8"> <div className="lg:col-span-2 space-y-8">
<div className="admin-card p-0 overflow-hidden"> <div className="admin-card p-0 overflow-hidden">
<h2 className="p-6 border-b border-white/5 font-bold uppercase tracking-widest text-xs text-slate-400">Order Items</h2> <h2 className="p-6 border-b border-white/5 font-bold uppercase tracking-widest text-xs text-slate-400">
{t('orders.orderItems', 'Order Items')}
</h2>
<div className="divide-y divide-white/5"> <div className="divide-y divide-white/5">
{order.items.map(item => ( {order.items.map(item => (
<div key={item.id} className="p-6 flex items-center gap-4"> <div key={item.id} className="p-6 flex items-center gap-4">
@@ -85,7 +97,7 @@ export default function OrderDetailPage() {
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" /> <img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<p className="font-bold">{item.productName}</p> <p className="font-bold text-white">{item.productName}</p>
<p className="text-xs text-slate-500 uppercase">{item.variantName}</p> <p className="text-xs text-slate-500 uppercase">{item.variantName}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -101,22 +113,24 @@ export default function OrderDetailPage() {
<span>{formatPrice(order.totalAmount - order.shippingFee)}</span> <span>{formatPrice(order.totalAmount - order.shippingFee)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span>Shipping</span> <span>{t('orders.fulfillment', 'Shipping')}</span>
<span>{formatPrice(order.shippingFee)}</span> <span>{formatPrice(order.shippingFee)}</span>
</div> </div>
<div className="flex justify-between text-lg font-bold text-white pt-4 mt-4 border-t border-white/10"> <div className="flex justify-between text-lg font-bold text-white pt-4 mt-4 border-t border-white/10">
<span>Total</span> <span>{t('orders.total', 'Total')}</span>
<span className="text-cyan-glow">{formatPrice(order.totalAmount)}</span> <span className="text-cyan-glow">{formatPrice(order.totalAmount)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="admin-card p-6 space-y-6"> <div className="admin-card p-6 space-y-6">
<h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">Customer Details</h2> <h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">
{t('orders.customerDetails', 'Customer Details')}
</h2>
{order.customer && ( {order.customer && (
<div className="flex items-center gap-4 mb-4 p-4 bg-white/5 rounded-xl border border-cyan-glow/20"> <div className="flex items-center gap-4 mb-4 p-4 bg-white/5 rounded-xl border border-cyan-glow/20">
<img src={order.customer.pictureUrl || `https://ui-avatars.com/api/?name=${order.customer.displayName}`} className="w-12 h-12 rounded-full border-2 border-cyan-glow/50" /> <img src={order.customer.pictureUrl || `https://ui-avatars.com/api/?name=${order.customer.displayName}`} className="w-12 h-12 rounded-full border-2 border-cyan-glow/50" alt="" />
<div> <div>
<p className="text-[10px] uppercase tracking-widest text-cyan-glow font-bold mb-1">LINE Customer</p> <p className="text-[10px] uppercase tracking-widest text-cyan-glow font-bold mb-1">LINE Customer</p>
<p className="font-bold text-white text-lg">{order.customer.displayName}</p> <p className="font-bold text-white text-lg">{order.customer.displayName}</p>
@@ -126,12 +140,12 @@ export default function OrderDetailPage() {
<div className="grid md:grid-cols-2 gap-6"> <div className="grid md:grid-cols-2 gap-6">
<div> <div>
<p className="text-[10px] uppercase text-slate-500 mb-1">Name</p> <p className="text-[10px] uppercase text-slate-500 mb-1">{t('orders.customer', 'Name')}</p>
<p className="font-bold">{order.customerName}</p> <p className="font-bold text-white">{order.customerName}</p>
</div> </div>
<div> <div>
<p className="text-[10px] uppercase text-slate-500 mb-1">Phone</p> <p className="text-[10px] uppercase text-slate-500 mb-1">Phone</p>
<p className="font-bold">{order.customerPhone}</p> <p className="font-bold text-white">{order.customerPhone}</p>
</div> </div>
<div> <div>
<p className="text-[10px] uppercase text-slate-500 mb-1">Email</p> <p className="text-[10px] uppercase text-slate-500 mb-1">Email</p>
@@ -143,14 +157,14 @@ export default function OrderDetailPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* Right Col: Verification & Actions */} {/* Right Col: Verification & Actions */}
<div className="space-y-8"> <div className="space-y-8">
<div className="admin-card p-6 space-y-6"> <div className="admin-card p-6 space-y-6">
<h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">Payment Verification</h2> <h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">
{t('orders.paymentSlip', 'Payment Verification')}
</h2>
{order.paymentSlipUrl ? ( {order.paymentSlipUrl ? (
<div className="space-y-4"> <div className="space-y-4">
@@ -162,7 +176,7 @@ export default function OrderDetailPage() {
> >
<img src={order.paymentSlipUrl} alt="Slip" className="w-full object-cover group-hover:opacity-50 transition-opacity" /> <img src={order.paymentSlipUrl} alt="Slip" className="w-full object-cover group-hover:opacity-50 transition-opacity" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"> <div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="glass px-4 py-2 rounded-full flex items-center gap-2 font-bold text-xs uppercase tracking-widest"> <div className="glass px-4 py-2 rounded-full flex items-center gap-2 font-bold text-xs uppercase tracking-widest text-white">
<ExternalLink className="w-4 h-4" /> Open Original <ExternalLink className="w-4 h-4" /> Open Original
</div> </div>
</div> </div>
@@ -177,34 +191,36 @@ export default function OrderDetailPage() {
</div> </div>
<div className="admin-card p-6 space-y-6"> <div className="admin-card p-6 space-y-6">
<h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">Actions</h2> <h2 className="font-bold uppercase tracking-widest text-xs text-slate-400">
{t('orders.action', 'Actions')}
</h2>
<div className="space-y-3"> <div className="space-y-3">
{order.status === 'PENDING_PAYMENT' && ( {order.status === 'PENDING_PAYMENT' && (
<button <button
onClick={() => updateStatus('PAID')} onClick={() => updateStatus('PAID')}
disabled={updating} disabled={updating}
className="w-full py-3 bg-emerald-500 text-night-900 font-bold uppercase tracking-widest text-xs rounded-lg hover:bg-emerald-400 transition-colors flex items-center justify-center gap-2" className="w-full py-3 bg-emerald-500 text-night-900 font-bold uppercase tracking-widest text-xs rounded-lg hover:bg-emerald-400 transition-colors flex items-center justify-center gap-2 cursor-pointer"
> >
<CheckCircle2 className="w-4 h-4" /> Verify Payment (Mark Paid) <CheckCircle2 className="w-4 h-4" /> {t('orders.markPaid', 'Verify Payment (Mark Paid)')}
</button> </button>
)} )}
{order.status === 'PAID' && ( {order.status === 'PAID' && (
<button <button
onClick={() => updateStatus('SHIPPED')} onClick={() => updateStatus('SHIPPED')}
disabled={updating} disabled={updating}
className="w-full py-3 bg-cyan-glow text-night-900 font-bold uppercase tracking-widest text-xs rounded-lg hover:shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all flex items-center justify-center gap-2" className="w-full py-3 bg-cyan-glow text-night-900 font-bold uppercase tracking-widest text-xs rounded-lg hover:shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all flex items-center justify-center gap-2 cursor-pointer"
> >
<Truck className="w-4 h-4" /> Mark as Shipped <Truck className="w-4 h-4" /> {t('orders.markShipped', 'Mark as Shipped')}
</button> </button>
)} )}
{order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && ( {order.status !== 'CANCELLED' && order.status !== 'SHIPPED' && (
<button <button
onClick={() => updateStatus('CANCELLED')} onClick={() => updateStatus('CANCELLED')}
disabled={updating} disabled={updating}
className="w-full py-3 bg-rose-500/10 text-rose-500 font-bold uppercase tracking-widest text-xs rounded-lg hover:bg-rose-500/20 transition-colors border border-rose-500/20 flex items-center justify-center gap-2 mt-4" className="w-full py-3 bg-rose-500/10 text-rose-500 font-bold uppercase tracking-widest text-xs rounded-lg hover:bg-rose-500/20 transition-colors border border-rose-500/20 flex items-center justify-center gap-2 mt-4 cursor-pointer"
> >
<XCircle className="w-4 h-4" /> Cancel Order <XCircle className="w-4 h-4" /> {t('orders.markCancelled', 'Cancel Order')}
</button> </button>
)} )}
{order.status === 'SHIPPED' && ( {order.status === 'SHIPPED' && (
@@ -214,7 +230,6 @@ export default function OrderDetailPage() {
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
+58 -36
View File
@@ -1,9 +1,11 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, formatPrice, type Order } from '@homaroma/types' import { API_BASE, formatPrice, type Order } from '@homaroma/types'
import { Search, ChevronRight, Clock, CheckCircle2, Truck, XCircle } from 'lucide-react' import { Search, ChevronRight, Clock, CheckCircle2, Truck, XCircle } from 'lucide-react'
export default function OrdersPage() { export default function OrdersPage() {
const { t, i18n } = useTranslation()
const [orders, setOrders] = useState<Order[]>([]) const [orders, setOrders] = useState<Order[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<string>('ALL') const [filter, setFilter] = useState<string>('ALL')
@@ -14,7 +16,7 @@ export default function OrdersPage() {
try { try {
const res = await fetch(`${API_BASE}/orders`) const res = await fetch(`${API_BASE}/orders`)
const data = await res.json() const data = await res.json()
setOrders(data) setOrders(Array.isArray(data) ? data : [])
} catch (err) { } catch (err) {
console.error(err) console.error(err)
} finally { } finally {
@@ -26,13 +28,13 @@ export default function OrdersPage() {
fetchData() fetchData()
}, []) }, [])
const filteredOrders = orders.filter(o => { const filteredOrders = orders.filter((o) => {
const matchesFilter = filter === 'ALL' || o.status === filter const matchesFilter = filter === 'ALL' || o.status === filter
const searchLower = searchTerm.toLowerCase() const searchLower = searchTerm.toLowerCase()
const matchesSearch = const matchesSearch =
o.id.toString().includes(searchLower) || o.id.toString().includes(searchLower) ||
o.customerName.toLowerCase().includes(searchLower) || (o.customerName && o.customerName.toLowerCase().includes(searchLower)) ||
o.customerPhone.includes(searchLower) || (o.customerPhone && o.customerPhone.includes(searchLower)) ||
(o.customer?.displayName?.toLowerCase() || '').includes(searchLower) (o.customer?.displayName?.toLowerCase() || '').includes(searchLower)
return matchesFilter && matchesSearch return matchesFilter && matchesSearch
@@ -48,37 +50,55 @@ export default function OrdersPage() {
} }
} }
const getStatusLabel = (status: string) => {
switch (status) {
case 'PENDING_PAYMENT': return t('orders.statusPending', 'Pending Payment')
case 'PAID': return t('orders.statusPaid', 'Paid')
case 'SHIPPED': return t('orders.statusShipped', 'Shipped')
case 'CANCELLED': return t('orders.statusCancelled', 'Cancelled')
default: return status.replace('_', ' ')
}
}
const filterTabs = [
{ key: 'ALL', label: t('orders.statusAll', 'All') },
{ key: 'PENDING_PAYMENT', label: t('orders.statusPending', 'Pending Payment') },
{ key: 'PAID', label: t('orders.statusPaid', 'Paid') },
{ key: 'SHIPPED', label: t('orders.statusShipped', 'Shipped') },
{ key: 'CANCELLED', label: t('orders.statusCancelled', 'Cancelled') },
]
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Orders Management</h1> <h1 className="text-3xl font-bold">{t('orders.title', 'Orders Management')}</h1>
<p className="text-slate-500 text-sm">Review incoming orders and verify payments.</p> <p className="text-slate-500 text-sm">{t('orders.subtitle', 'Review incoming orders and verify payments.')}</p>
</div> </div>
</div> </div>
{/* Filters & Search */} {/* Filters & Search */}
<div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center"> <div className="flex flex-col md:flex-row gap-4 justify-between items-start md:items-center">
<div className="flex flex-wrap bg-white/5 border border-white/5 rounded-xl p-1 w-max"> <div className="flex flex-wrap bg-white/5 border border-white/5 rounded-xl p-1 w-max">
{['ALL', 'PENDING_PAYMENT', 'PAID', 'SHIPPED', 'CANCELLED'].map((f) => ( {filterTabs.map((tab) => (
<button <button
key={f} key={tab.key}
onClick={() => setFilter(f)} onClick={() => setFilter(tab.key)}
className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all ${ className={`px-4 py-2 rounded-lg text-xs font-bold uppercase tracking-widest transition-all cursor-pointer ${
filter === f ? 'bg-white/10 text-cyan-glow' : 'text-slate-500 hover:text-white' filter === tab.key ? 'bg-white/10 text-cyan-glow shadow-sm' : 'text-slate-500 hover:text-white'
}`} }`}
> >
{f.replace('_', ' ')} {tab.label}
</button> </button>
))} ))}
</div> </div>
<div className="relative w-full md:w-64"> <div className="relative w-full md:w-72">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input <input
type="text" type="text"
placeholder="Search order ID, name, phone..." placeholder={t('orders.searchPlaceholder', 'Search order ID, name, phone...')}
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-glow/50 transition-colors" className="w-full bg-white/5 border border-white/10 rounded-xl pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-glow/50 transition-colors"
@@ -87,17 +107,17 @@ export default function OrdersPage() {
</div> </div>
{/* Orders Table */} {/* Orders Table */}
<div className="admin-card overflow-hidden"> <div className="admin-card overflow-hidden !p-0 border-white/10 shadow-2xl">
<div className="overflow-x-auto"> <div className="overflow-x-auto min-h-[300px]">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500 bg-black/20"> <tr className="border-b border-white/10 text-[10px] uppercase tracking-widest text-slate-500 bg-white/[0.02]">
<th className="p-4 rounded-tl-xl">Order ID</th> <th className="p-4">{t('orders.orderId', 'Order ID')}</th>
<th className="p-4">Customer</th> <th className="p-4">{t('orders.customer', 'Customer')}</th>
<th className="p-4">Date</th> <th className="p-4">{t('orders.date', 'Date')}</th>
<th className="p-4">Total</th> <th className="p-4">{t('orders.total', 'Total')}</th>
<th className="p-4">Status</th> <th className="p-4">{t('orders.status', 'Status')}</th>
<th className="p-4 rounded-tr-xl">Action</th> <th className="p-4 text-right">{t('orders.action', 'Action')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -109,47 +129,49 @@ export default function OrdersPage() {
<td className="p-4"><div className="h-4 w-24 bg-white/5 rounded" /></td> <td className="p-4"><div className="h-4 w-24 bg-white/5 rounded" /></td>
<td className="p-4"><div className="h-4 w-16 bg-white/5 rounded" /></td> <td className="p-4"><div className="h-4 w-16 bg-white/5 rounded" /></td>
<td className="p-4"><div className="h-6 w-24 bg-white/5 rounded-full" /></td> <td className="p-4"><div className="h-6 w-24 bg-white/5 rounded-full" /></td>
<td className="p-4"><div className="h-8 w-16 bg-white/5 rounded-lg" /></td> <td className="p-4 text-right"><div className="h-8 w-16 bg-white/5 rounded-lg ml-auto" /></td>
</tr> </tr>
)) ))
) : filteredOrders.length === 0 ? ( ) : filteredOrders.length === 0 ? (
<tr> <tr>
<td colSpan={6} className="p-8 text-center text-slate-500 italic">No orders found.</td> <td colSpan={6} className="p-12 text-center text-slate-500 italic">
{t('orders.noOrders', 'No orders found.')}
</td>
</tr> </tr>
) : ( ) : (
filteredOrders.map((order) => ( filteredOrders.map((order) => (
<tr key={order.id} className="hover:bg-white/5 transition-colors group"> <tr key={order.id} className="hover:bg-white/5 transition-colors group">
<td className="p-4 font-mono text-cyan-glow text-sm"> <td className="p-4 font-mono text-cyan-glow text-sm font-bold">
#{order.id.toString().padStart(4, '0')} #{order.id.toString().padStart(4, '0')}
</td> </td>
<td className="p-4"> <td className="p-4">
{order.customer ? ( {order.customer ? (
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<img src={order.customer.pictureUrl || `https://ui-avatars.com/api/?name=${order.customer.displayName}`} className="w-5 h-5 rounded-full border border-cyan-glow/50" /> <img src={order.customer.pictureUrl || `https://ui-avatars.com/api/?name=${order.customer.displayName}`} className="w-5 h-5 rounded-full border border-cyan-glow/50" alt="" />
<span className="text-[10px] font-bold text-cyan-glow tracking-widest uppercase truncate max-w-[120px]">LINE: {order.customer.displayName}</span> <span className="text-[10px] font-bold text-cyan-glow tracking-widest uppercase truncate max-w-[120px]">LINE: {order.customer.displayName}</span>
</div> </div>
) : null} ) : null}
<p className="font-bold">{order.customerName}</p> <p className="font-bold text-white">{order.customerName}</p>
<p className="text-xs text-slate-500">{order.customerEmail}</p> <p className="text-xs text-slate-500">{order.customerEmail || order.customerPhone || '-'}</p>
</td> </td>
<td className="p-4 text-sm text-slate-300"> <td className="p-4 text-sm text-slate-300 whitespace-nowrap">
{new Date(order.createdAt).toLocaleDateString()} {new Date(order.createdAt).toLocaleDateString(i18n.language === 'th' ? 'th-TH' : i18n.language === 'ja' ? 'ja-JP' : i18n.language === 'zh' ? 'zh-CN' : 'en-US')}
</td> </td>
<td className="p-4 font-bold"> <td className="p-4 font-bold text-white">
{formatPrice(order.totalAmount)} {formatPrice(order.totalAmount)}
</td> </td>
<td className="p-4"> <td className="p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<StatusIcon status={order.status} /> <StatusIcon status={order.status} />
<span className="text-[10px] font-bold uppercase tracking-widest">{order.status.replace('_', ' ')}</span> <span className="text-[11px] font-bold tracking-wide">{getStatusLabel(order.status)}</span>
</div> </div>
</td> </td>
<td className="p-4"> <td className="p-4 text-right">
<Link <Link
to={`/orders/${order.id}`} to={`/orders/${order.id}`}
className="flex items-center gap-1 text-xs font-bold uppercase tracking-widest text-slate-400 hover:text-cyan-glow transition-colors" className="inline-flex items-center gap-1 text-xs font-bold uppercase tracking-wider text-slate-400 hover:text-cyan-glow transition-colors cursor-pointer"
> >
Review {t('orders.review', 'Review')}
<ChevronRight className="w-4 h-4" /> <ChevronRight className="w-4 h-4" />
</Link> </Link>
</td> </td>
+214 -160
View File
@@ -1,8 +1,10 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types' import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/types'
import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react' import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const const LANGUAGES = ['th', 'en', 'ja', 'zh'] as const
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
type EditableDefinition = { type EditableDefinition = {
@@ -57,6 +59,7 @@ function parseDefinition(definition: AttributeDefinition): EditableDefinition {
} }
export default function ProductAttributesPage() { export default function ProductAttributesPage() {
const { t, i18n } = useTranslation()
const [definitions, setDefinitions] = useState<AttributeDefinition[]>([]) const [definitions, setDefinitions] = useState<AttributeDefinition[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
const [editing, setEditing] = useState<EditableDefinition | null>(null) const [editing, setEditing] = useState<EditableDefinition | null>(null)
@@ -94,20 +97,32 @@ export default function ProductAttributesPage() {
setError(null) setError(null)
try { try {
const baseLabel = editing.translations.th.label || editing.translations.en.label const baseLabel = editing.translations.th?.label || editing.translations.en?.label
if (!baseLabel) throw new Error('Thai or English label is required') if (!baseLabel) throw new Error('Thai or English label is required')
const payload = { const payload = {
code: editing.code.trim(), code: editing.code,
label: baseLabel,
description: editing.translations.th.description || null,
type: editing.type, type: editing.type,
unit: editing.unit || null, unit: editing.unit,
isRequired: editing.isRequired, isRequired: editing.isRequired,
isFilterable: editing.isFilterable, isFilterable: editing.isFilterable,
sortOrder: Number(editing.sortOrder) || 0, sortOrder: Number(editing.sortOrder) || 0,
categoryId: editing.categoryId || null, categoryId: editing.categoryId ? Number(editing.categoryId) : null,
options: editing.type === 'SELECT' ? editing.options : null, label: Object.fromEntries(
Object.entries(editing.translations)
.filter(([, val]) => val.label.trim())
.map(([lang, val]) => [lang, val.label.trim()])
),
description: Object.fromEntries(
Object.entries(editing.translations)
.filter(([, val]) => val.description.trim())
.map(([lang, val]) => [lang, val.description.trim()])
),
options: editing.type === 'SELECT'
? editing.options.map((opt) => Object.fromEntries(
Object.entries(opt).filter(([, val]) => typeof val === 'string' && val.trim())
)).filter((opt) => Object.keys(opt).length > 0)
: undefined,
} }
const url = editing.id const url = editing.id
@@ -118,39 +133,15 @@ export default function ProductAttributesPage() {
const res = await fetch(url, { const res = await fetch(url, {
method, method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload),
...payload,
translations: Object.fromEntries(
Object.entries(editing.translations).filter(([, value]) => value.label.trim())
),
}),
}) })
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => null) const errData = await res.json().catch(() => null)
throw new Error(err?.message || 'Failed to save attribute definition') throw new Error(errData?.message || 'Failed to save attribute definition')
} }
const saved = await res.json()
const definitionId = editing.id || saved.definition?.id
const translationRes = await fetch(`${API_BASE}/product-attributes/definitions/${definitionId}/translations`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
translations: Object.fromEntries(
Object.entries(editing.translations).filter(([, value]) => value.label.trim())
),
}),
})
if (!translationRes.ok) {
const err = await translationRes.json().catch(() => null)
throw new Error(err?.message || 'Failed to save attribute definition translations')
}
setEditing(null)
await fetchData() await fetchData()
setEditing(null)
} catch (err: any) { } catch (err: any) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -158,56 +149,106 @@ export default function ProductAttributesPage() {
} }
} }
const openCreate = () => { const handleDelete = async (id: number) => {
setEditing(emptyDefinition()) if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this attribute definition?'))) return
setActiveLang('th') try {
const res = await fetch(`${API_BASE}/product-attributes/definitions/${id}`, { method: 'DELETE' })
if (!res.ok) {
const errData = await res.json().catch(() => null)
throw new Error(errData?.message || 'Failed to delete attribute definition')
}
await fetchData()
if (editing?.id === id) setEditing(null)
} catch (err: any) {
setError(err.message)
}
} }
const categoryNameMap = useMemo( const categoryNameMap = useMemo(() => {
() => new Map(categories.map((category) => [category.id, typeof category.name === 'string' ? category.name : category.name.th || category.name.en || 'Untitled'])), const map = new Map<number, string>()
[categories] const currentLang = i18n.language
categories.forEach((cat) => {
const name = typeof cat.name === 'string'
? cat.name
: (cat.name?.[currentLang] || cat.name?.th || cat.name?.en || `Category #${cat.id}`)
map.set(cat.id, name)
})
return map
}, [categories, i18n.language])
const handleCopyFromLang = (fromLang: string) => {
if (!editing) return
const src = editing.translations[fromLang]
if (!src) return
setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: {
label: src.label || '',
description: src.description || '',
},
},
})
}
const labelTranslationsMap = useMemo(() => {
if (!editing) return {}
return Object.fromEntries(
Object.entries(editing.translations).map(([lang, val]) => [lang, val.label])
) )
}, [editing])
return ( return (
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Product Attribute Definitions</h1> <div className="flex items-center gap-2 text-cyan-glow">
<p className="text-slate-500 text-sm">Create reusable product specs and maintain translations across languages.</p> <Settings2 className="w-6 h-6" />
<h1 className="text-3xl font-bold tracking-tight">{t('nav.productAttributes', 'Product Attributes')}</h1>
</div> </div>
<button onClick={openCreate} className="btn-primary flex items-center gap-2"> <p className="text-slate-500 text-sm">Define multilingual specs, units, and select options for products.</p>
</div>
<button
onClick={() => {
setEditing(emptyDefinition())
setActiveLang('th')
}}
className="btn-primary flex items-center gap-2"
>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
New Definition {t('common.addNew', 'Add Definition')}
</button> </button>
</div> </div>
{error && ( {error && (
<div className="p-4 rounded-xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-sm"> <div className="p-4 rounded-2xl border border-rose-500/20 bg-rose-500/10 text-rose-400 text-sm">
{error} {error}
</div> </div>
)} )}
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<section className="admin-card space-y-4"> <section className="admin-card space-y-4">
<div className="flex items-center gap-2 text-cyan-glow"> <h2 className="font-bold text-lg text-white">Defined Attributes ({definitions.length})</h2>
<Settings2 className="w-5 h-5" />
<h2 className="font-bold text-lg">Definitions</h2>
</div>
<div className="space-y-3"> <div className="space-y-3">
{loading && Array.from({ length: 4 }).map((_, idx) => ( {loading && Array.from({ length: 4 }).map((_, i) => (
<div key={idx} className="h-24 rounded-2xl bg-white/5 animate-pulse" /> <div key={i} className="h-24 rounded-2xl bg-white/5 animate-pulse" />
))} ))}
{!loading && definitions.map((definition) => { {!loading && definitions.map((definition) => {
const label = typeof definition.label === 'string' ? definition.label : definition.label.th || definition.label.en || definition.code const currentLang = i18n.language
const description = typeof definition.description === 'string' ? definition.description : definition.description?.th || definition.description?.en || '' const label = typeof definition.label === 'string'
? definition.label
: definition.label?.[currentLang] || definition.label?.th || definition.label?.en || definition.code
const description = typeof definition.description === 'string'
? definition.description
: definition.description?.[currentLang] || definition.description?.th || definition.description?.en || ''
return ( return (
<div key={definition.id} className="p-4 rounded-2xl border border-white/5 bg-white/[0.02] flex items-start justify-between gap-4"> <div key={definition.id} className="p-4 rounded-2xl border border-white/5 bg-white/[0.02] flex items-start justify-between gap-4 hover:border-cyan-glow/30 transition-all">
<div className="space-y-1"> <div className="space-y-1 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="text-lg font-bold text-white">{label}</h3> <h3 className="text-lg font-bold text-white">{label}</h3>
<span className="text-[10px] uppercase tracking-widest text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 px-2 py-1 rounded-full"> <span className="text-[10px] uppercase tracking-widest text-cyan-glow bg-cyan-glow/10 border border-cyan-glow/20 px-2 py-0.5 rounded-full font-bold">
{definition.type} {definition.type}
</span> </span>
</div> </div>
@@ -217,15 +258,25 @@ export default function ProductAttributesPage() {
{definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'} {definition.categoryId ? `Category: ${categoryNameMap.get(definition.categoryId) || definition.categoryId}` : 'Global attribute'}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<button <button
onClick={() => { onClick={() => {
setEditing(parseDefinition(definition)) setEditing(parseDefinition(definition))
setActiveLang('th') setActiveLang('th')
}} }}
className="p-2 rounded-xl text-slate-500 hover:text-cyan-glow hover:bg-cyan-glow/10 transition-all" title={t('common.edit', 'Edit')}
className="p-2 rounded-xl text-slate-500 hover:text-cyan-glow hover:bg-cyan-glow/10 transition-all cursor-pointer"
> >
<Edit3 className="w-4 h-4" /> <Edit3 className="w-4 h-4" />
</button> </button>
<button
onClick={() => handleDelete(definition.id)}
title={t('common.delete', 'Delete')}
className="p-2 rounded-xl text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div> </div>
) )
})} })}
@@ -236,12 +287,12 @@ export default function ProductAttributesPage() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="font-bold text-lg text-white">{editing?.id ? 'Edit Definition' : 'Create Definition'}</h2> <h2 className="font-bold text-lg text-white">{editing?.id ? 'Edit Definition' : 'Create Definition'}</h2>
<p className="text-slate-500 text-sm">Type-aware definitions with localized labels and SELECT options.</p> <p className="text-slate-500 text-sm">Type-aware definitions with 4-language localized labels and SELECT options.</p>
</div> </div>
{editing && ( {editing && (
<button <button
onClick={() => setEditing(null)} onClick={() => setEditing(null)}
className="p-2 rounded-xl text-slate-500 hover:text-white hover:bg-white/10 transition-all" className="p-2 rounded-xl text-slate-500 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
> >
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
@@ -250,31 +301,27 @@ export default function ProductAttributesPage() {
{!editing ? ( {!editing ? (
<div className="text-center py-16 border-2 border-dashed border-white/5 rounded-2xl text-slate-500 text-sm"> <div className="text-center py-16 border-2 border-dashed border-white/5 rounded-2xl text-slate-500 text-sm">
Select a definition to edit or create a new one. Select an attribute definition to edit or click Create Definition.
</div> </div>
) : ( ) : (
<form onSubmit={handleSave} className="space-y-5"> <form onSubmit={handleSave} className="space-y-5">
<div className="flex bg-white/5 rounded-lg p-1 flex-wrap"> <ContentLanguageTabs
{LANGUAGES.map((lang) => ( activeLang={activeLang}
<button onSelectLang={setActiveLang}
key={lang} translationsMap={labelTranslationsMap}
type="button" onCopyFrom={handleCopyFromLang}
onClick={() => setActiveLang(lang)} />
className={`px-4 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
{lang}
</button>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Code</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Code</label>
<input <input
type="text" type="text"
required
value={editing.code} value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })} onChange={(e) => setEditing({ ...editing, code: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
placeholder="e.g. burn_time, weight"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -282,7 +329,7 @@ export default function ProductAttributesPage() {
<select <select
value={editing.type} value={editing.type}
onChange={(e) => setEditing({ ...editing, type: e.target.value as EditableDefinition['type'], options: e.target.value === 'SELECT' ? editing.options : [] })} onChange={(e) => setEditing({ ...editing, type: e.target.value as EditableDefinition['type'], options: e.target.value === 'SELECT' ? editing.options : [] })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
> >
{ATTRIBUTE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)} {ATTRIBUTE_TYPES.map((type) => <option key={type} value={type}>{type}</option>)}
</select> </select>
@@ -293,7 +340,8 @@ export default function ProductAttributesPage() {
type="text" type="text"
value={editing.unit} value={editing.unit}
onChange={(e) => setEditing({ ...editing, unit: e.target.value })} onChange={(e) => setEditing({ ...editing, unit: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
placeholder="e.g. g, ml, hrs"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -301,121 +349,127 @@ export default function ProductAttributesPage() {
<input <input
type="number" type="number"
value={editing.sortOrder} value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: Number(e.target.value) || 0 })} onChange={(e) => setEditing({ ...editing, sortOrder: Number(e.target.value) })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
/> />
</div> </div>
<div className="space-y-2 md:col-span-2"> </div>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category Binding</label>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
Label ({activeLang.toUpperCase()})
</label>
<input
type="text"
required={activeLang === 'th'}
value={editing.translations[activeLang]?.label || ''}
onChange={(e) => setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: {
...editing.translations[activeLang],
label: e.target.value,
},
},
})}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
placeholder={`Label in ${activeLang.toUpperCase()}`}
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">
Description ({activeLang.toUpperCase()})
</label>
<textarea
value={editing.translations[activeLang]?.description || ''}
onChange={(e) => setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: {
...editing.translations[activeLang],
description: e.target.value,
},
},
})}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none min-h-[70px]"
placeholder={`Description in ${activeLang.toUpperCase()}`}
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Category Restriction</label>
<select <select
value={editing.categoryId || 0} value={editing.categoryId ?? 0}
onChange={(e) => setEditing({ ...editing, categoryId: Number(e.target.value) || null })} onChange={(e) => setEditing({ ...editing, categoryId: Number(e.target.value) || null })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white" className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
> >
<option value={0}>Global attribute</option> <option value={0}>Global (All categories)</option>
{categories.map((category) => ( {categories.map((c) => (
<option key={category.id} value={category.id}> <option key={c.id} value={c.id}>
{typeof category.name === 'string' ? category.name : category.name.th || category.name.en || `Category ${category.id}`} {typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || ''}
</option> </option>
))} ))}
</select> </select>
</div> </div>
</div>
<div className="grid grid-cols-2 gap-4">
<label className="flex items-center gap-3 text-sm text-white">
<input
type="checkbox"
checked={editing.isRequired}
onChange={(e) => setEditing({ ...editing, isRequired: e.target.checked })}
/>
Required
</label>
<label className="flex items-center gap-3 text-sm text-white">
<input
type="checkbox"
checked={editing.isFilterable}
onChange={(e) => setEditing({ ...editing, isFilterable: e.target.checked })}
/>
Filterable
</label>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Label ({activeLang.toUpperCase()})</label>
<input
type="text"
value={editing.translations[activeLang].label}
onChange={(e) => setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: { ...editing.translations[activeLang], label: e.target.value },
},
})}
className="w-full px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
/>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Description ({activeLang.toUpperCase()})</label>
<textarea
value={editing.translations[activeLang].description}
onChange={(e) => setEditing({
...editing,
translations: {
...editing.translations,
[activeLang]: { ...editing.translations[activeLang], description: e.target.value },
},
})}
className="w-full min-h-[100px] px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
/>
</div>
{editing.type === 'SELECT' && ( {editing.type === 'SELECT' && (
<div className="space-y-4"> <div className="space-y-3 pt-3 border-t border-white/5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-bold text-white">Localized Options</h3> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
Options ({activeLang.toUpperCase()})
</label>
<button <button
type="button" type="button"
onClick={() => setEditing({ ...editing, options: [...editing.options, { th: '', en: '' }] })} onClick={() => setEditing({
className="px-3 py-2 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow/20" ...editing,
options: [...editing.options, { [activeLang]: '' }],
})}
className="text-xs font-bold text-cyan-glow hover:text-cyan-glow/80 flex items-center gap-1 cursor-pointer"
> >
Add Option <Plus className="w-3.5 h-3.5" /> Add Option
</button> </button>
</div> </div>
{editing.options.map((opt, idx) => (
<div className="space-y-3"> <div key={idx} className="flex items-center gap-2">
{editing.options.map((option, idx) => (
<div key={idx} className="p-3 rounded-2xl border border-white/5 bg-white/[0.02] flex items-center gap-3">
<input <input
type="text" type="text"
value={option[activeLang] || ''} value={opt[activeLang] || ''}
onChange={(e) => { onChange={(e) => {
const next = [...editing.options] const nextOptions = [...editing.options]
next[idx] = { ...next[idx], [activeLang]: e.target.value } nextOptions[idx] = { ...nextOptions[idx], [activeLang]: e.target.value }
setEditing({ ...editing, options: next }) setEditing({ ...editing, options: nextOptions })
}} }}
className="flex-1 px-4 py-2 bg-night-950 border border-white/10 rounded-xl text-sm text-white focus:border-cyan-glow/40 outline-none"
placeholder={`Option ${idx + 1} (${activeLang.toUpperCase()})`} placeholder={`Option ${idx + 1} (${activeLang.toUpperCase()})`}
className="flex-1 px-4 py-3 bg-night-950 border border-white/10 rounded-xl text-sm text-white"
/> />
<button <button
type="button" type="button"
onClick={() => setEditing({ ...editing, options: editing.options.filter((_, optionIdx) => optionIdx !== idx) })} onClick={() => {
className="p-2 rounded-xl text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 transition-all" const nextOptions = editing.options.filter((_, i) => i !== idx)
setEditing({ ...editing, options: nextOptions })
}}
className="p-2 text-slate-500 hover:text-rose-500 rounded-xl transition-all cursor-pointer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
))} ))}
</div> </div>
</div>
)} )}
<button type="submit" disabled={saving} className="btn-primary flex items-center gap-2"> <div className="pt-4 flex gap-3">
<button
type="submit"
disabled={saving}
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2 cursor-pointer"
>
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
{saving ? 'Saving...' : 'Save Definition'} {saving ? t('common.saving', 'Saving...') : t('common.saveChanges', 'Save Changes')}
</button> </button>
</div>
</form> </form>
)} )}
</section> </section>
+21 -14
View File
@@ -19,6 +19,7 @@ import {
import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types' import { API_BASE, type AttributeDefinition, type Category, type Product, type ProductAttribute, type ProductImage, type Variant } from '@homaroma/types'
import MDEditor from '@uiw/react-md-editor' import MDEditor from '@uiw/react-md-editor'
import MediaLibraryModal from '../components/MediaLibraryModal' import MediaLibraryModal from '../components/MediaLibraryModal'
import ContentLanguageTabs from '../components/ContentLanguageTabs'
type EditableAttribute = { type EditableAttribute = {
id?: number id?: number
@@ -42,9 +43,8 @@ export default function ProductEditPage() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
// Form State // Form State - 4 Core Languages
const [activeLang, setActiveLang] = useState('th') const [activeLang, setActiveLang] = useState('th')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']
const [categoryId, setCategoryId] = useState<number>(0) const [categoryId, setCategoryId] = useState<number>(0)
const [name, setName] = useState<Record<string, string>>({}) const [name, setName] = useState<Record<string, string>>({})
@@ -57,6 +57,18 @@ export default function ProductEditPage() {
const [attributes, setAttributes] = useState<EditableAttribute[]>([]) const [attributes, setAttributes] = useState<EditableAttribute[]>([])
const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0) const [selectedDefinitionId, setSelectedDefinitionId] = useState<number>(0)
const handleCopyFromLang = (fromLang: string) => {
if (name[fromLang]) {
setName(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
if (description[fromLang]) {
setDescription(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
if (details[fromLang]) {
setDetails(prev => ({ ...prev, [activeLang]: prev[fromLang] }))
}
}
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false) const [isMediaModalOpen, setIsMediaModalOpen] = useState(false)
const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null) const [onSelectMedia, setOnSelectMedia] = useState<((url: string) => void) | null>(null)
@@ -411,22 +423,17 @@ export default function ProductEditPage() {
<div className="space-y-8"> <div className="space-y-8">
{/* Basic Info */} {/* Basic Info */}
<section className="admin-card space-y-6"> <section className="admin-card space-y-6">
<div className="flex items-center justify-between"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-center gap-2 text-cyan-glow"> <div className="flex items-center gap-2 text-cyan-glow">
<Info className="w-5 h-5" /> <Info className="w-5 h-5" />
<h2 className="font-bold text-lg">Basic Information</h2> <h2 className="font-bold text-lg">Basic Information</h2>
</div> </div>
<div className="flex bg-white/5 rounded-lg p-1"> <ContentLanguageTabs
{languages.map(lang => ( activeLang={activeLang}
<button onSelectLang={setActiveLang}
key={lang} translationsMap={name}
onClick={(e) => { e.preventDefault(); setActiveLang(lang); }} onCopyFrom={handleCopyFromLang}
className={`px-4 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`} />
>
{lang}
</button>
))}
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+58 -30
View File
@@ -3,7 +3,6 @@ import {
Package, Package,
Search, Search,
Plus, Plus,
Eye,
Edit3, Edit3,
Trash2, Trash2,
Filter, Filter,
@@ -16,6 +15,7 @@ import {
LayoutTemplate LayoutTemplate
} from 'lucide-react' } from 'lucide-react'
import { useNavigate, useSearchParams } from 'react-router-dom' import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types' import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '@homaroma/types'
interface PaginationMeta { interface PaginationMeta {
@@ -26,6 +26,7 @@ interface PaginationMeta {
} }
export default function ProductsPage() { export default function ProductsPage() {
const { t, i18n } = useTranslation()
const navigate = useNavigate() const navigate = useNavigate()
const [products, setProducts] = useState<Product[]>([]) const [products, setProducts] = useState<Product[]>([])
const [categories, setCategories] = useState<Category[]>([]) const [categories, setCategories] = useState<Category[]>([])
@@ -187,6 +188,21 @@ export default function ProductsPage() {
} }
} }
const handleDelete = async (id: number) => {
if (!confirm(t('common.confirmDelete', 'Are you sure you want to delete this product?'))) return
try {
const res = await fetch(`${API_BASE}/products/${id}`, { method: 'DELETE' })
if (res.ok) {
fetchData()
} else {
const errorData = await res.json().catch(() => null)
alert(errorData?.message || 'Failed to delete product')
}
} catch (err) {
console.error('Delete error:', err)
}
}
const setPage = (newOrUpdaterData: number | ((p: number) => number)) => { const setPage = (newOrUpdaterData: number | ((p: number) => number)) => {
const nextUrlPage = typeof newOrUpdaterData === 'function' ? newOrUpdaterData(page) : newOrUpdaterData const nextUrlPage = typeof newOrUpdaterData === 'function' ? newOrUpdaterData(page) : newOrUpdaterData
setSearchParams(prev => { setSearchParams(prev => {
@@ -232,17 +248,17 @@ export default function ProductsPage() {
<div className="space-y-8 animate-fade-in"> <div className="space-y-8 animate-fade-in">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-3xl font-bold">Products Management</h1> <h1 className="text-3xl font-bold">{t('products.title', 'Products Management')}</h1>
<p className="text-slate-500 text-sm"> <p className="text-slate-500 text-sm">
{meta ? `Displaying ${products.length} of ${meta.total} products` : 'Loading products...'} {meta ? `Displaying ${products.length} of ${meta.total} products` : t('common.loading', 'Loading products...')}
</p> </p>
</div> </div>
<button <button
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2 cursor-pointer"
onClick={() => navigate('/products/new')} onClick={() => navigate('/products/new')}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
Add Product {t('products.addNew', 'Add Product')}
</button> </button>
</div> </div>
@@ -252,7 +268,7 @@ export default function ProductsPage() {
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 group-focus-within:text-cyan-glow transition-colors" />
<input <input
type="text" type="text"
placeholder="Search products..." placeholder={t('products.searchPlaceholder', 'Search products...')}
value={search} value={search}
onChange={(e) => handleSearchChange(e.target.value)} onChange={(e) => handleSearchChange(e.target.value)}
className="w-full pl-11 pr-4 py-3 bg-night-900/50 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm placeholder:text-slate-600 transition-all shadow-inner text-white" className="w-full pl-11 pr-4 py-3 bg-night-900/50 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm placeholder:text-slate-600 transition-all shadow-inner text-white"
@@ -266,11 +282,13 @@ export default function ProductsPage() {
<select <select
value={activeCategory || ''} value={activeCategory || ''}
onChange={(e) => handleCategoryChange(Number(e.target.value) || null)} onChange={(e) => handleCategoryChange(Number(e.target.value) || null)}
className="flex-1 md:w-52 px-4 py-3 bg-night-900 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-silver-metallic" className="flex-1 md:w-52 px-4 py-3 bg-night-900 border border-white/5 focus:border-cyan-glow/30 rounded-xl outline-none text-sm text-silver-metallic cursor-pointer"
> >
<option value="">All Categories</option> <option value="">{t('common.all', 'All Categories')}</option>
{categories.map(c => ( {categories.map(c => (
<option key={c.id} value={c.id}>{typeof c.name === 'string' ? c.name : (c.name as any)?.th || (c.name as any)?.en || ''}</option> <option key={c.id} value={c.id}>
{typeof c.name === 'string' ? c.name : (c.name as any)?.[i18n.language] || (c.name as any)?.th || (c.name as any)?.en || ''}
</option>
))} ))}
</select> </select>
</div> </div>
@@ -282,11 +300,11 @@ export default function ProductsPage() {
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-white/[0.02] border-b border-white/5"> <tr className="bg-white/[0.02] border-b border-white/5">
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Product</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.name', 'Product')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Category</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.category', 'Category')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Variants</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.variants', 'Variants')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">Price</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500">{t('products.price', 'Price')}</th>
<th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">Actions</th> <th className="px-6 py-4 text-[10px] uppercase font-bold tracking-widest text-slate-500 text-right">{t('common.actions', 'Actions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/[0.03]"> <tbody className="divide-y divide-white/[0.03]">
@@ -302,10 +320,18 @@ export default function ProductsPage() {
<tr> <tr>
<td colSpan={5} className="px-6 py-20 text-center space-y-3"> <td colSpan={5} className="px-6 py-20 text-center space-y-3">
<Package className="w-10 h-10 text-slate-700 mx-auto" /> <Package className="w-10 h-10 text-slate-700 mx-auto" />
<p className="text-slate-500 font-medium italic">No products matched your search.</p> <p className="text-slate-500 font-medium italic">{t('common.noData', 'No products matched your search.')}</p>
</td> </td>
</tr> </tr>
) : products.map((product) => ( ) : products.map((product) => {
const localizedName = typeof product.name === 'string'
? product.name
: (product.name?.[i18n.language] || product.name?.th || product.name?.en || product.brand?.name)
const localizedCategory = typeof product.category.name === 'string'
? product.category.name
: (product.category.name?.[i18n.language] || (product.category.name as any)?.th || (product.category.name as any)?.en || '')
return (
<tr key={product.id} className="hover:bg-white/[0.01] transition-colors group"> <tr key={product.id} className="hover:bg-white/[0.01] transition-colors group">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -319,14 +345,14 @@ export default function ProductsPage() {
/> />
</div> </div>
<div className="overflow-hidden"> <div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.brand?.name)}</p> <p className="text-sm font-bold text-white truncate">{localizedName}</p>
<p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p> <p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p>
</div> </div>
</div> </div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<span className="inline-block px-2.5 py-1 rounded-md bg-cyan-glow/10 text-cyan-glow text-[10px] font-bold uppercase tracking-wider border border-cyan-glow/20"> <span className="inline-block px-2.5 py-1 rounded-md bg-cyan-glow/10 text-cyan-glow text-[10px] font-bold uppercase tracking-wider border border-cyan-glow/20">
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.th || (product.category.name as any)?.en || ''} {localizedCategory}
</span> </span>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
@@ -348,7 +374,7 @@ export default function ProductsPage() {
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<button <button
onClick={() => { setSelectedProduct(product); setIsGalleryOpen(true); }} onClick={() => { setSelectedProduct(product); setIsGalleryOpen(true); }}
className={`p-2 rounded-lg transition-all ${ className={`p-2 rounded-lg transition-all cursor-pointer ${
product.images && product.images.length > 0 product.images && product.images.length > 0
? 'bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow/20' ? 'bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow/20'
: 'hover:bg-white/5 text-slate-600' : 'hover:bg-white/5 text-slate-600'
@@ -357,37 +383,39 @@ export default function ProductsPage() {
> >
<ImageIcon className="w-4 h-4" /> <ImageIcon className="w-4 h-4" />
</button> </button>
<button className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-white transition-all">
<Eye className="w-4 h-4" />
</button>
<button <button
onClick={() => navigate(`/products/${product.id}/bom`)} onClick={() => navigate(`/products/${product.id}/bom`)}
title="Manage BOM" title={t('products.bom', 'BOM & Formula')}
className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all" className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all cursor-pointer"
> >
<Package className="w-4 h-4" /> <Package className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => navigate(`/products/${product.id}/cms`)} onClick={() => navigate(`/products/${product.id}/cms`)}
title="Edit Product CMS" title={t('products.cmsBuilder', 'Product CMS Builder')}
className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all" className="p-2 rounded-lg hover:bg-cyan-glow/10 text-slate-500 hover:text-cyan-glow transition-all cursor-pointer"
> >
<LayoutTemplate className="w-4 h-4" /> <LayoutTemplate className="w-4 h-4" />
</button> </button>
<button <button
onClick={() => navigate(`/products/${product.id}/edit`)} onClick={() => navigate(`/products/${product.id}/edit`)}
title="Edit Product" title={t('products.editProduct', 'Edit Product')}
className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all" className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-cyan-glow transition-all cursor-pointer"
> >
<Edit3 className="w-4 h-4" /> <Edit3 className="w-4 h-4" />
</button> </button>
<button className="p-2 rounded-lg hover:bg-white/5 text-slate-500 hover:text-rose-500 transition-all"> <button
onClick={() => handleDelete(product.id)}
title={t('common.delete', 'Delete Product')}
className="p-2 rounded-lg hover:bg-rose-500/10 text-slate-500 hover:text-rose-500 transition-all cursor-pointer"
>
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
+124 -66
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, LineChart, Line, Legend, PieChart, Pie, Cell, LineChart, Line, Legend,
@@ -68,20 +69,18 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [ const PRESET_CONFIG: { key: Preset; i18nKey: string; fallback: string }[] = [
{ key: '7d', label: '7 วัน' }, { key: '7d', i18nKey: 'analytics.preset7d', fallback: '7 วัน' },
{ key: '30d', label: '30 วัน' }, { key: '30d', i18nKey: 'analytics.preset30d', fallback: '30 วัน' },
{ key: '90d', label: '90 วัน' }, { key: '90d', i18nKey: 'analytics.preset90d', fallback: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' }, { key: 'thisMonth', i18nKey: 'analytics.presetThisMonth', fallback: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' }, { key: 'lastMonth', i18nKey: 'analytics.presetLastMonth', fallback: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' }, { key: 'year', i18nKey: 'analytics.presetThisYear', fallback: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' }, { key: 'lastYear', i18nKey: 'analytics.presetLastYear', fallback: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' }, { key: 'last12m', i18nKey: 'analytics.presetLast12m', fallback: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' }, { key: 'all', i18nKey: 'analytics.presetAll', fallback: 'ทั้งหมด' },
]; ];
function StatCard({ function StatCard({
label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5', label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5',
}: { label: string; value: string | number; sub?: string; icon: any; color?: string; bgColor?: string }) { }: { label: string; value: string | number; sub?: string; icon: any; color?: string; bgColor?: string }) {
@@ -114,6 +113,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function ShopeeAnalyticsPage() { export default function ShopeeAnalyticsPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -164,15 +164,11 @@ export default function ShopeeAnalyticsPage() {
setPreset('all'); setDateFrom(''); setDateTo(''); setPreset('all'); setDateFrom(''); setDateTo('');
}; };
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className="w-8 h-8 text-orange-400 animate-spin" /> <Loader2 className="w-8 h-8 text-orange-400 animate-spin" />
<span className="text-slate-500">กำลังคำนวณข้อมูลวิเคราะห์...</span> <span className="text-slate-500">{t('shopeeAnalytics.loading', 'กำลังคำนวณข้อมูลวิเคราะห์...')}</span>
</div> </div>
); );
} }
@@ -181,9 +177,9 @@ export default function ShopeeAnalyticsPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<ShoppingBag className="w-14 h-14 text-slate-700" /> <ShoppingBag className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Shopee ก่อน'}</p> <p className="text-slate-500">{error || t('shopeeAnalytics.noData', 'ไม่มีข้อมูล — กรุณา Import ไฟล์ Shopee ก่อน')}</p>
<Link to="/shopee" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm"> <Link to="/shopee" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm">
<ArrowLeft className="w-4 h-4" /> กลับหน้า Shopee <ArrowLeft className="w-4 h-4" /> {t('shopeeAnalytics.backToShopee', 'กลับหน้า Shopee')}
</Link> </Link>
</div> </div>
); );
@@ -211,12 +207,11 @@ export default function ShopeeAnalyticsPage() {
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1"> <div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/shopee" className="hover:text-orange-400 transition-colors">Shopee</Link> <Link to="/shopee" className="hover:text-orange-400 transition-colors">Shopee</Link>
<span>/</span> <span>/</span>
<span className="text-white">Analytics</span> <span className="text-white">{t('shopeeAnalytics.breadcrumb', 'Analytics')}</span>
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
<TrendingUp className={`w-6 h-6 ${shopCfg.color}`} /> <TrendingUp className={`w-6 h-6 ${shopCfg.color}`} />
วิเคราะห์ยอดขาย — {t('shopeeAnalytics.title', { shop: shopCfg.label, defaultValue: `วิเคราะห์ยอดขาย — ${shopCfg.label}` })}
<span className={shopCfg.color}>{shopCfg.label}</span>
</h1> </h1>
</div> </div>
</div> </div>
@@ -245,7 +240,7 @@ export default function ShopeeAnalyticsPage() {
{/* Preset Buttons */} {/* Preset Buttons */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" /> <Calendar className="w-4 h-4 text-slate-500 mr-1" />
{PRESETS.map(p => ( {PRESET_CONFIG.map(p => (
<button <button
key={p.key} key={p.key}
onClick={() => applyPreset(p.key)} onClick={() => applyPreset(p.key)}
@@ -255,7 +250,7 @@ export default function ShopeeAnalyticsPage() {
: 'bg-white/5 text-slate-400 border border-white/10 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-slate-400 border border-white/10 hover:bg-white/10 hover:text-white'
}`} }`}
> >
{p.label} {t(p.i18nKey, p.fallback)}
</button> </button>
))} ))}
</div> </div>
@@ -265,14 +260,14 @@ export default function ShopeeAnalyticsPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('analytics.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={e => setDateFrom(e.target.value)} onChange={e => setDateFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-orange-500/50 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-orange-500/50 focus:outline-none"
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" type="date"
value={dateTo} value={dateTo}
@@ -283,7 +278,7 @@ export default function ShopeeAnalyticsPage() {
onClick={applyCustom} onClick={applyCustom}
className="px-4 py-1.5 rounded-lg bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs font-medium hover:bg-orange-500/25 transition-all" className="px-4 py-1.5 rounded-lg bg-orange-500/15 border border-orange-500/30 text-orange-400 text-xs font-medium hover:bg-orange-500/25 transition-all"
> >
ดูข้อมูล {t('analytics.apply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -297,36 +292,86 @@ export default function ShopeeAnalyticsPage() {
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors"
> >
✕ ล้าง ✕ {t('analytics.clear', 'ล้าง')}
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* KPI Cards Row 1 */} {/* KPI Cards Row 1 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="ยอดขายรวม (Gross)" value={fmtCurrency(summary.totalRevenue)} icon={DollarSign} color="text-emerald-400" bgColor="from-emerald-500/10" sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} /> <StatCard
<StatCard label="รายได้สุทธิ (Net)" value={fmtCurrency(summary.netRevenue)} icon={TrendingUp} color="text-cyan-glow" bgColor="from-cyan-500/10" sub={`หลังหัก Commission + ส่งออก`} /> label={t('shopeeAnalytics.kpiGrossRevenue', 'ยอดขายรวม (Gross)')}
<StatCard label="ค่าคอมมิชชั่นรวม" value={fmtCurrency(summary.totalCommission)} icon={ReceiptText} color="text-rose-400" bgColor="from-rose-500/10" sub={`${fmt(summary.commissionRate, 1)}% ของยอดขาย`} /> value={fmtCurrency(summary.totalRevenue)}
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-amber-400" bgColor="from-amber-500/10" sub="Average Order Value" /> icon={DollarSign}
color="text-emerald-400"
bgColor="from-emerald-500/10"
sub={t('shopeeAnalytics.kpiGrossOrdersSub', { count: fmt(summary.totalOrders), defaultValue: `${fmt(summary.totalOrders)} คำสั่งซื้อ` })}
/>
<StatCard
label={t('shopeeAnalytics.kpiNetRevenue', 'รายได้สุทธิ (Net)')}
value={fmtCurrency(summary.netRevenue)}
icon={TrendingUp}
color="text-cyan-glow"
bgColor="from-cyan-500/10"
sub={t('shopeeAnalytics.kpiNetSub', 'หลังหัก Commission + ส่งออก')}
/>
<StatCard
label={t('shopeeAnalytics.kpiCommission', 'ค่าคอมมิชชั่นรวม')}
value={fmtCurrency(summary.totalCommission)}
icon={ReceiptText}
color="text-rose-400"
bgColor="from-rose-500/10"
sub={t('shopeeAnalytics.kpiCommissionSub', { rate: fmt(summary.commissionRate, 1), defaultValue: `${fmt(summary.commissionRate, 1)}% ของยอดขาย` })}
/>
<StatCard
label={t('shopeeAnalytics.kpiAov', 'AOV (เฉลี่ยต่อออเดอร์)')}
value={fmtCurrency(summary.aov)}
icon={ShoppingCart}
color="text-amber-400"
bgColor="from-amber-500/10"
sub={t('shopeeAnalytics.kpiAovSub', 'Average Order Value')}
/>
</div> </div>
{/* KPI Cards Row 2 */} {/* KPI Cards Row 2 */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} /> <StatCard
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} /> label={t('shopeeAnalytics.kpiCompletedOrders', 'คำสั่งซื้อสำเร็จ')}
<StatCard label="ค่าจัดส่ง (ผู้ซื้อ)" value={fmtCurrency(summary.totalShipping)} icon={Package} color="text-blue-400" sub="ค่าส่งที่ผู้ซื้อจ่าย" /> value={fmt(summary.completedOrders)}
<StatCard label="Effective Net Margin" value={`${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%`} icon={BadgePercent} color="text-purple-400" sub="Net / Gross Revenue" /> icon={CheckCircle2}
color="text-emerald-400"
sub={t('shopeeAnalytics.kpiCompletedSub', { total: fmt(summary.totalOrders), defaultValue: `จากทั้งหมด ${fmt(summary.totalOrders)}` })}
/>
<StatCard
label={t('shopeeAnalytics.kpiCancelledOrders', 'คำสั่งซื้อยกเลิก')}
value={fmt(summary.cancelledOrders)}
icon={XCircle}
color="text-rose-400"
sub={t('shopeeAnalytics.kpiCancelledSub', { rate: fmt(summary.cancelRate, 1), defaultValue: `${fmt(summary.cancelRate, 1)}% ของทั้งหมด` })}
/>
<StatCard
label={t('shopeeAnalytics.kpiShipping', 'ค่าจัดส่ง (ผู้ซื้อ)')}
value={fmtCurrency(summary.totalShipping)}
icon={Package}
color="text-blue-400"
sub={t('shopeeAnalytics.kpiShippingSub', 'ค่าส่งที่ผู้ซื้อจ่าย')}
/>
<StatCard
label={t('shopeeAnalytics.kpiEffectiveMargin', 'Effective Net Margin')}
value={`${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%`}
icon={BadgePercent}
color="text-purple-400"
sub={t('shopeeAnalytics.kpiEffectiveMarginSub', 'Net / Gross Revenue')}
/>
</div> </div>
{/* Monthly Revenue Chart */} {/* Monthly Revenue Chart */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className="w-5 h-5 text-orange-400" /> <TrendingUp className="w-5 h-5 text-orange-400" />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.monthlyRevenueTitle', 'ยอดขายรายเดือน')}</h2>
<span className="text-xs text-slate-500 ml-2">Gross Revenue vs Net Revenue</span> <span className="text-xs text-slate-500 ml-2">{t('shopeeAnalytics.monthlyRevenueSub', 'Gross Revenue vs Net Revenue')}</span>
</div> </div>
{monthlyChart.length > 0 ? ( {monthlyChart.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -337,14 +382,14 @@ export default function ShopeeAnalyticsPage() {
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} /> <Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
<Bar dataKey="revenue" name="฿ ยอดขาย (Gross)" fill="#4ade80" radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" name={t('shopeeAnalytics.barGrossRevenue', '฿ ยอดขาย (Gross)')} fill="#4ade80" radius={[4, 4, 0, 0]} />
<Bar dataKey="net" name="฿ รายได้สุทธิ (Net)" fill="#22d3ee" radius={[4, 4, 0, 0]} /> <Bar dataKey="net" name={t('shopeeAnalytics.barNetRevenue', '฿ รายได้สุทธิ (Net)')} fill="#22d3ee" radius={[4, 4, 0, 0]} />
<Bar dataKey="commission" name="฿ ค่าคอม" fill="#f87171" radius={[4, 4, 0, 0]} /> <Bar dataKey="commission" name={t('shopeeAnalytics.barCommission', '฿ ค่าคอม')} fill="#f87171" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('shopeeAnalytics.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}</p>
)} )}
</div> </div>
@@ -353,7 +398,7 @@ export default function ShopeeAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<ShoppingBag className="w-5 h-5 text-cyan-glow" /> <ShoppingBag className="w-5 h-5 text-cyan-glow" />
<h2 className="text-lg font-bold text-white">จำนวนคำสั่งซื้อรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.monthlyOrdersTitle', 'จำนวนคำสั่งซื้อรายเดือน')}</h2>
</div> </div>
<div className="h-[220px]"> <div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -362,8 +407,8 @@ export default function ShopeeAnalyticsPage() {
<XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} /> <XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} />
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="orders" name="คำสั่งซื้อ" stroke="#f97316" strokeWidth={2} dot={{ r: 4, fill: '#f97316' }} /> <Line type="monotone" dataKey="orders" name={t('shopeeAnalytics.lineOrders', 'คำสั่งซื้อ')} stroke="#f97316" strokeWidth={2} dot={{ r: 4, fill: '#f97316' }} />
<Line type="monotone" dataKey="items" name="จำนวนสินค้า (ชิ้น)" stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} /> <Line type="monotone" dataKey="items" name={t('shopeeAnalytics.lineItems', 'จำนวนสินค้า (ชิ้น)')} stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -377,7 +422,7 @@ export default function ShopeeAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<CheckCircle2 className="w-5 h-5 text-emerald-400" /> <CheckCircle2 className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนสถานะคำสั่งซื้อ</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.statusBreakdownTitle', 'สัดส่วนสถานะคำสั่งซื้อ')}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-4 items-center"> <div className="grid grid-cols-2 gap-4 items-center">
<div className="h-[200px]"> <div className="h-[200px]">
@@ -409,7 +454,7 @@ export default function ShopeeAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<DollarSign className="w-5 h-5 text-amber-400" /> <DollarSign className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">ช่องทางการชำระเงิน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.paymentMethodTitle', 'ช่องทางการชำระเงิน')}</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{paymentBreakdown.map((p: any, i: number) => { {paymentBreakdown.map((p: any, i: number) => {
@@ -418,7 +463,9 @@ export default function ShopeeAnalyticsPage() {
<div key={i} className="space-y-1"> <div key={i} className="space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method}</span> <span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method}</span>
<span className="text-slate-400 ml-2">{p.count} ออเดอร์ · {fmt(pct, 0)}%</span> <span className="text-slate-400 ml-2">
{t('shopeeAnalytics.ordersCountAndPct', { count: p.count, pct: fmt(pct, 0), defaultValue: `${p.count} ออเดอร์ · ${fmt(pct, 0)}%` })}
</span>
</div> </div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden"> <div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div <div
@@ -437,19 +484,19 @@ export default function ShopeeAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี (by ยอดเงิน)</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.topProductsTitle', 'Top 10 สินค้าขายดี (by ยอดเงิน)')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10"> <thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10">
<tr> <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">{t('shopeeAnalytics.thProduct', 'สินค้า')}</th>
<th className="px-4 py-3">ตัวเลือก</th> <th className="px-4 py-3">{t('shopeeAnalytics.thVariation', 'ตัวเลือก')}</th>
<th className="px-4 py-3 text-right">ชิ้น</th> <th className="px-4 py-3 text-right">{t('shopeeAnalytics.thUnits', 'ชิ้น')}</th>
<th className="px-4 py-3 text-right">ออเดอร์</th> <th className="px-4 py-3 text-right">{t('shopeeAnalytics.thOrders', 'ออเดอร์')}</th>
<th className="px-4 py-3 text-right">ยอดรายได้</th> <th className="px-4 py-3 text-right">{t('shopeeAnalytics.thRevenue', 'ยอดรายได้')}</th>
<th className="px-4 py-3 text-right">% จากยอดรวม</th> <th className="px-4 py-3 text-right">{t('shopeeAnalytics.thPercentTotal', '% จากยอดรวม')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -487,20 +534,22 @@ export default function ShopeeAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<ReceiptText className="w-5 h-5 text-rose-400" /> <ReceiptText className="w-5 h-5 text-rose-400" />
<h2 className="text-lg font-bold text-white">สรุปค่าใช้จ่าย (Cost Breakdown)</h2> <h2 className="text-lg font-bold text-white">{t('shopeeAnalytics.costBreakdownTitle', 'สรุปค่าใช้จ่าย (Cost Breakdown)')}</h2>
</div> </div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{[ {[
{ label: 'ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' }, { label: t('shopeeAnalytics.stepGross', 'ยอดขาย Gross'), value: summary.totalRevenue, color: 'text-emerald-400', bar: 'bg-emerald-400' },
{ label: 'หัก: ค่าคอมมิชชั่น', value: -summary.totalCommission, color: 'text-rose-400', bar: 'bg-rose-400' }, { label: t('shopeeAnalytics.stepCommission', 'หัก: ค่าคอมมิชชั่น'), value: -summary.totalCommission, color: 'text-rose-400', bar: 'bg-rose-400' },
{ label: 'หัก: ค่าจัดส่ง (ผู้ซื้อ)', value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' }, { label: t('shopeeAnalytics.stepShipping', 'หัก: ค่าจัดส่ง (ผู้ซื้อ)'), value: -summary.totalShipping, color: 'text-orange-400', bar: 'bg-orange-400' },
{ label: '= รายได้สุทธิ', value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true }, { label: t('shopeeAnalytics.stepNet', '= รายได้สุทธิ'), value: summary.netRevenue, color: 'text-cyan-glow', bar: 'bg-cyan-glow', highlight: true },
].map((item, i) => ( ].map((item, i) => (
<div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/10 bg-white/[0.02]'}`}> <div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-cyan-glow/20 bg-cyan-glow/5' : 'border-white/10 bg-white/[0.02]'}`}>
<p className="text-[10px] uppercase tracking-widest text-slate-500 mb-1">{item.label}</p> <p className="text-[10px] uppercase tracking-widest text-slate-500 mb-1">{item.label}</p>
<p className={`text-xl font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p> <p className={`text-xl font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p>
{summary.totalRevenue > 0 && ( {summary.totalRevenue > 0 && (
<p className="text-[10px] text-slate-600 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด</p> <p className="text-[10px] text-slate-600 mt-1">
{t('shopeeAnalytics.percentOfSales', { pct: fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1), defaultValue: `${fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอด` })}
</p>
)} )}
</div> </div>
))} ))}
@@ -515,9 +564,18 @@ export default function ShopeeAnalyticsPage() {
)} )}
</div> </div>
<div className="flex gap-6 mt-3 text-[10px] text-slate-500"> <div className="flex gap-6 mt-3 text-[10px] text-slate-500">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-rose-400/70" /> Commission ({fmt(summary.commissionRate, 1)}%)</span> <span className="flex items-center gap-1.5">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-orange-400/70" /> ค่าจัดส่ง ({fmt(summary.totalRevenue > 0 ? (summary.totalShipping / summary.totalRevenue) * 100 : 0, 1)}%)</span> <span className="w-2.5 h-2.5 rounded-full bg-rose-400/70" />
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded-full bg-cyan-glow/60" /> Net ({fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%)</span> {t('shopeeAnalytics.legendCommission', { rate: fmt(summary.commissionRate, 1), defaultValue: `Commission (${fmt(summary.commissionRate, 1)}%)` })}
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-orange-400/70" />
{t('shopeeAnalytics.legendShipping', { rate: fmt(summary.totalRevenue > 0 ? (summary.totalShipping / summary.totalRevenue) * 100 : 0, 1), defaultValue: `ค่าจัดส่ง (${fmt(summary.totalRevenue > 0 ? (summary.totalShipping / summary.totalRevenue) * 100 : 0, 1)}%)` })}
</span>
<span className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-cyan-glow/60" />
{t('shopeeAnalytics.legendNet', { rate: fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1), defaultValue: `Net (${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%)` })}
</span>
</div> </div>
</div> </div>
+75 -52
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid, BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
PieChart, Pie, Cell, Legend, PieChart, Pie, Cell, Legend,
@@ -43,16 +44,17 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; }
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: '7 วัน' }, const PRESET_CONFIG: { key: Preset; i18nKey: string; fallback: string }[] = [
{ key: '30d', label: '30 วัน' }, { key: '7d', i18nKey: 'analytics.preset7d', fallback: '7 วัน' },
{ key: '90d', label: '90 วัน' }, { key: '30d', i18nKey: 'analytics.preset30d', fallback: '30 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' }, { key: '90d', i18nKey: 'analytics.preset90d', fallback: '90 วัน' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' }, { key: 'thisMonth', i18nKey: 'analytics.presetThisMonth', fallback: 'เดือนนี้' },
{ key: 'year', label: 'ปีนี้' }, { key: 'lastMonth', i18nKey: 'analytics.presetLastMonth', fallback: 'เดือนที่แล้ว' },
{ key: 'lastYear', label: 'ปีที่แล้ว' }, { key: 'year', i18nKey: 'analytics.presetThisYear', fallback: 'ปีนี้' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' }, { key: 'lastYear', i18nKey: 'analytics.presetLastYear', fallback: 'ปีที่แล้ว' },
{ key: 'all', label: 'ทั้งหมด' }, { key: 'last12m', i18nKey: 'analytics.presetLast12m', fallback: '12 เดือนที่ผ่านมา' },
{ key: 'all', i18nKey: 'analytics.presetAll', fallback: 'ทั้งหมด' },
]; ];
function authHeaders() { function authHeaders() {
@@ -93,6 +95,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function ShopeeOverviewPage() { export default function ShopeeOverviewPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -129,13 +132,11 @@ export default function ShopeeOverviewPage() {
loadData(dateFrom, dateTo); loadData(dateFrom, dateTo);
}; };
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" /> <Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
<span className="text-slate-500">กำลังรวมข้อมูลทุกร้าน...</span> <span className="text-slate-500">{t('shopeeOverview.loading', 'กำลังรวมข้อมูลทุกร้าน...')}</span>
</div> </div>
); );
} }
@@ -144,9 +145,9 @@ export default function ShopeeOverviewPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<Store className="w-14 h-14 text-slate-700" /> <Store className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน'}</p> <p className="text-slate-500">{error || t('shopeeOverview.noData', 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน')}</p>
<Link to="/shopee" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm"> <Link to="/shopee" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm">
← กลับ ← {t('shopeeOverview.backToShopee', 'กลับหน้า Shopee')}
</Link> </Link>
</div> </div>
); );
@@ -168,7 +169,7 @@ export default function ShopeeOverviewPage() {
</div> </div>
<div> <div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Shopee</p> <p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">Shopee</p>
<h1 className="text-3xl font-bold text-white tracking-tight">ภาพรวมทุกร้าน</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('shopeeOverview.headerTitle', 'ภาพรวมทุกร้าน')}</h1>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -190,7 +191,7 @@ export default function ShopeeOverviewPage() {
{/* Preset Buttons */} {/* Preset Buttons */}
<div className="flex items-center gap-1.5 flex-wrap"> <div className="flex items-center gap-1.5 flex-wrap">
<Calendar className="w-4 h-4 text-slate-500 mr-1" /> <Calendar className="w-4 h-4 text-slate-500 mr-1" />
{PRESETS.map(p => ( {PRESET_CONFIG.map(p => (
<button <button
key={p.key} key={p.key}
onClick={() => applyPreset(p.key)} onClick={() => applyPreset(p.key)}
@@ -200,7 +201,7 @@ export default function ShopeeOverviewPage() {
: 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white' : 'bg-white/5 text-slate-400 border-white/10 hover:bg-white/10 hover:text-white'
}`} }`}
> >
{p.label} {t(p.i18nKey, p.fallback)}
</button> </button>
))} ))}
</div> </div>
@@ -209,12 +210,12 @@ export default function ShopeeOverviewPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('analytics.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-cyan-glow/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-cyan-glow/40 focus:outline-none"
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-cyan-glow/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-cyan-glow/40 focus:outline-none"
@@ -223,7 +224,7 @@ export default function ShopeeOverviewPage() {
onClick={applyCustom} onClick={applyCustom}
className="px-4 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-medium hover:bg-cyan-glow/20 transition-all" className="px-4 py-1.5 rounded-lg bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow text-xs font-medium hover:bg-cyan-glow/20 transition-all"
> >
ดูข้อมูล {t('analytics.apply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -237,20 +238,42 @@ export default function ShopeeOverviewPage() {
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors"
> >
✕ ล้าง ✕ {t('analytics.clear', 'ล้าง')}
</button> </button>
</div> </div>
)} )}
</div> </div>
{/* Grand KPI Row */} {/* Grand KPI Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard label="ยอดขายรวมทุกร้าน" value={fmtB(totals.totalRevenue)} sub={`${fmt(totals.totalOrders)} คำสั่งซื้อ`} color="text-emerald-400" icon={DollarSign} /> <KPICard
<KPICard label="รายได้สุทธิรวม" value={fmtB(totals.netRevenue)} sub={`Margin ${fmt(totals.totalRevenue > 0 ? (totals.netRevenue / totals.totalRevenue) * 100 : 0, 1)}%`} color="text-cyan-glow" icon={TrendingUp} /> label={t('shopeeOverview.grandRevenue', 'ยอดขายรวมทุกร้าน')}
<KPICard label="ค่าคอมมิชชั่นรวม" value={fmtB(totals.totalCommission)} sub={`${fmt(totals.commissionRate, 1)}% ของยอดขาย`} color="text-rose-400" icon={ReceiptText} /> value={fmtB(totals.totalRevenue)}
<KPICard label="AOV รวม" value={fmtB(totals.aov)} sub={`ยกเลิก ${fmt(totals.cancelRate, 1)}%`} color="text-amber-400" icon={ShoppingCart} /> sub={t('shopeeOverview.ordersCount', { count: fmt(totals.totalOrders), defaultValue: `${fmt(totals.totalOrders)} คำสั่งซื้อ` })}
color="text-emerald-400"
icon={DollarSign}
/>
<KPICard
label={t('shopeeOverview.grandNetRevenue', 'รายได้สุทธิรวม')}
value={fmtB(totals.netRevenue)}
sub={t('shopeeOverview.marginSub', { margin: fmt(totals.totalRevenue > 0 ? (totals.netRevenue / totals.totalRevenue) * 100 : 0, 1), defaultValue: `Margin ${fmt(totals.totalRevenue > 0 ? (totals.netRevenue / totals.totalRevenue) * 100 : 0, 1)}%` })}
color="text-cyan-glow"
icon={TrendingUp}
/>
<KPICard
label={t('shopeeOverview.grandCommission', 'ค่าคอมมิชชั่นรวม')}
value={fmtB(totals.totalCommission)}
sub={t('shopeeOverview.commissionRateSub', { rate: fmt(totals.commissionRate, 1), defaultValue: `${fmt(totals.commissionRate, 1)}% ของยอดขาย` })}
color="text-rose-400"
icon={ReceiptText}
/>
<KPICard
label={t('shopeeOverview.grandAov', 'AOV รวม')}
value={fmtB(totals.aov)}
sub={t('shopeeOverview.cancelRateSub', { rate: fmt(totals.cancelRate, 1), defaultValue: `ยกเลิก ${fmt(totals.cancelRate, 1)}%` })}
color="text-amber-400"
icon={ShoppingCart}
/>
</div> </div>
{/* Per-Shop Cards */} {/* Per-Shop Cards */}
@@ -271,19 +294,19 @@ export default function ShopeeOverviewPage() {
<p className="text-[10px] font-mono text-slate-500">{s.shop}</p> <p className="text-[10px] font-mono text-slate-500">{s.shop}</p>
</div> </div>
<span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}> <span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}>
{fmt(revShare, 1)}% of total {t('shopeeOverview.percentOfTotal', { pct: fmt(revShare, 1), defaultValue: `${fmt(revShare, 1)}% of total` })}
</span> </span>
</div> </div>
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
{[ {[
{ label: 'ยอดขาย', value: fmtB(s.revenue), color: 'text-emerald-400' }, { label: t('shopeeOverview.statRevenue', 'ยอดขาย'), value: fmtB(s.revenue), color: 'text-emerald-400' },
{ label: 'สุทธิ', value: fmtB(s.net), color: 'text-cyan-glow' }, { label: t('shopeeOverview.statNet', 'สุทธิ'), value: fmtB(s.net), color: 'text-cyan-glow' },
{ label: 'คำสั่งซื้อ', value: fmt(s.orders), color: 'text-white' }, { label: t('shopeeOverview.statOrders', 'คำสั่งซื้อ'), value: fmt(s.orders), color: 'text-white' },
{ label: 'ค่าคอม', value: fmtB(s.commission), color: 'text-rose-400' }, { label: t('shopeeOverview.statCommission', 'ค่าคอม'), value: fmtB(s.commission), color: 'text-rose-400' },
{ label: 'ยกเลิก', value: `${fmt(s.cancelRate, 1)}%`, color: 'text-amber-400' }, { label: t('shopeeOverview.statCancelled', 'ยกเลิก'), value: `${fmt(s.cancelRate, 1)}%`, color: 'text-amber-400' },
{ label: 'AOV', value: fmtB(s.aov), color: 'text-purple-400' }, { label: t('shopeeOverview.statAov', 'AOV'), value: fmtB(s.aov), color: 'text-purple-400' },
].map(item => ( ].map(item => (
<div key={item.label}> <div key={item.label}>
<p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p> <p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p>
@@ -304,7 +327,7 @@ export default function ShopeeOverviewPage() {
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90" className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90"
style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }} style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }}
> >
<TrendingUp className="w-3.5 h-3.5" /> ดู Analytics <TrendingUp className="w-3.5 h-3.5" /> {t('shopeeOverview.viewAnalytics', 'ดู Analytics')}
</Link> </Link>
</div> </div>
); );
@@ -315,7 +338,7 @@ export default function ShopeeOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className="w-5 h-5 text-cyan-glow" /> <TrendingUp className="w-5 h-5 text-cyan-glow" />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน (แยกตามร้าน)</h2> <h2 className="text-lg font-bold text-white">{t('shopeeOverview.monthlySalesTitle', 'ยอดขายรายเดือน (แยกตามร้าน)')}</h2>
</div> </div>
{monthly.length > 0 ? ( {monthly.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -333,7 +356,7 @@ export default function ShopeeOverviewPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('shopeeOverview.noMonthlyData', 'ยังไม่มีข้อมูลรายเดือน')}</p>
)} )}
</div> </div>
@@ -344,7 +367,7 @@ export default function ShopeeOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<BadgePercent className="w-5 h-5 text-amber-400" /> <BadgePercent className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนยอดขายตามร้าน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeOverview.salesShareTitle', 'สัดส่วนยอดขายตามร้าน')}</h2>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="h-[220px] flex-1"> <div className="h-[220px] flex-1">
@@ -376,17 +399,17 @@ export default function ShopeeOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Store className="w-5 h-5 text-slate-400" /> <Store className="w-5 h-5 text-slate-400" />
<h2 className="text-lg font-bold text-white">เปรียบเทียบร้าน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeOverview.shopComparisonTitle', 'เปรียบเทียบร้าน')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="text-slate-500 uppercase border-b border-white/10"> <thead className="text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left">ร้าน</th> <th className="pb-3 text-left">{t('shopeeOverview.thShop', 'ร้าน')}</th>
<th className="pb-3 text-right">ยอดขาย</th> <th className="pb-3 text-right">{t('shopeeOverview.thRevenue', 'ยอดขาย')}</th>
<th className="pb-3 text-right">สุทธิ</th> <th className="pb-3 text-right">{t('shopeeOverview.thNet', 'สุทธิ')}</th>
<th className="pb-3 text-right">ออเดอร์</th> <th className="pb-3 text-right">{t('shopeeOverview.thOrders', 'ออเดอร์')}</th>
<th className="pb-3 text-right">ยกเลิก</th> <th className="pb-3 text-right">{t('shopeeOverview.thCancelled', 'ยกเลิก')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -406,7 +429,7 @@ export default function ShopeeOverviewPage() {
))} ))}
{/* Total row */} {/* Total row */}
<tr className="bg-white/[0.03]"> <tr className="bg-white/[0.03]">
<td className="py-3 font-bold text-white text-xs">รวมทั้งหมด</td> <td className="py-3 font-bold text-white text-xs">{t('shopeeOverview.totalAll', 'รวมทั้งหมด')}</td>
<td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(totals.totalRevenue)}</td> <td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(totals.totalRevenue)}</td>
<td className="py-3 text-right font-mono font-bold text-cyan-glow">{fmtB(totals.netRevenue)}</td> <td className="py-3 text-right font-mono font-bold text-cyan-glow">{fmtB(totals.netRevenue)}</td>
<td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(totals.totalOrders)}</td> <td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(totals.totalOrders)}</td>
@@ -422,18 +445,18 @@ export default function ShopeeOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี — รวมทุกร้าน</h2> <h2 className="text-lg font-bold text-white">{t('shopeeOverview.topProductsTitle', 'Top 10 สินค้าขายดี — รวมทุกร้าน')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-slate-500 uppercase border-b border-white/10"> <thead className="text-xs text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left w-8">#</th> <th className="pb-3 text-left w-8">#</th>
<th className="pb-3 text-left">สินค้า</th> <th className="pb-3 text-left">{t('shopeeOverview.thProduct', 'สินค้า')}</th>
<th className="pb-3 text-left">ตัวเลือก</th> <th className="pb-3 text-left">{t('shopeeOverview.thVariation', 'ตัวเลือก')}</th>
<th className="pb-3 text-right">ชิ้น</th> <th className="pb-3 text-right">{t('shopeeOverview.thUnits', 'ชิ้น')}</th>
<th className="pb-3 text-right">ยอดรายได้</th> <th className="pb-3 text-right">{t('shopeeOverview.thProductRevenue', 'ยอดรายได้')}</th>
<th className="pb-3 text-right">% รวม</th> <th className="pb-3 text-right">{t('shopeeOverview.thPercentTotal', '% รวม')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
+75 -61
View File
@@ -4,16 +4,18 @@ import {
Filter, Calendar, Table2, Search, Filter, Calendar, Table2, Search,
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg'; import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg';
import DistrictMapModal from '../components/tiktok-map/DistrictMapModal'; import DistrictMapModal from '../components/tiktok-map/DistrictMapModal';
import { fmtBaht, fmtNum } from '../utils/thaiGeoUtils'; import { fmtBaht, fmtNum, getLocalizedProvinceName, getLocalizedRegionName } from '../utils/thaiGeoUtils';
import { SHOPEE_SHOPS } from './ShopeePage'; import { SHOPEE_SHOPS } from './ShopeePage';
type ViewMode = 'map' | 'list'; type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
export default function ShopeeSalesMapPage() { export default function ShopeeSalesMapPage() {
const { t, i18n } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('map'); const [viewMode, setViewMode] = useState<ViewMode>('map');
const [shop, setShop] = useState('all'); const [shop, setShop] = useState('all');
const [period, setPeriod] = useState<TimePeriod>('all'); const [period, setPeriod] = useState<TimePeriod>('all');
@@ -93,11 +95,11 @@ export default function ShopeeSalesMapPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white">
แผนที่ยอดขายประเทศไทย (Shopee Sales Map) {t('shopeeSalesMap.pageTitle')}
</h1> </h1>
</div> </div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
วิเคราะห์การกระจายตัวของยอดขาย Shopee และศักยภาพลูกค้าแยกตาม 77 จังหวัด {t('shopeeSalesMap.pageSubtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -108,23 +110,23 @@ export default function ShopeeSalesMapPage() {
<div className="flex items-center p-1 bg-slate-900 border border-white/10 rounded-xl"> <div className="flex items-center p-1 bg-slate-900 border border-white/10 rounded-xl">
<button <button
onClick={() => setViewMode('map')} onClick={() => setViewMode('map')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
viewMode === 'map' viewMode === 'map'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
<Map className="w-3.5 h-3.5" /> มุมมองแผนที่ <Map className="w-3.5 h-3.5" /> {t('shopeeSalesMap.viewMap')}
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
viewMode === 'list' viewMode === 'list'
? 'bg-orange-500/20 text-orange-400 border border-orange-500/30' ? 'bg-orange-500/20 text-orange-400 border border-orange-500/30'
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
<Table2 className="w-3.5 h-3.5" /> มุมมองรายการ <Table2 className="w-3.5 h-3.5" /> {t('shopeeSalesMap.viewList')}
</button> </button>
</div> </div>
@@ -132,9 +134,9 @@ export default function ShopeeSalesMapPage() {
<select <select
value={shop} value={shop}
onChange={(e) => setShop(e.target.value)} onChange={(e) => setShop(e.target.value)}
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50" className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50 cursor-pointer"
> >
<option value="all">ทั้งหมด (ทุกร้าน)</option> <option value="all">{t('shopeeSalesMap.allShops')}</option>
{SHOPEE_SHOPS.map((s) => ( {SHOPEE_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -146,12 +148,12 @@ export default function ShopeeSalesMapPage() {
<select <select
value={period} value={period}
onChange={(e) => handlePeriodChange(e.target.value as TimePeriod)} onChange={(e) => handlePeriodChange(e.target.value as TimePeriod)}
className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50" className="bg-slate-900 border border-white/10 text-xs text-slate-300 rounded-xl px-3 py-2 font-medium focus:outline-none focus:border-orange-500/50 cursor-pointer"
> >
<option value="all">ทั้งหมด (All Time)</option> <option value="all">{t('shopeeSalesMap.periodAll')}</option>
<option value="30days">30 วันล่าสุด</option> <option value="30days">{t('shopeeSalesMap.period30Days')}</option>
<option value="this_year">ปีนี้</option> <option value="this_year">{t('shopeeSalesMap.periodThisYear')}</option>
<option value="custom">กำหนดเอง</option> <option value="custom">{t('shopeeSalesMap.periodCustom')}</option>
</select> </select>
{/* Custom Date Pickers */} {/* Custom Date Pickers */}
@@ -184,7 +186,7 @@ export default function ShopeeSalesMapPage() {
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer" className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-orange-500 hover:bg-orange-600 disabled:opacity-50 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
> >
{loading ? <span className="animate-spin">⏳</span> : <Filter className="w-3.5 h-3.5" />} {loading ? <span className="animate-spin">⏳</span> : <Filter className="w-3.5 h-3.5" />}
{loading ? 'กำลังโหลด...' : 'กรอง'} {loading ? t('shopeeSalesMap.loading') : t('shopeeSalesMap.filter')}
</button> </button>
</div> </div>
</div> </div>
@@ -194,7 +196,7 @@ export default function ShopeeSalesMapPage() {
{/* KPI 1 */} {/* KPI 1 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดขายรวม</span> <span>{t('shopeeSalesMap.kpiTotalRevenue')}</span>
<span className="text-orange-400 font-bold text-[11px] bg-orange-500/10 px-1.5 py-0.5 rounded-full border border-orange-500/20"> <span className="text-orange-400 font-bold text-[11px] bg-orange-500/10 px-1.5 py-0.5 rounded-full border border-orange-500/20">
Shopee Shopee
</span> </span>
@@ -203,28 +205,28 @@ export default function ShopeeSalesMapPage() {
<p className="text-xl md:text-2xl font-bold text-white font-mono"> <p className="text-xl md:text-2xl font-bold text-white font-mono">
{fmtBaht(mapData?.kpi?.totalRevenue || 0)} {fmtBaht(mapData?.kpi?.totalRevenue || 0)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">Shopee Total Revenue</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('shopeeSalesMap.kpiTotalRevenueSub')}</p>
</div> </div>
</div> </div>
{/* KPI 2 */} {/* KPI 2 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>คำสั่งซื้อสะสม</span> <span>{t('shopeeSalesMap.kpiTotalOrders')}</span>
<ShoppingBag className="w-3.5 h-3.5 text-orange-400" /> <ShoppingBag className="w-3.5 h-3.5 text-orange-400" />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white font-mono"> <p className="text-xl md:text-2xl font-bold text-white font-mono">
{fmtNum(mapData?.kpi?.totalOrders || 0)} {fmtNum(mapData?.kpi?.totalOrders || 0)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">รวมทุกคำสั่งซื้อ Shopee</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('shopeeSalesMap.kpiTotalOrdersSub')}</p>
</div> </div>
</div> </div>
{/* KPI 3 */} {/* KPI 3 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>การเข้าถึงจังหวัด</span> <span>{t('shopeeSalesMap.kpiCoverage')}</span>
<span className="text-emerald-400 font-bold text-[11px] font-mono"> <span className="text-emerald-400 font-bold text-[11px] font-mono">
{(mapData?.kpi?.coveragePercent || 0).toFixed(1)}% {(mapData?.kpi?.coveragePercent || 0).toFixed(1)}%
</span> </span>
@@ -232,41 +234,45 @@ export default function ShopeeSalesMapPage() {
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono"> <p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono">
{mapData?.kpi?.coverageCount || 0}{' '} {mapData?.kpi?.coverageCount || 0}{' '}
<span className="text-xs text-slate-500 font-normal">/ 77 จังหวัด</span> <span className="text-xs text-slate-500 font-normal">{t('shopeeSalesMap.outOf77Provinces')}</span>
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">ครอบคลุมทั่วประเทศ</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('shopeeSalesMap.nationwideCoverage')}</p>
</div> </div>
</div> </div>
{/* KPI 4 */} {/* KPI 4 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดเฉลี่ย/ออเดอร์</span> <span>{t('shopeeSalesMap.kpiAov')}</span>
<DollarSign className="w-3.5 h-3.5 text-amber-400" /> <DollarSign className="w-3.5 h-3.5 text-amber-400" />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-amber-400 font-mono"> <p className="text-xl md:text-2xl font-bold text-amber-400 font-mono">
{fmtBaht(mapData?.kpi?.aov || 0)} {fmtBaht(mapData?.kpi?.aov || 0)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">AOV คำนวณจากทุกคำสั่งซื้อ</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('shopeeSalesMap.aovSub')}</p>
</div> </div>
</div> </div>
{/* KPI 5 */} {/* 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="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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ภาคที่มีสัดส่วนสูงสุด</span> <span>{t('shopeeSalesMap.kpiTopRegion')}</span>
<span className="text-orange-400 font-bold text-[10px] bg-orange-500/10 px-2 py-0.5 rounded-full border border-orange-500/20"> <span className="text-orange-400 font-bold text-[10px] bg-orange-500/10 px-2 py-0.5 rounded-full border border-orange-500/20">
Top Region {t('shopeeSalesMap.topRegionBadge')}
</span> </span>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white tracking-tight"> <p className="text-xl md:text-2xl font-bold text-white tracking-tight">
{mapData?.kpi?.topRegion?.name || 'ภาคกลาง'} {mapData?.kpi?.topRegion?.name
? getLocalizedRegionName(mapData.kpi.topRegion.name, i18n.language)
: getLocalizedRegionName('ภาคกลาง', i18n.language)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5"> <p className="text-[10px] text-slate-500 mt-0.5">
{fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ ( {t('shopeeSalesMap.topRegionSub', {
{fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)}) count: fmtNum(mapData?.kpi?.topRegion?.orders || 0),
amount: fmtBaht(mapData?.kpi?.topRegion?.revenue || 0),
})}
</p> </p>
</div> </div>
</div> </div>
@@ -279,9 +285,9 @@ export default function ShopeeSalesMapPage() {
<div className="lg:col-span-8 flex flex-col gap-4"> <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="bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6 flex flex-col">
<div className="mb-4"> <div className="mb-4">
<h2 className="text-lg font-bold text-white">แผนที่ยอดขายประเทศไทย (Shopee)</h2> <h2 className="text-lg font-bold text-white">{t('shopeeSalesMap.mapCardTitle')}</h2>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น {t('shopeeSalesMap.mapCardSub')}
</p> </p>
</div> </div>
@@ -306,21 +312,23 @@ export default function ShopeeSalesMapPage() {
<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="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"> <div className="flex items-center justify-between text-xs">
<span className="text-slate-500 uppercase tracking-wider font-bold"> <span className="text-slate-500 uppercase tracking-wider font-bold">
จังหวัด {t('shopeeSalesMap.provinceLabel')}
</span> </span>
{selectedProvince && ( {selectedProvince && (
<button <button
onClick={() => setSelectedProvince(null)} onClick={() => setSelectedProvince(null)}
className="text-xs text-orange-400 hover:underline font-bold" className="text-xs text-orange-400 hover:underline font-bold cursor-pointer"
> >
ดูทั่วประเทศ {t('shopeeSalesMap.viewNationwide')}
</button> </button>
)} )}
</div> </div>
<div> <div>
<h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight"> <h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
{currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'} {currentProvinceInfo
? getLocalizedProvinceName(currentProvinceInfo.nameTh, i18n.language)
: t('shopeeSalesMap.nationwide')}
</h3> </h3>
<p className="text-2xl font-bold text-orange-400 font-mono mt-1"> <p className="text-2xl font-bold text-orange-400 font-mono mt-1">
{fmtBaht( {fmtBaht(
@@ -330,23 +338,24 @@ export default function ShopeeSalesMapPage() {
)} )}
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
{fmtNum( {t('shopeeSalesMap.provinceCardSub', {
orders: fmtNum(
currentProvinceInfo currentProvinceInfo
? currentProvinceInfo.orders ? currentProvinceInfo.orders
: mapData?.kpi?.totalOrders || 0 : mapData?.kpi?.totalOrders || 0
)}{' '} ),
คำสั่งซื้อ •{' '} region: currentProvinceInfo
{currentProvinceInfo ? getLocalizedRegionName(currentProvinceInfo.regionTh, i18n.language)
? currentProvinceInfo.regionTh : t('shopeeSalesMap.coverageCountText', { count: mapData?.kpi?.coverageCount || 0 }),
: `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`} })}
</p> </p>
</div> </div>
{/* Color Scale Legend */} {/* Color Scale Legend */}
<div className="space-y-1.5 pt-2"> <div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between text-[11px] text-slate-500"> <div className="flex items-center justify-between text-[11px] text-slate-500">
<span>ไม่มียอด</span> <span>{t('shopeeSalesMap.noSales')}</span>
<span>ยอดสูง</span> <span>{t('shopeeSalesMap.highSales')}</span>
</div> </div>
<div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" /> <div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" />
</div> </div>
@@ -357,11 +366,11 @@ export default function ShopeeSalesMapPage() {
onClick={() => setIsModalOpen(true)} onClick={() => setIsModalOpen(true)}
className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer" className="w-full flex items-center justify-center gap-2 py-3 px-4 rounded-xl bg-orange-500 hover:bg-orange-600 text-white font-bold text-xs transition-all shadow-lg shadow-orange-500/20 cursor-pointer"
> >
<MapPin className="w-4 h-4" /> ดูรายละเอียดและแผนที่อำเภอ (Map Focus) <MapPin className="w-4 h-4" /> {t('shopeeSalesMap.btnDistrictFocus')}
</button> </button>
) : ( ) : (
<div className="text-[11px] text-slate-500 leading-relaxed pt-1"> <div className="text-[11px] text-slate-500 leading-relaxed pt-1">
เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง {t('shopeeSalesMap.selectProvinceHint')}
</div> </div>
)} )}
</div> </div>
@@ -374,7 +383,7 @@ export default function ShopeeSalesMapPage() {
<div className="flex items-center justify-between pb-4 border-b border-white/10"> <div className="flex items-center justify-between pb-4 border-b border-white/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Award className="w-5 h-5 text-orange-400" /> <Award className="w-5 h-5 text-orange-400" />
<h2 className="text-base font-bold text-white">จังหวัดยอดขายสูง (Shopee)</h2> <h2 className="text-base font-bold text-white">{t('shopeeSalesMap.topProvincesTitle')}</h2>
</div> </div>
<span className="text-xs text-slate-400 font-mono">Top 20</span> <span className="text-xs text-slate-400 font-mono">Top 20</span>
</div> </div>
@@ -405,10 +414,13 @@ export default function ShopeeSalesMapPage() {
</span> </span>
<div> <div>
<p className="text-xs font-bold text-white flex items-center gap-1.5"> <p className="text-xs font-bold text-white flex items-center gap-1.5">
{p.nameTh} {getLocalizedProvinceName(p.nameTh, i18n.language)}
</p> </p>
<p className="text-[10px] text-slate-500"> <p className="text-[10px] text-slate-500">
{p.orders} ออเดอร์ • {p.regionTh} {t('shopeeSalesMap.rankingItemSub', {
orders: p.orders,
region: getLocalizedRegionName(p.regionTh, i18n.language),
})}
</p> </p>
</div> </div>
</div> </div>
@@ -418,7 +430,7 @@ export default function ShopeeSalesMapPage() {
{fmtBaht(p.revenue)} {fmtBaht(p.revenue)}
</p> </p>
<p className="text-[9px] text-slate-500"> <p className="text-[9px] text-slate-500">
AOV {fmtBaht(p.aov)} {t('shopeeSalesMap.rankingAov', { amount: fmtBaht(p.aov) })}
</p> </p>
</div> </div>
</div> </div>
@@ -432,7 +444,7 @@ export default function ShopeeSalesMapPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-6 space-y-4"> <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"> <div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-bold text-white"> <h2 className="text-base font-bold text-white">
ตารางสรุปยอดขาย Shopee แยกราย 77 จังหวัด {t('shopeeSalesMap.tableTitle')}
</h2> </h2>
<div className="relative w-64"> <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" /> <Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
@@ -440,7 +452,7 @@ export default function ShopeeSalesMapPage() {
type="text" type="text"
value={listSearch} value={listSearch}
onChange={(e) => setListSearch(e.target.value)} onChange={(e) => setListSearch(e.target.value)}
placeholder="ค้นหาชื่อจังหวัด หรือ ภาค..." placeholder={t('shopeeSalesMap.searchPlaceholder')}
className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/50" className="w-full bg-slate-950/80 border border-white/10 rounded-xl pl-9 pr-3 py-1.5 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/50"
/> />
</div> </div>
@@ -450,13 +462,13 @@ export default function ShopeeSalesMapPage() {
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10"> <thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10">
<tr> <tr>
<th className="px-4 py-3">รหัส</th> <th className="px-4 py-3">{t('shopeeSalesMap.thCode')}</th>
<th className="px-4 py-3">จังหวัด</th> <th className="px-4 py-3">{t('shopeeSalesMap.thProvince')}</th>
<th className="px-4 py-3">ภูมิภาค</th> <th className="px-4 py-3">{t('shopeeSalesMap.thRegion')}</th>
<th className="px-4 py-3 text-right">ยอดขายรวม (฿)</th> <th className="px-4 py-3 text-right">{t('shopeeSalesMap.thRevenue')}</th>
<th className="px-4 py-3 text-right">จำนวนออเดอร์</th> <th className="px-4 py-3 text-right">{t('shopeeSalesMap.thOrders')}</th>
<th className="px-4 py-3 text-right">AOV (฿/ออเดอร์)</th> <th className="px-4 py-3 text-right">{t('shopeeSalesMap.thAov')}</th>
<th className="px-4 py-3 text-center">จัดการ</th> <th className="px-4 py-3 text-center">{t('shopeeSalesMap.thActions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -476,10 +488,12 @@ export default function ShopeeSalesMapPage() {
> >
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td> <td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white"> <td className="px-4 py-3 font-bold text-white">
{p.nameTh}{' '} {getLocalizedProvinceName(p.nameTh, i18n.language)}{' '}
{i18n.language !== 'en' && (
<span className="text-slate-500 text-[10px] font-mono">({p.nameEn})</span> <span className="text-slate-500 text-[10px] font-mono">({p.nameEn})</span>
)}
</td> </td>
<td className="px-4 py-3 text-slate-400">{p.regionTh}</td> <td className="px-4 py-3 text-slate-400">{getLocalizedRegionName(p.regionTh, i18n.language)}</td>
<td className="px-4 py-3 text-right font-mono font-bold text-orange-400"> <td className="px-4 py-3 text-right font-mono font-bold text-orange-400">
{fmtBaht(p.revenue)} {fmtBaht(p.revenue)}
</td> </td>
@@ -497,7 +511,7 @@ export default function ShopeeSalesMapPage() {
}} }}
className="px-3 py-1 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold border border-orange-500/20 transition-colors inline-flex items-center gap-1 cursor-pointer" className="px-3 py-1 bg-orange-500/10 hover:bg-orange-500/20 text-orange-400 rounded-lg text-xs font-bold border border-orange-500/20 transition-colors inline-flex items-center gap-1 cursor-pointer"
> >
<MapPin className="w-3 h-3" /> แผนที่อำเภอ <MapPin className="w-3 h-3" /> {t('shopeeSalesMap.btnDistrictMap')}
</button> </button>
</td> </td>
</tr> </tr>
+71 -56
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
Calculator, Calculator,
Info, Info,
@@ -10,6 +11,7 @@ function fmt(val: number) {
} }
export default function ShopeeShopCasePage() { export default function ShopeeShopCasePage() {
const { t } = useTranslation()
const [resetKey, setResetKey] = useState(0) const [resetKey, setResetKey] = useState(0)
const [selectedCase, setSelectedCase] = useState<'case1' | 'case2' | 'case3'>('case1') const [selectedCase, setSelectedCase] = useState<'case1' | 'case2' | 'case3'>('case1')
@@ -133,10 +135,10 @@ export default function ShopeeShopCasePage() {
<div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-orange-500/10 border border-orange-500/20 flex items-center justify-center">
<Calculator className="w-5 h-5 text-orange-500" /> <Calculator className="w-5 h-5 text-orange-500" />
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight">Shopee Shop Case</h1> <h1 className="text-2xl font-bold text-white tracking-tight">{t('shopeeShopCase.pageTitle', 'Shopee Shop Case')}</h1>
</div> </div>
<p className="text-sm text-slate-500 max-w-2xl"> <p className="text-sm text-slate-500 max-w-2xl">
ศึกษาโครงสร้างบิลรับเงินจริงจากระบบ Shopee พร้อมทั้งจำลองคำนวณราคาขาย, คูปองส่วนลดร้านค้า, ต้นทุน และประเมินสถิติกำไร & ROI ก่อนขายจริง {t('shopeeShopCase.pageSubtitle', 'ศึกษาโครงสร้างบิลรับเงินจริงจากระบบ Shopee พร้อมทั้งจำลองคำนวณราคาขาย, คูปองส่วนลดร้านค้า, ต้นทุน และประเมินสถิติกำไร & ROI ก่อนขายจริง')}
</p> </p>
</div> </div>
<button <button
@@ -160,10 +162,10 @@ export default function ShopeeShopCasePage() {
} }
}} }}
className="btn-ghost flex items-center gap-2 text-xs shrink-0" className="btn-ghost flex items-center gap-2 text-xs shrink-0"
title="รีเซ็ตทุกค่า" title={t('shopeeShopCase.resetTitle', 'รีเซ็ตทุกค่า')}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
Reset {t('shopeeShopCase.reset', 'Reset')}
</button> </button>
</div> </div>
@@ -172,7 +174,7 @@ export default function ShopeeShopCasePage() {
<div className="flex items-center justify-between mb-4 flex-wrap gap-4 border-b border-white/5 pb-4"> <div className="flex items-center justify-between mb-4 flex-wrap gap-4 border-b border-white/5 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Info className="w-5 h-5 text-orange-500" /> <Info className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-bold text-white">กรณีศึกษา & เครื่องมือจำลองค่าธรรมเนียมจริง (Shopee)</h2> <h2 className="text-lg font-bold text-white">{t('shopeeShopCase.sectionTitle', 'กรณีศึกษา & เครื่องมือจำลองค่าธรรมเนียมจริง (Shopee)')}</h2>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex p-1 bg-night-900 border border-white/10 rounded-lg"> <div className="flex p-1 bg-night-900 border border-white/10 rounded-lg">
@@ -181,21 +183,21 @@ export default function ShopeeShopCasePage() {
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case1' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case1' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case1')} onClick={() => setSelectedCase('case1')}
> >
ตัวอย่างที่ 1 (ยอด ฿79) {t('shopeeShopCase.case1Btn', 'ตัวอย่างที่ 1 (ยอด ฿79)')}
</button> </button>
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case2' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case2' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case2')} onClick={() => setSelectedCase('case2')}
> >
ตัวอย่างที่ 2 (ยอด ฿79 + AMS) {t('shopeeShopCase.case2Btn', 'ตัวอย่างที่ 2 (ยอด ฿79 + AMS)')}
</button> </button>
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case3' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case3' ? 'bg-orange-500/20 text-orange-500' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case3')} onClick={() => setSelectedCase('case3')}
> >
ตัวอย่างที่ 3 (ยอด ฿190 + AMS) {t('shopeeShopCase.case3Btn', 'ตัวอย่างที่ 3 (ยอด ฿190 + AMS)')}
</button> </button>
</div> </div>
<button <button
@@ -219,37 +221,44 @@ export default function ShopeeShopCasePage() {
}} }}
className="btn-ghost text-xs py-1.5 px-3 rounded border border-white/10" className="btn-ghost text-xs py-1.5 px-3 rounded border border-white/10"
> >
ใช้ค่าตั้งต้นตัวอย่าง {t('shopeeShopCase.useDefaultBtn', 'ใช้ค่าตั้งต้นตัวอย่าง')}
</button> </button>
</div> </div>
</div> </div>
<p className="text-xs text-slate-400 mb-2 font-medium"> <p className="text-xs text-slate-400 mb-2 font-medium">
รายละเอียดบิลจริงดั้งเดิมของ {selectedCase === 'case1' ? 'ตัวอย่างที่ 1' : 'ตัวอย่างที่ 2'}: {t('shopeeShopCase.billDetailSubtitle', {
caseName: selectedCase === 'case1'
? t('shopeeShopCase.case1Name', 'ตัวอย่างที่ 1')
: selectedCase === 'case2'
? t('shopeeShopCase.case2Name', 'ตัวอย่างที่ 2')
: t('shopeeShopCase.case3Name', 'ตัวอย่างที่ 3'),
defaultValue: `รายละเอียดบิลจริงดั้งเดิมของ ${selectedCase === 'case1' ? 'ตัวอย่างที่ 1' : selectedCase === 'case2' ? 'ตัวอย่างที่ 2' : 'ตัวอย่างที่ 3'}:`
})}
</p> </p>
<div className="grid grid-cols-3 gap-4 p-3 rounded-lg bg-night-800/40 border border-white/5 text-xs mb-6 opacity-80"> <div className="grid grid-cols-3 gap-4 p-3 rounded-lg bg-night-800/40 border border-white/5 text-xs mb-6 opacity-80">
<div> <div>
<span className="text-slate-500 block">ยอดรวมสินค้าก่อนลด</span> <span className="text-slate-500 block">{t('shopeeShopCase.grossBeforeDiscount', 'ยอดรวมสินค้าก่อนลด')}</span>
<span className="font-bold text-white font-mono">{fmt(activeCase.beforeDiscount)}</span> <span className="font-bold text-white font-mono">{fmt(activeCase.beforeDiscount)}</span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">ส่วนลดจากร้านค้า</span> <span className="text-slate-500 block">{t('shopeeShopCase.shopDiscountWithPct', 'ส่วนลดจากร้านค้า')}</span>
<span className="font-bold text-rose-400 font-mono"> <span className="font-bold text-rose-400 font-mono">
{fmt(activeCase.shopDiscount)} (0.0%) {fmt(activeCase.shopDiscount)} (0.0%)
</span> </span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">ยอดรวมหลังหักส่วนลด (ฐานคิดค่าคอม)</span> <span className="text-slate-500 block">{t('shopeeShopCase.netBaseForCommission', 'ยอดรวมหลังหักส่วนลด (ฐานคิดค่าคอม)')}</span>
<span className="font-bold text-orange-500 font-mono">{fmt(activeCase.baseAmt)}</span> <span className="font-bold text-orange-500 font-mono">{fmt(activeCase.baseAmt)}</span>
</div> </div>
</div> </div>
{/* Simulator controls - Price & Discounts */} {/* Simulator controls - Price & Discounts */}
<p className="text-xs text-slate-400 mb-2 font-medium">1. จำลองการตั้งราคา ส่วนลด และต้นทุนสินค้า (ปรับแต่งเองได้):</p> <p className="text-xs text-slate-400 mb-2 font-medium">{t('shopeeShopCase.step1ControlTitle', '1. จำลองการตั้งราคา ส่วนลด และต้นทุนสินค้า (ปรับแต่งเองได้):')}</p>
<div className="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5"> <div className="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ราคาขายก่อนลด (List Price)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.listPriceLabel', 'ราคาขายก่อนลด (List Price)')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -263,7 +272,7 @@ export default function ShopeeShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ต้นทุนสินค้า (Cost)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.costLabel', 'ต้นทุนสินค้า (Cost)')}</label>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<input <input
type="number" type="number"
@@ -291,11 +300,11 @@ export default function ShopeeShopCasePage() {
</div> </div>
<div className="space-y-3 sm:col-span-2"> <div className="space-y-3 sm:col-span-2">
<label className="block text-xs text-slate-400 font-medium">ส่วนลดจากร้านค้า (คูปองร้านค้า 1-3)</label> <label className="block text-xs text-slate-400 font-medium">{t('shopeeShopCase.shopDiscountsLabel', 'ส่วนลดจากร้านค้า (คูปองร้านค้า 1-3)')}</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{discounts.map((discount, index) => ( {discounts.map((discount, index) => (
<div key={index} className="flex flex-col gap-2 p-2.5 rounded-lg bg-night-900/60 border border-white/5"> <div key={index} className="flex flex-col gap-2 p-2.5 rounded-lg bg-night-900/60 border border-white/5">
<span className="text-[10px] text-slate-500 font-bold">คูปองที่ #{index + 1}</span> <span className="text-[10px] text-slate-500 font-bold">{t('shopeeShopCase.couponIndex', { index: index + 1, defaultValue: `คูปองที่ #${index + 1}` })}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex p-0.5 bg-night-950 border border-white/10 rounded-md shrink-0"> <div className="flex p-0.5 bg-night-950 border border-white/10 rounded-md shrink-0">
<button <button
@@ -342,10 +351,10 @@ export default function ShopeeShopCasePage() {
</div> </div>
{/* Simulator controls - Fees */} {/* Simulator controls - Fees */}
<p className="text-xs text-slate-400 mb-2 font-medium">2. จำลองการปรับเปลี่ยนอัตราค่าธรรมเนียม Shopee (%):</p> <p className="text-xs text-slate-400 mb-2 font-medium">{t('shopeeShopCase.step2ControlTitle', '2. จำลองการปรับเปลี่ยนอัตราค่าธรรมเนียม Shopee (%):')}</p>
<div className="mb-6 grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5"> <div className="mb-6 grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5">
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าคอมมิชชั่น (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.feeCommission', 'ค่าคอมมิชชั่น (%)')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -360,7 +369,7 @@ export default function ShopeeShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าบริการ (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.feeService', 'ค่าบริการ (%)')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -375,7 +384,7 @@ export default function ShopeeShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าธุรกรรม (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.feeTransaction', 'ค่าธุรกรรม (%)')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -390,7 +399,7 @@ export default function ShopeeShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าคอม AMS (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('shopeeShopCase.feeAms', 'ค่าคอม AMS (%)')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -411,66 +420,66 @@ export default function ShopeeShopCasePage() {
{/* ก่อนปรับ (ค่าธรรมเนียมจากบิลตัวอย่างจริง) */} {/* ก่อนปรับ (ค่าธรรมเนียมจากบิลตัวอย่างจริง) */}
<div className="rounded-xl bg-night-800/30 border border-white/5 p-4 space-y-4"> <div className="rounded-xl bg-night-800/30 border border-white/5 p-4 space-y-4">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider border-b border-white/5 pb-2 flex justify-between"> <h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider border-b border-white/5 pb-2 flex justify-between">
<span>ก่อนปรับ (บิลดั้งเดิม)</span> <span>{t('shopeeShopCase.tableBeforeTitle', 'ก่อนปรับ (บิลดั้งเดิม)')}</span>
<span className="text-slate-500">รวม {((Math.abs(activeCase.origTotalFees) / activeCase.baseAmt) * 100).toFixed(2)}%</span> <span className="text-slate-500">{t('shopeeShopCase.totalPctHeader', { pct: ((Math.abs(activeCase.origTotalFees) / activeCase.baseAmt) * 100).toFixed(2), defaultValue: `รวม ${((Math.abs(activeCase.origTotalFees) / activeCase.baseAmt) * 100).toFixed(2)}%` })}</span>
</h3> </h3>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ยอดรวมสินค้าก่อนลด</span> <span>{t('shopeeShopCase.rowGross', 'ยอดรวมสินค้าก่อนลด')}</span>
<span className="font-mono">{fmt(activeCase.beforeDiscount)}</span> <span className="font-mono">{fmt(activeCase.beforeDiscount)}</span>
</div> </div>
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ส่วนลดจากร้านค้า</span> <span>{t('shopeeShopCase.rowShopDiscount', 'ส่วนลดจากร้านค้า')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.shopDiscount)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.shopDiscount)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300">
<span>ยอดสินค้าสุทธิ (ฐานคิดคอม)</span> <span>{t('shopeeShopCase.rowNetBase', 'ยอดสินค้าสุทธิ (ฐานคิดคอม)')}</span>
<span>{fmt(activeCase.baseAmt)}</span> <span>{fmt(activeCase.baseAmt)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมมิชชั่น (Commission Fee)</span> <span>{t('shopeeShopCase.rowFeeComm', '- ค่าคอมมิชชั่น (Commission Fee)')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origComm)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origComm)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าบริการ (Service Fee)</span> <span>{t('shopeeShopCase.rowFeeService', '- ค่าบริการ (Service Fee)')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origService)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origService)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าธุรกรรมการชำระเงิน</span> <span>{t('shopeeShopCase.rowFeeTrans', '- ค่าธุรกรรมการชำระเงิน')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origTrans)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origTrans)}</span>
</div> </div>
{activeCase.origAms !== 0 && ( {activeCase.origAms !== 0 && (
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมมิชชั่น AMS</span> <span>{t('shopeeShopCase.rowFeeAms', '- ค่าคอมมิชชั่น AMS')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origAms)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origAms)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300">
<span>รวมค่าธรรมเนียมโดนหัก</span> <span>{t('shopeeShopCase.rowTotalFees', 'รวมค่าธรรมเนียมโดนหัก')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origTotalFees)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origTotalFees)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm">
<span>ยอดเงินโอนเข้าบัญชีสุทธิ</span> <span>{t('shopeeShopCase.rowNetPayout', 'ยอดเงินโอนเข้าบัญชีสุทธิ')}</span>
<span className="font-mono text-orange-500">{fmt(activeCase.originalPayout)}</span> <span className="font-mono text-orange-500">{fmt(activeCase.originalPayout)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-1.5 text-slate-400"> <div className="flex justify-between border-t border-white/5 pt-1.5 text-slate-400">
<span>- ต้นทุนสินค้า (Cost)</span> <span>{t('shopeeShopCase.rowCost', '- ต้นทุนสินค้า (Cost)')}</span>
<span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm">
<span>กำไรสุทธิ (Net Profit)</span> <span>{t('shopeeShopCase.rowNetProfit', 'กำไรสุทธิ (Net Profit)')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origProfit >= 0 ? '' : '-'}฿{Math.abs(origProfit).toFixed(2)} {origProfit >= 0 ? '' : '-'}฿{Math.abs(origProfit).toFixed(2)}
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อยอดขาย (Margin %)</span> <span>{t('shopeeShopCase.rowMargin', 'กำไรต่อยอดขาย (Margin %)')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origProfitMargin.toFixed(1)}% {origProfitMargin.toFixed(1)}%
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อทุน (ROI %)</span> <span>{t('shopeeShopCase.rowRoi', 'กำไรต่อทุน (ROI %)')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origROI.toFixed(1)}% {origROI.toFixed(1)}%
</span> </span>
@@ -481,76 +490,82 @@ export default function ShopeeShopCasePage() {
{/* หลังปรับ (จำลองการปรับค่าธรรมเนียม) */} {/* หลังปรับ (จำลองการปรับค่าธรรมเนียม) */}
<div className="rounded-xl bg-orange-500/5 border border-orange-500/20 p-4 space-y-4"> <div className="rounded-xl bg-orange-500/5 border border-orange-500/20 p-4 space-y-4">
<h3 className="text-xs font-bold text-orange-500 uppercase tracking-wider border-b border-orange-500/10 pb-2 flex justify-between"> <h3 className="text-xs font-bold text-orange-500 uppercase tracking-wider border-b border-orange-500/10 pb-2 flex justify-between">
<span>หลังปรับ (ผลการจำลองใหม่)</span> <span>{t('shopeeShopCase.tableAfterTitle', 'หลังปรับ (ผลการจำลองใหม่)')}</span>
<span>รวม {simTotalPct.toFixed(2)}%</span> <span>{t('shopeeShopCase.totalPctHeader', { pct: simTotalPct.toFixed(2), defaultValue: `รวม ${simTotalPct.toFixed(2)}%` })}</span>
</h3> </h3>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ยอดรวมสินค้าก่อนลด</span> <span>{t('shopeeShopCase.rowGross', 'ยอดรวมสินค้าก่อนลด')}</span>
<span className="font-mono">{fmt(simBeforeDiscount)}</span> <span className="font-mono">{fmt(simBeforeDiscount)}</span>
</div> </div>
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ส่วนลดจากร้านค้า (รวม)</span> <span>{t('shopeeShopCase.rowShopDiscountTotal', 'ส่วนลดจากร้านค้า (รวม)')}</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-{fmt(simDiscountAmount)} -{fmt(simDiscountAmount)}
</span> </span>
</div> </div>
{discounts.map((d, i) => d.value > 0 ? ( {discounts.map((d, i) => d.value > 0 ? (
<div key={i} className="flex justify-between text-[10px] text-slate-500 pl-3"> <div key={i} className="flex justify-between text-[10px] text-slate-500 pl-3">
<span>- คูปอง #{i + 1} ({d.type === 'pct' ? `${d.value}%` : `฿${d.value}`})</span> <span>
{t('shopeeShopCase.rowCouponItem', {
index: i + 1,
val: d.type === 'pct' ? `${d.value}%` : `฿${d.value}`,
defaultValue: `- คูปอง #${i + 1} (${d.type === 'pct' ? `${d.value}%` : `฿${d.value}`})`
})}
</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-{fmt(d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value)} -{fmt(d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value)}
</span> </span>
</div> </div>
) : null)} ) : null)}
<div className="flex justify-between border-t border-orange-500/10 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-orange-500/10 pt-1.5 font-medium text-slate-300">
<span>ยอดสินค้าสุทธิ (ฐานคิดคอม)</span> <span>{t('shopeeShopCase.rowNetBase', 'ยอดสินค้าสุทธิ (ฐานคิดคอม)')}</span>
<span className="font-mono text-orange-500">{fmt(baseAmt)}</span> <span className="font-mono text-orange-500">{fmt(baseAmt)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมมิชชั่น ({simCommPct.toFixed(2)}%)</span> <span>{t('shopeeShopCase.rowFeeCommWithPct', { pct: simCommPct.toFixed(2), defaultValue: `- ค่าคอมมิชชั่น (${simCommPct.toFixed(2)}%)` })}</span>
<span className="font-mono text-rose-400">฿-{simCommAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCommAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าบริการ ({simServicePct.toFixed(2)}%)</span> <span>{t('shopeeShopCase.rowFeeServiceWithPct', { pct: simServicePct.toFixed(2), defaultValue: `- ค่าบริการ (${simServicePct.toFixed(2)}%)` })}</span>
<span className="font-mono text-rose-400">฿-{simServiceAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simServiceAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าธุรกรรมการชำระเงิน ({simTransPct.toFixed(2)}%)</span> <span>{t('shopeeShopCase.rowFeeTransWithPct', { pct: simTransPct.toFixed(2), defaultValue: `- ค่าธุรกรรมการชำระเงิน (${simTransPct.toFixed(2)}%)` })}</span>
<span className="font-mono text-rose-400">฿-{simTransAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simTransAmt.toFixed(2)}</span>
</div> </div>
{simAmsPct > 0 && ( {simAmsPct > 0 && (
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมมิชชั่น AMS ({simAmsPct.toFixed(2)}%)</span> <span>{t('shopeeShopCase.rowFeeAmsWithPct', { pct: simAmsPct.toFixed(2), defaultValue: `- ค่าคอมมิชชั่น AMS (${simAmsPct.toFixed(2)}%)` })}</span>
<span className="font-mono text-rose-400">฿-{simAmsAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simAmsAmt.toFixed(2)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-orange-500/10 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-orange-500/10 pt-1.5 font-medium text-slate-300">
<span>รวมค่าธรรมเนียมโดนหัก</span> <span>{t('shopeeShopCase.rowTotalFees', 'รวมค่าธรรมเนียมโดนหัก')}</span>
<span className="font-mono text-rose-400">฿-{simTotalAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simTotalAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-orange-500/10 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-orange-500/10 pt-2 font-bold text-white text-sm">
<span>ยอดเงินโอนเข้าบัญชีสุทธิ</span> <span>{t('shopeeShopCase.rowNetPayout', 'ยอดเงินโอนเข้าบัญชีสุทธิ')}</span>
<span className="font-mono text-orange-500">฿{simPayout.toFixed(2)}</span> <span className="font-mono text-orange-500">฿{simPayout.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-orange-500/10 pt-1.5 text-slate-400"> <div className="flex justify-between border-t border-orange-500/10 pt-1.5 text-slate-400">
<span>- ต้นทุนสินค้า (Cost)</span> <span>{t('shopeeShopCase.rowCost', '- ต้นทุนสินค้า (Cost)')}</span>
<span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-orange-500/10 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-orange-500/10 pt-2 font-bold text-white text-sm">
<span>กำไรสุทธิ (Net Profit)</span> <span>{t('shopeeShopCase.rowNetProfit', 'กำไรสุทธิ (Net Profit)')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simProfit >= 0 ? '' : '-'}฿{Math.abs(simProfit).toFixed(2)} {simProfit >= 0 ? '' : '-'}฿{Math.abs(simProfit).toFixed(2)}
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อยอดขาย (Margin %)</span> <span>{t('shopeeShopCase.rowMargin', 'กำไรต่อยอดขาย (Margin %)')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simProfitMargin.toFixed(1)}% {simProfitMargin.toFixed(1)}%
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อทุน (ROI %)</span> <span>{t('shopeeShopCase.rowRoi', 'กำไรต่อทุน (ROI %)')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simROI.toFixed(1)}% {simROI.toFixed(1)}%
</span> </span>
@@ -565,8 +580,8 @@ export default function ShopeeShopCasePage() {
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
<span className="font-medium"> <span className="font-medium">
{diffVal >= 0 {diffVal >= 0
? `ยอดรับเงินเพิ่มขึ้น ฿${diffVal.toFixed(2)} จากโครงสร้างธรรมเนียมใหม่` ? t('shopeeShopCase.diffProfitUp', { diff: diffVal.toFixed(2), defaultValue: `ยอดรับเงินเพิ่มขึ้น ฿${diffVal.toFixed(2)} จากโครงสร้างธรรมเนียมใหม่` })
: `ยอดเงินโอนเข้าบัญชีสุทธิจะต่างไป ฿${diffVal.toFixed(2)} (${diffVal >= 0 ? '+' : ''}${diffPct.toFixed(1)}%) เมื่อเทียบกับบิลจริงดั้งเดิม`} : t('shopeeShopCase.diffPayoutChanged', { diff: diffVal.toFixed(2), sign: diffVal >= 0 ? '+' : '', pct: diffPct.toFixed(1), defaultValue: `ยอดเงินโอนเข้าบัญชีสุทธิจะต่างไป ฿${diffVal.toFixed(2)} (${diffVal >= 0 ? '+' : ''}${diffPct.toFixed(1)}%) เมื่อเทียบกับบิลจริงดั้งเดิม` })}
</span> </span>
</div> </div>
<span className="font-bold font-mono text-sm">{diffVal >= 0 ? '+' : ''}฿{diffVal.toFixed(2)}</span> <span className="font-bold font-mono text-sm">{diffVal >= 0 ? '+' : ''}฿{diffVal.toFixed(2)}</span>
+77 -76
View File
@@ -9,6 +9,7 @@ import {
ArrowLeft, BadgePercent, ShoppingCart, XCircle, CheckCircle2, Loader2, ArrowLeft, BadgePercent, ShoppingCart, XCircle, CheckCircle2, Loader2,
Calendar, Store, Calendar, Store,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { TIKTOK_SHOPS } from './TiktokOrderPage';
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '');
@@ -68,18 +69,6 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: '7 วัน' },
{ key: '30d', label: '30 วัน' },
{ key: '90d', label: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' },
];
function StatCard({ function StatCard({
label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5', label, value, sub, icon: Icon, color = 'text-white', bgColor = 'from-white/5',
}: { label: string; value: string | number; sub?: string; icon: any; color?: string; bgColor?: string }) { }: { label: string; value: string | number; sub?: string; icon: any; color?: string; bgColor?: string }) {
@@ -112,6 +101,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function TiktokAnalyticsPage() { export default function TiktokAnalyticsPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -125,6 +115,18 @@ export default function TiktokAnalyticsPage() {
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: t('analytics.preset7d', '7 วัน') },
{ key: '30d', label: t('analytics.preset30d', '30 วัน') },
{ key: '90d', label: t('analytics.preset90d', '90 วัน') },
{ key: 'thisMonth', label: t('analytics.presetThisMonth', 'เดือนนี้') },
{ key: 'lastMonth', label: t('analytics.presetLastMonth', 'เดือนที่แล้ว') },
{ key: 'year', label: t('analytics.presetThisYear', 'ปีนี้') },
{ key: 'lastYear', label: t('analytics.presetLastYear', 'ปีที่แล้ว') },
{ key: 'last12m', label: t('analytics.presetLast12m', '12 เดือนที่ผ่านมา') },
{ key: 'all', label: t('analytics.presetAll', 'ทั้งหมด') },
];
const buildUrl = useCallback((s: string, from: string, to: string) => { const buildUrl = useCallback((s: string, from: string, to: string) => {
const params = new URLSearchParams({ shop: s }); const params = new URLSearchParams({ shop: s });
if (from) params.set('dateFrom', from); if (from) params.set('dateFrom', from);
@@ -166,7 +168,7 @@ export default function TiktokAnalyticsPage() {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className={`w-8 h-8 ${shopCfg.color} animate-spin`} /> <Loader2 className={`w-8 h-8 ${shopCfg.color} animate-spin`} />
<span className="text-slate-500">กำลังคำนวณข้อมูลวิเคราะห์...</span> <span className="text-slate-500">{t('tiktokAnalytics.loading')}</span>
</div> </div>
); );
} }
@@ -175,9 +177,9 @@ export default function TiktokAnalyticsPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<ShoppingBag className="w-14 h-14 text-slate-700" /> <ShoppingBag className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ไม่มีข้อมูล — กรุณา Import ไฟล์ TikTok ก่อน'}</p> <p className="text-slate-500">{error || t('tiktokAnalytics.noData')}</p>
<Link to="/tiktok-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm"> <Link to="/tiktok-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 hover:text-white text-sm">
<ArrowLeft className="w-4 h-4" /> กลับหน้า TikTok Orders <ArrowLeft className="w-4 h-4" /> {t('tiktokAnalytics.backToOrders')}
</Link> </Link>
</div> </div>
); );
@@ -205,12 +207,11 @@ export default function TiktokAnalyticsPage() {
<div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1"> <div className="flex items-center gap-2 text-xs text-slate-500 uppercase tracking-widest mb-1">
<Link to="/tiktok-order" className={`hover:${shopCfg.color} transition-colors`}>TikTok</Link> <Link to="/tiktok-order" className={`hover:${shopCfg.color} transition-colors`}>TikTok</Link>
<span>/</span> <span>/</span>
<span className="text-white">Analytics</span> <span className="text-white">{t('tiktokAnalytics.breadcrumb', 'Analytics')}</span>
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2"> <h1 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
<TrendingUp className={`w-6 h-6 ${shopCfg.color}`} /> <TrendingUp className={`w-6 h-6 ${shopCfg.color}`} />
วิเคราะห์ยอดขาย — {t('tiktokAnalytics.title', { shop: shopCfg.label })}
<span className={shopCfg.color}>{shopCfg.label}</span>
</h1> </h1>
</div> </div>
</div> </div>
@@ -259,14 +260,14 @@ export default function TiktokAnalyticsPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('analytics.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={e => setDateFrom(e.target.value)} onChange={e => setDateFrom(e.target.value)}
className={`px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:${shopCfg.activeBorder} focus:outline-none`} className={`px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:${shopCfg.activeBorder} focus:outline-none`}
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" type="date"
value={dateTo} value={dateTo}
@@ -275,9 +276,9 @@ export default function TiktokAnalyticsPage() {
/> />
<button <button
onClick={applyCustom} onClick={applyCustom}
className={`px-4 py-1.5 rounded-lg ${shopCfg.activeBg} ${shopCfg.activeBorder} ${shopCfg.color} text-xs font-medium hover:opacity-80 transition-all`} className={`px-4 py-1.5 rounded-lg ${shopCfg.activeBg} ${shopCfg.activeBorder} ${shopCfg.color} text-xs font-medium hover:opacity-80 transition-all cursor-pointer`}
> >
ดูข้อมูล {t('analytics.apply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -289,9 +290,9 @@ export default function TiktokAnalyticsPage() {
</span> </span>
<button <button
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData(shop, '', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors cursor-pointer"
> >
✕ ล้าง ✕ {t('analytics.clear', 'ล้าง')}
</button> </button>
</div> </div>
)} )}
@@ -300,81 +301,81 @@ export default function TiktokAnalyticsPage() {
{/* KPI Cards Row 1 - Main Financials */} {/* KPI Cards Row 1 - Main Financials */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label="ยอดขายรวม (Gross Revenue)" label={t('tiktokAnalytics.grossRevenue')}
value={fmtCurrency(summary.totalRevenue)} value={fmtCurrency(summary.totalRevenue)}
icon={DollarSign} icon={DollarSign}
color="text-emerald-400" color="text-emerald-400"
bgColor="from-emerald-500/10" bgColor="from-emerald-500/10"
sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} sub={t('tiktokAnalytics.ordersCount', { count: fmt(summary.totalOrders) })}
/> />
<StatCard <StatCard
label="ยอดเงินโอนสุทธิ (Settlement Payout)" label={t('tiktokAnalytics.settlementPayout')}
value={fmtCurrency(summary.totalSettlementAmount || summary.netRevenue)} value={fmtCurrency(summary.totalSettlementAmount || summary.netRevenue)}
icon={TrendingUp} icon={TrendingUp}
color="text-cyan-glow" color="text-cyan-glow"
bgColor="from-cyan-500/10" bgColor="from-cyan-500/10"
sub="ยอดเงินเข้าบัญชีจริงจาก TikTok" sub={t('tiktokAnalytics.settlementPayoutSub')}
/> />
<StatCard <StatCard
label="ต้นทุนสินค้า BOM (BOM Cost)" label={t('tiktokAnalytics.bomCost')}
value={fmtCurrency(summary.totalBOMCost || 0)} value={fmtCurrency(summary.totalBOMCost || 0)}
icon={Package} icon={Package}
color="text-amber-400" color="text-amber-400"
bgColor="from-amber-500/10" bgColor="from-amber-500/10"
sub="คำนวณจากสูตรการผลิต" sub={t('tiktokAnalytics.bomCostSub')}
/> />
<StatCard <StatCard
label="กำไรสุทธิแท้จริง (Net Profit)" label={t('tiktokAnalytics.netProfit')}
value={fmtCurrency(summary.totalNetProfit || (summary.totalSettlementAmount - summary.totalBOMCost) || 0)} value={fmtCurrency(summary.totalNetProfit || (summary.totalSettlementAmount - summary.totalBOMCost) || 0)}
icon={ShoppingBag} icon={ShoppingBag}
color="text-emerald-300" color="text-emerald-300"
bgColor="from-emerald-500/20" bgColor="from-emerald-500/20"
sub={`Margin ${fmt(summary.netProfitMargin || 0, 1)}% ของยอดขาย`} sub={t('tiktokAnalytics.marginOfSales', { margin: fmt(summary.netProfitMargin || 0, 1) })}
/> />
</div> </div>
{/* KPI Cards Row 2 - Fees & Commissions Breakdown */} {/* KPI Cards Row 2 - Fees & Commissions Breakdown */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
label="ค่าคอมมิชชั่น TikTok Shop" label={t('tiktokAnalytics.tiktokCommission')}
value={fmtCurrency(summary.totalCommission || 0)} value={fmtCurrency(summary.totalCommission || 0)}
icon={ReceiptText} icon={ReceiptText}
color="text-rose-400" color="text-rose-400"
bgColor="from-rose-500/10" bgColor="from-rose-500/10"
sub="TikTok Commission Fee" sub={t('tiktokAnalytics.tiktokCommissionSub')}
/> />
<StatCard <StatCard
label="ค่าคอมมิชชั่น Affiliate" label={t('tiktokAnalytics.affiliateCommission')}
value={fmtCurrency(summary.totalAffiliate || 0)} value={fmtCurrency(summary.totalAffiliate || 0)}
icon={BadgePercent} icon={BadgePercent}
color="text-orange-400" color="text-orange-400"
bgColor="from-orange-500/10" bgColor="from-orange-500/10"
sub="Affiliate Partner & Creator Fee" sub={t('tiktokAnalytics.affiliateCommissionSub')}
/> />
<StatCard <StatCard
label="ส่วนลด Platform (TikTok ออก)" label={t('tiktokAnalytics.platformDiscount')}
value={fmtCurrency(summary.totalPlatformDisc)} value={fmtCurrency(summary.totalPlatformDisc)}
icon={BadgePercent} icon={BadgePercent}
color="text-purple-400" color="text-purple-400"
bgColor="from-purple-500/10" bgColor="from-purple-500/10"
sub="TikTok อุดหนุนส่วนลดให้" sub={t('tiktokAnalytics.platformDiscountSub')}
/> />
<StatCard <StatCard
label="ส่วนลดร้านค้า (เราออก)" label={t('tiktokAnalytics.sellerDiscount')}
value={fmtCurrency(summary.totalSellerDisc)} value={fmtCurrency(summary.totalSellerDisc)}
icon={ReceiptText} icon={ReceiptText}
color="text-pink-400" color="text-pink-400"
bgColor="from-pink-500/10" bgColor="from-pink-500/10"
sub="ส่วนลดที่เราให้ลูกค้า" sub={t('tiktokAnalytics.sellerDiscountSub')}
/> />
</div> </div>
{/* KPI Cards Row 3 - Operations & Orders */} {/* KPI Cards Row 3 - Operations & Orders */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard label="คำสั่งซื้อสำเร็จ" value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={`จากทั้งหมด ${fmt(summary.totalOrders)}`} /> <StatCard label={t('tiktokAnalytics.completedOrders')} value={fmt(summary.completedOrders)} icon={CheckCircle2} color="text-emerald-400" sub={t('tiktokAnalytics.outOfTotal', { total: fmt(summary.totalOrders) })} />
<StatCard label="คำสั่งซื้อยกเลิก" value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={`${fmt(summary.cancelRate, 1)}% ของทั้งหมด`} /> <StatCard label={t('tiktokAnalytics.cancelledOrders')} value={fmt(summary.cancelledOrders)} icon={XCircle} color="text-rose-400" sub={t('tiktokAnalytics.percentOfTotal', { rate: fmt(summary.cancelRate, 1) })} />
<StatCard label="ค่าจัดส่งรวม" value={fmtCurrency(summary.totalShipping)} icon={Package} color="text-amber-400" sub="ค่าส่งที่เก็บจากผู้ซื้อ" /> <StatCard label={t('tiktokAnalytics.totalShipping')} value={fmtCurrency(summary.totalShipping)} icon={Package} color="text-amber-400" sub={t('tiktokAnalytics.shippingCollectedSub')} />
<StatCard label="AOV (เฉลี่ยต่อออเดอร์)" value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub="Average Order Value" /> <StatCard label={t('tiktokAnalytics.aov')} value={fmtCurrency(summary.aov)} icon={ShoppingCart} color="text-blue-400" sub={t('tiktokAnalytics.aovSub')} />
</div> </div>
{/* Monthly Revenue & Financial Trend Chart */} {/* Monthly Revenue & Financial Trend Chart */}
@@ -382,9 +383,9 @@ export default function TiktokAnalyticsPage() {
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap"> <div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TrendingUp className={`w-5 h-5 ${shopCfg.color}`} /> <TrendingUp className={`w-5 h-5 ${shopCfg.color}`} />
<h2 className="text-lg font-bold text-white">ยอดขาย ค่าธรรมเนียม และกำไรสุทธิรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.monthlyFinancialsTitle')}</h2>
</div> </div>
<span className="text-xs text-slate-400">เปรียบเทียบยอดขาย ยอดโอน ค่าคอมมิชชั่น และกำไรสุทธิ</span> <span className="text-xs text-slate-400">{t('tiktokAnalytics.monthlyFinancialsSub')}</span>
</div> </div>
{monthlyChart.length > 0 ? ( {monthlyChart.length > 0 ? (
<div className="h-[340px]"> <div className="h-[340px]">
@@ -395,16 +396,16 @@ export default function TiktokAnalyticsPage() {
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} tickFormatter={v => `฿${v >= 1000 ? `${(v / 1000).toFixed(0)}k` : v}`} />
<Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} /> <Tooltip content={<CustomTooltip />} cursor={{ fill: '#ffffff06' }} />
<Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} /> <Legend wrapperStyle={{ fontSize: '11px', color: '#94a3b8' }} />
<Bar dataKey="revenue" name="฿ ยอดขายรวม" fill="#4ade80" radius={[4, 4, 0, 0]} /> <Bar dataKey="revenue" name={t('tiktokAnalytics.barGrossRevenue')} fill="#4ade80" radius={[4, 4, 0, 0]} />
<Bar dataKey="settlement" name="฿ ยอดเงินโอนสุทธิ" fill="#22d3ee" radius={[4, 4, 0, 0]} /> <Bar dataKey="settlement" name={t('tiktokAnalytics.barSettlement')} fill="#22d3ee" radius={[4, 4, 0, 0]} />
<Bar dataKey="commission" name="฿ ค่าคอม TikTok" fill="#f43f5e" radius={[4, 4, 0, 0]} /> <Bar dataKey="commission" name={t('tiktokAnalytics.barCommission')} fill="#f43f5e" radius={[4, 4, 0, 0]} />
<Bar dataKey="affiliate" name="฿ ค่า Affiliate" fill="#fb923c" radius={[4, 4, 0, 0]} /> <Bar dataKey="affiliate" name={t('tiktokAnalytics.barAffiliate')} fill="#fb923c" radius={[4, 4, 0, 0]} />
<Bar dataKey="netProfit" name="฿ กำไรสุทธิ" fill="#10b981" radius={[4, 4, 0, 0]} /> <Bar dataKey="netProfit" name={t('tiktokAnalytics.barNetProfit')} fill="#10b981" radius={[4, 4, 0, 0]} />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('tiktokAnalytics.noMonthlyData')}</p>
)} )}
</div> </div>
@@ -413,7 +414,7 @@ export default function TiktokAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<ShoppingBag className="w-5 h-5 text-cyan-glow" /> <ShoppingBag className="w-5 h-5 text-cyan-glow" />
<h2 className="text-lg font-bold text-white">จำนวนคำสั่งซื้อรายเดือน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.monthlyOrdersTitle')}</h2>
</div> </div>
<div className="h-[220px]"> <div className="h-[220px]">
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
@@ -422,8 +423,8 @@ export default function TiktokAnalyticsPage() {
<XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} /> <XAxis dataKey="label" stroke="#64748b" tick={{ fontSize: 11 }} />
<YAxis stroke="#64748b" tick={{ fontSize: 11 }} /> <YAxis stroke="#64748b" tick={{ fontSize: 11 }} />
<Tooltip content={<CustomTooltip />} /> <Tooltip content={<CustomTooltip />} />
<Line type="monotone" dataKey="orders" name="คำสั่งซื้อ" stroke={shop === 'hom-aroma' ? '#fb7185' : '#a78bfa'} strokeWidth={2} dot={{ r: 4 }} /> <Line type="monotone" dataKey="orders" name={t('tiktokAnalytics.lineOrders')} stroke={shop === 'hom-aroma' ? '#fb7185' : '#a78bfa'} strokeWidth={2} dot={{ r: 4 }} />
<Line type="monotone" dataKey="items" name="จำนวนสินค้า (ชิ้น)" stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} /> <Line type="monotone" dataKey="items" name={t('tiktokAnalytics.lineItems')} stroke="#818cf8" strokeWidth={2} dot={{ r: 4, fill: '#818cf8' }} />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -437,7 +438,7 @@ export default function TiktokAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<CheckCircle2 className="w-5 h-5 text-emerald-400" /> <CheckCircle2 className="w-5 h-5 text-emerald-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนสถานะคำสั่งซื้อ</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.statusShareTitle')}</h2>
</div> </div>
<div className="grid grid-cols-2 gap-4 items-center"> <div className="grid grid-cols-2 gap-4 items-center">
<div className="h-[200px]"> <div className="h-[200px]">
@@ -469,7 +470,7 @@ export default function TiktokAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<DollarSign className="w-5 h-5 text-amber-400" /> <DollarSign className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">ช่องทางการชำระเงิน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.paymentMethodTitle')}</h2>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{paymentBreakdown.map((p: any, i: number) => { {paymentBreakdown.map((p: any, i: number) => {
@@ -477,8 +478,8 @@ export default function TiktokAnalyticsPage() {
return ( return (
<div key={i} className="space-y-1"> <div key={i} className="space-y-1">
<div className="flex justify-between text-xs"> <div className="flex justify-between text-xs">
<span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method || 'ไม่ระบุ'}</span> <span className="text-slate-300 truncate max-w-[160px]" title={p.method}>{p.method || t('common.unspecified', 'ไม่ระบุ')}</span>
<span className="text-slate-400 ml-2">{p.count} ออเดอร์ · {fmt(pct, 0)}%</span> <span className="text-slate-400 ml-2">{t('tiktokAnalytics.ordersPercent', { count: p.count, pct: fmt(pct, 0) })}</span>
</div> </div>
<div className="h-1.5 rounded-full bg-white/5 overflow-hidden"> <div className="h-1.5 rounded-full bg-white/5 overflow-hidden">
<div <div
@@ -497,18 +498,18 @@ export default function TiktokAnalyticsPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี (by ยอดเงิน)</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.topProductsTitle')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10"> <thead className="text-xs text-slate-400 uppercase bg-white/[0.03] border-b border-white/10">
<tr> <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">{t('tiktokAnalytics.thProduct')}</th>
<th className="px-4 py-3">ตัวเลือก</th> <th className="px-4 py-3">{t('tiktokAnalytics.thVariation')}</th>
<th className="px-4 py-3 text-right">ชิ้น</th> <th className="px-4 py-3 text-right">{t('tiktokAnalytics.thUnits')}</th>
<th className="px-4 py-3 text-right">ยอดรายได้</th> <th className="px-4 py-3 text-right">{t('tiktokAnalytics.thRevenue')}</th>
<th className="px-4 py-3 text-right">% จากยอดรวม</th> <th className="px-4 py-3 text-right">{t('tiktokAnalytics.thPercentTotal')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -546,19 +547,19 @@ export default function TiktokAnalyticsPage() {
<div className="flex items-center justify-between gap-2 mb-6 flex-wrap"> <div className="flex items-center justify-between gap-2 mb-6 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ReceiptText className="w-5 h-5 text-rose-400" /> <ReceiptText className="w-5 h-5 text-rose-400" />
<h2 className="text-lg font-bold text-white">โครงสร้างต้นทุน ค่าธรรมเนียม และกำไรสุทธิ (Financial Breakdown)</h2> <h2 className="text-lg font-bold text-white">{t('tiktokAnalytics.waterfallTitle')}</h2>
</div> </div>
<span className="text-xs text-slate-400">หักลบรายได้ตามลำดับสู่กำไรสุทธิแท้จริง</span> <span className="text-xs text-slate-400">{t('tiktokAnalytics.waterfallSub')}</span>
</div> </div>
<div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3 mb-6"> <div className="grid sm:grid-cols-2 lg:grid-cols-6 gap-3 mb-6">
{[ {[
{ label: '1. ยอดขาย Gross', value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' }, { label: t('tiktokAnalytics.step1Gross'), value: summary.totalRevenue, color: 'text-emerald-400', sign: '+' },
{ label: '2. หัก: ส่วนลดร้านค้า', value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' }, { label: t('tiktokAnalytics.step2SellerDisc'), value: -summary.totalSellerDisc, color: 'text-rose-400', sign: '-' },
{ label: '3. หัก: ค่าคอม TikTok', value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' }, { label: t('tiktokAnalytics.step3Comm'), value: -(summary.totalCommission || 0), color: 'text-rose-400', sign: '-' },
{ label: '4. หัก: ค่า Affiliate', value: -(summary.totalAffiliate || 0), color: 'text-orange-400', sign: '-' }, { label: t('tiktokAnalytics.step4Aff'), value: -(summary.totalAffiliate || 0), color: 'text-orange-400', sign: '-' },
{ label: '5. หัก: ต้นทุน BOM สินค้า', value: -(summary.totalBOMCost || 0), color: 'text-amber-400', sign: '-' }, { label: t('tiktokAnalytics.step5Bom'), value: -(summary.totalBOMCost || 0), color: 'text-amber-400', sign: '-' },
{ label: '6. กำไรสุทธิแท้จริง (Net Profit)', value: summary.totalNetProfit || 0, color: 'text-emerald-300 font-bold', highlight: true, sign: '=' }, { label: t('tiktokAnalytics.step6Net'), value: summary.totalNetProfit || 0, color: 'text-emerald-300 font-bold', highlight: true, sign: '=' },
].map((item, i) => ( ].map((item, i) => (
<div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-white/10 bg-white/[0.02]'}`}> <div key={i} className={`p-4 rounded-xl border ${item.highlight ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-white/10 bg-white/[0.02]'}`}>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
@@ -567,7 +568,7 @@ export default function TiktokAnalyticsPage() {
</div> </div>
<p className={`text-lg font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p> <p className={`text-lg font-bold font-mono ${item.color}`}>{fmtCurrency(Math.abs(item.value))}</p>
{summary.totalRevenue > 0 && ( {summary.totalRevenue > 0 && (
<p className="text-[10px] text-slate-500 mt-1">{fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1)}% ของยอดขาย</p> <p className="text-[10px] text-slate-500 mt-1">{t('tiktokAnalytics.percentOfSales', { pct: fmt(Math.abs(item.value / summary.totalRevenue) * 100, 1) })}</p>
)} )}
</div> </div>
))} ))}
@@ -577,8 +578,8 @@ export default function TiktokAnalyticsPage() {
<BadgePercent className="w-7 h-7 text-purple-400 flex-shrink-0" /> <BadgePercent className="w-7 h-7 text-purple-400 flex-shrink-0" />
<div className="flex items-center justify-between w-full flex-wrap gap-2"> <div className="flex items-center justify-between w-full flex-wrap gap-2">
<div> <div>
<p className="text-xs text-slate-300 font-medium">ส่วนลด Platform สนับสนุน (TikTok ช่วยจ่ายให้ลูกค้า)</p> <p className="text-xs text-slate-300 font-medium">{t('tiktokAnalytics.platformSubsidyTitle')}</p>
<p className="text-[11px] text-slate-500">ยอดเงินที่ TikTok ออกคูปองสนับสนุนให้ผู้ซื้อ โดยไม่หักเงินจากร้านค้า</p> <p className="text-[11px] text-slate-500">{t('tiktokAnalytics.platformSubsidySub')}</p>
</div> </div>
<p className="text-xl font-bold font-mono text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p> <p className="text-xl font-bold font-mono text-purple-400">{fmtCurrency(summary.totalPlatformDisc)}</p>
</div> </div>
+84 -64
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useTranslation, Trans } from 'react-i18next';
import { import {
ArrowLeft, ArrowLeft,
ShoppingBag, ShoppingBag,
@@ -19,6 +20,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { TIKTOK_SHOPS } from './TiktokOrderPage';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
function authHeaders() { function authHeaders() {
return { return {
@@ -50,7 +52,16 @@ function statusStyle(status: string) {
return 'bg-white/5 text-slate-400 border-white/10'; return 'bg-white/5 text-slate-400 border-white/10';
} }
function getLocalizedStatus(status: string, t: any) {
if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return t('tiktokOrders.statusCompleted', 'เสร็จสิ้น');
if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return t('tiktokOrders.statusShipped', 'จัดส่งแล้ว');
if (status.includes('จะจัดส่ง') || status.includes('รอ')) return t('tiktokOrders.statusToShip', 'ที่ต้องจัดส่ง');
if (status.includes('ยกเลิก') || status.includes('cancel')) return t('tiktokOrders.statusCancelled', 'ยกเลิก');
return status;
}
export default function TiktokOrderDetailPage() { export default function TiktokOrderDetailPage() {
const { t, i18n } = useTranslation();
const { orderId } = useParams<{ orderId: string }>(); const { orderId } = useParams<{ orderId: string }>();
const [order, setOrder] = useState<any | null>(null); const [order, setOrder] = useState<any | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -73,7 +84,7 @@ export default function TiktokOrderDetailPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4 animate-pulse"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4 animate-pulse">
<Loader2 className="w-10 h-10 text-rose-400 animate-spin" /> <Loader2 className="w-10 h-10 text-rose-400 animate-spin" />
<p className="text-slate-500 text-sm">กำลังโหลดรายละเอียดคำสั่งซื้อ TikTok และคำนวณต้นทุน BOM...</p> <p className="text-slate-500 text-sm">{t('tiktokOrderDetail.loading')}</p>
</div> </div>
); );
} }
@@ -82,9 +93,9 @@ export default function TiktokOrderDetailPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<ShoppingBag className="w-14 h-14 text-slate-700" /> <ShoppingBag className="w-14 h-14 text-slate-700" />
<p className="text-slate-400 text-sm">{error || 'ไม่พบคำสั่งซื้อนี้'}</p> <p className="text-slate-400 text-sm">{error || t('tiktokOrderDetail.notFound')}</p>
<Link to="/tiktok-order" className="px-6 py-2 rounded-xl bg-rose-500/20 text-rose-400 border border-rose-500/30 font-bold text-xs flex items-center gap-2 hover:bg-rose-500/30 transition-all"> <Link to="/tiktok-order" className="px-6 py-2 rounded-xl bg-rose-500/20 text-rose-400 border border-rose-500/30 font-bold text-xs flex items-center gap-2 hover:bg-rose-500/30 transition-all">
<ArrowLeft className="w-4 h-4" /> กลับหน้ารายการ <ArrowLeft className="w-4 h-4" /> {t('tiktokOrderDetail.backToList')}
</Link> </Link>
</div> </div>
); );
@@ -92,6 +103,8 @@ export default function TiktokOrderDetailPage() {
const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0]; const shopCfg = TIKTOK_SHOPS.find((s) => s.id === order.shop) || TIKTOK_SHOPS[0];
const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0; const hasIncompleteBom = order.hasIncompleteBom || order.missingBomCount > 0;
const locDistrict = getLocalizedDistrictName(order.district, undefined, i18n.language) || order.district || '';
const locProvince = order.province ? getLocalizedProvinceName(order.province, i18n.language) : t('tiktokOrderDetail.unspecified');
return ( return (
<div className="space-y-6 animate-fade-in text-white pb-12"> <div className="space-y-6 animate-fade-in text-white pb-12">
@@ -110,7 +123,7 @@ export default function TiktokOrderDetailPage() {
#{order.orderId} #{order.orderId}
</h1> </h1>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusStyle(order.status)}`}> <span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${statusStyle(order.status)}`}>
{order.status} {getLocalizedStatus(order.status, t)}
</span> </span>
{order.subStatus && ( {order.subStatus && (
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded-full border border-white/10"> <span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded-full border border-white/10">
@@ -121,18 +134,18 @@ export default function TiktokOrderDetailPage() {
{/* BOM Status Pill */} {/* BOM Status Pill */}
{hasIncompleteBom ? ( {hasIncompleteBom ? (
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 flex items-center gap-1.5 shadow-sm shadow-rose-950"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 flex items-center gap-1.5 shadow-sm shadow-rose-950">
<AlertTriangle className="w-3.5 h-3.5" /> ขาด BOM ({order.missingBomCount} รายการ) <AlertTriangle className="w-3.5 h-3.5" /> {t('tiktokOrderDetail.missingBomPill', { count: order.missingBomCount })}
</span> </span>
) : ( ) : (
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5" /> BOM ครบถ้วน <CheckCircle2 className="w-3.5 h-3.5" /> {t('tiktokOrderDetail.bomCompletePill')}
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-slate-400 mt-1 flex items-center gap-2"> <p className="text-xs text-slate-400 mt-1 flex items-center gap-2">
<span className={`font-bold ${shopCfg.color}`}>ร้าน: {shopCfg.label}</span> <span className={`font-bold ${shopCfg.color}`}>{t('tiktokOrderDetail.shopLabel', { shop: shopCfg.label })}</span>
<span>•</span> <span>•</span>
<span>สั่งซื้อเมื่อ: {order.createdTime || '—'}</span> <span>{t('tiktokOrderDetail.orderedAt', { time: order.createdTime || '—' })}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -142,7 +155,7 @@ export default function TiktokOrderDetailPage() {
to="/tiktok-sales-map" to="/tiktok-sales-map"
className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs font-bold transition-all" className="flex items-center gap-1.5 px-4 py-2 rounded-xl bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 text-xs font-bold transition-all"
> >
<MapPin className="w-3.5 h-3.5" /> ดูใน Sales Map <MapPin className="w-3.5 h-3.5" /> {t('tiktokOrderDetail.viewInSalesMap')}
</Link> </Link>
<a <a
href={`https://seller-th.tiktok.com/order`} href={`https://seller-th.tiktok.com/order`}
@@ -164,10 +177,10 @@ export default function TiktokOrderDetailPage() {
</div> </div>
<div> <div>
<p className="text-sm font-bold text-rose-200"> <p className="text-sm font-bold text-rose-200">
ตรวจพบสินค้าขาดสูตร BOM หรือยังไม่ได้ใส่ราคาต้นทุน ({order.missingBomCount} รายการ) {t('tiktokOrderDetail.missingBomAlertTitle', { count: order.missingBomCount })}
</p> </p>
<p className="text-xs text-rose-300/80"> <p className="text-xs text-rose-300/80">
พนักงานสามารถคลิกปุ่มสีแดงที่รายการสินค้าด้านล่าง เพื่อเข้าไปกรอกสูตร BOM หรือเชื่อมโยงสินค้าได้ทันที {t('tiktokOrderDetail.missingBomAlertSub')}
</p> </p>
</div> </div>
</div> </div>
@@ -175,7 +188,7 @@ export default function TiktokOrderDetailPage() {
to="/tools/marketplace-products" to="/tools/marketplace-products"
className="px-3.5 py-1.5 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-bold text-xs shrink-0 flex items-center gap-1.5 shadow-md shadow-rose-900/50 transition-colors" className="px-3.5 py-1.5 rounded-xl bg-rose-500 hover:bg-rose-600 text-white font-bold text-xs shrink-0 flex items-center gap-1.5 shadow-md shadow-rose-900/50 transition-colors"
> >
<Layers className="w-3.5 h-3.5" /> ตรวจสอบตาราง Mapping <Layers className="w-3.5 h-3.5" /> {t('tiktokOrderDetail.checkMappingTable')}
</Link> </Link>
</div> </div>
)} )}
@@ -186,15 +199,15 @@ export default function TiktokOrderDetailPage() {
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Order Items Table */} {/* Order Items Table */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm"> <div className="bg-slate-900/60 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]"> <div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02] flex-wrap gap-2">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Package className="w-4 h-4 text-rose-400" /> <Package className="w-4 h-4 text-rose-400" />
<h2 className="font-bold text-sm text-white"> <h2 className="font-bold text-sm text-white">
รายการสินค้าในคำสั่งซื้อ ({order.items?.length || 0} SKU) {t('tiktokOrderDetail.orderItemsTitle', { count: order.items?.length || 0 })}
</h2> </h2>
</div> </div>
<div className="text-xs text-slate-400 flex items-center gap-3 font-mono"> <div className="text-xs text-slate-400 flex items-center gap-3 font-mono">
<span>ต้นทุนรวม BOM: <strong className={order.orderTotalBomCost > 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)}</strong></span> <span>{t('tiktokOrderDetail.totalBomCostLabel')} <strong className={order.orderTotalBomCost > 0 ? "text-amber-400" : "text-rose-400"}>฿{fmt(order.orderTotalBomCost)}</strong></span>
</div> </div>
</div> </div>
<div className="divide-y divide-white/5"> <div className="divide-y divide-white/5">
@@ -216,11 +229,11 @@ export default function TiktokOrderDetailPage() {
<p className="text-sm font-bold text-white leading-snug">{item.productName}</p> <p className="text-sm font-bold text-white leading-snug">{item.productName}</p>
{isItemBomMissing ? ( {isItemBomMissing ? (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 animate-pulse"> <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-400 border border-rose-500/30 animate-pulse">
🔴 ขาด BOM / รอใส่ราคา {t('tiktokOrderDetail.itemMissingBomBadge')}
</span> </span>
) : ( ) : (
<span className="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/20"> <span className="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/15 text-emerald-400 border border-emerald-500/20">
✓ มีสูตร BOM {t('tiktokOrderDetail.itemHasBomBadge')}
</span> </span>
)} )}
</div> </div>
@@ -228,7 +241,7 @@ export default function TiktokOrderDetailPage() {
<div className="flex items-center gap-2 flex-wrap text-xs text-slate-400"> <div className="flex items-center gap-2 flex-wrap text-xs text-slate-400">
{item.variation && ( {item.variation && (
<span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-300 font-mono text-[11px]"> <span className="px-2 py-0.5 rounded bg-white/5 border border-white/10 text-slate-300 font-mono text-[11px]">
ตัวเลือก: {item.variation} {t('tiktokOrderDetail.variationLabel', { variation: item.variation })}
</span> </span>
)} )}
{item.sellerSku && ( {item.sellerSku && (
@@ -238,7 +251,10 @@ export default function TiktokOrderDetailPage() {
)} )}
{item.masterProductName && ( {item.masterProductName && (
<span className="text-[11px] font-mono text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded border border-violet-500/20"> <span className="text-[11px] font-mono text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded border border-violet-500/20">
เชื่อมโยง: {item.masterProductName} ({item.masterSku || 'No SKU'}) {t('tiktokOrderDetail.linkedMasterLabel', {
product: item.masterProductName,
sku: item.masterSku || 'No SKU'
})}
</span> </span>
)} )}
</div> </div>
@@ -247,15 +263,15 @@ export default function TiktokOrderDetailPage() {
{/* Pricing, Quantity & Subtotal */} {/* Pricing, Quantity & Subtotal */}
<div className="flex items-center justify-between md:justify-end gap-5 text-right shrink-0"> <div className="flex items-center justify-between md:justify-end gap-5 text-right shrink-0">
<div> <div>
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ราคา/หน่วย</p> <p className="text-[10px] text-slate-500 uppercase tracking-widest">{t('tiktokOrderDetail.thUnitPrice')}</p>
<p className="text-xs font-mono text-slate-300">฿{fmt(item.unitOriginalPrice)}</p> <p className="text-xs font-mono text-slate-300">฿{fmt(item.unitOriginalPrice)}</p>
</div> </div>
<div> <div>
<p className="text-[10px] text-slate-500 uppercase tracking-widest">จำนวน</p> <p className="text-[10px] text-slate-500 uppercase tracking-widest">{t('tiktokOrderDetail.thQuantity')}</p>
<p className="text-sm font-bold font-mono text-white">x{item.quantity}</p> <p className="text-sm font-bold font-mono text-white">x{item.quantity}</p>
</div> </div>
<div className="min-w-[80px]"> <div className="min-w-[80px]">
<p className="text-[10px] text-slate-500 uppercase tracking-widest">ยอดขาย (SKU)</p> <p className="text-[10px] text-slate-500 uppercase tracking-widest">{t('tiktokOrderDetail.thSubtotal')}</p>
<p className="text-sm font-bold font-mono text-white">฿{fmt(item.subtotalAfterDiscount)}</p> <p className="text-sm font-bold font-mono text-white">฿{fmt(item.subtotalAfterDiscount)}</p>
</div> </div>
</div> </div>
@@ -266,15 +282,15 @@ export default function TiktokOrderDetailPage() {
{item.hasBom ? ( {item.hasBom ? (
<div className="flex items-center gap-4 flex-wrap"> <div className="flex items-center gap-4 flex-wrap">
<div> <div>
<span className="text-slate-500 text-[11px]">ต้นทุน BOM/ชิ้น: </span> <span className="text-slate-500 text-[11px]">{t('tiktokOrderDetail.unitBomCostLabel')} </span>
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.unitBomCost)}</span> <span className="font-mono text-amber-400 font-bold">฿{fmt(item.unitBomCost)}</span>
</div> </div>
<div> <div>
<span className="text-slate-500 text-[11px]">ต้นทุนรวม: </span> <span className="text-slate-500 text-[11px]">{t('tiktokOrderDetail.totalBomCostItemLabel')} </span>
<span className="font-mono text-amber-400 font-bold">฿{fmt(item.totalBomCost)}</span> <span className="font-mono text-amber-400 font-bold">฿{fmt(item.totalBomCost)}</span>
</div> </div>
<div> <div>
<span className="text-slate-500 text-[11px]">กำไรขั้นต้น: </span> <span className="text-slate-500 text-[11px]">{t('tiktokOrderDetail.grossProfitLabel')} </span>
<span className={`font-mono font-bold ${item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> <span className={`font-mono font-bold ${item.profit >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%) ฿{fmt(item.profit)} ({item.marginPercent?.toFixed(1)}%)
</span> </span>
@@ -283,7 +299,7 @@ export default function TiktokOrderDetailPage() {
) : ( ) : (
<div className="flex items-center gap-2 text-rose-400 font-medium"> <div className="flex items-center gap-2 text-rose-400 font-medium">
<AlertTriangle className="w-3.5 h-3.5" /> <AlertTriangle className="w-3.5 h-3.5" />
<span>ไม่พบต้นทุน BOM สำหรับสินค้านี้ (ทำให้คำนวณกำไรไม่ได้)</span> <span>{t('tiktokOrderDetail.bomNotFoundWarning')}</span>
</div> </div>
)} )}
@@ -295,7 +311,7 @@ export default function TiktokOrderDetailPage() {
className="px-2.5 py-1 rounded-lg bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors" className="px-2.5 py-1 rounded-lg bg-rose-500/20 hover:bg-rose-500/30 text-rose-300 border border-rose-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
> >
<Calculator className="w-3 h-3 text-rose-400" /> <Calculator className="w-3 h-3 text-rose-400" />
{item.hasBom ? 'แก้ไขสูตร BOM' : '➕ ไปใส่สูตร BOM'} {item.hasBom ? t('tiktokOrderDetail.editBom') : t('tiktokOrderDetail.addBom')}
<ArrowUpRight className="w-3 h-3" /> <ArrowUpRight className="w-3 h-3" />
</Link> </Link>
) : ( ) : (
@@ -304,7 +320,7 @@ export default function TiktokOrderDetailPage() {
className="px-2.5 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors" className="px-2.5 py-1 rounded-lg bg-amber-500/20 hover:bg-amber-500/30 text-amber-300 border border-amber-500/40 text-[11px] font-bold flex items-center gap-1 transition-colors"
> >
<Layers className="w-3 h-3 text-amber-400" /> <Layers className="w-3 h-3 text-amber-400" />
🔗 ไปเชื่อมโยง Master SKU {t('tiktokOrderDetail.mapMasterSku')}
<ArrowUpRight className="w-3 h-3" /> <ArrowUpRight className="w-3 h-3" />
</Link> </Link>
)} )}
@@ -315,7 +331,7 @@ export default function TiktokOrderDetailPage() {
{item.bomItemsDetail && item.bomItemsDetail.length > 0 && ( {item.bomItemsDetail && item.bomItemsDetail.length > 0 && (
<div className="p-2.5 rounded-xl bg-black/20 border border-white/5 space-y-1 text-[11px] font-mono text-slate-400"> <div className="p-2.5 rounded-xl bg-black/20 border border-white/5 space-y-1 text-[11px] font-mono text-slate-400">
<p className="text-[10px] text-slate-500 uppercase font-sans font-bold tracking-wider mb-1"> <p className="text-[10px] text-slate-500 uppercase font-sans font-bold tracking-wider mb-1">
สูตรวัตถุดิบ BOM ({item.bomItemsDetail.length} รายการ): {t('tiktokOrderDetail.rawMaterialBreakdown', { count: item.bomItemsDetail.length })}
</p> </p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
{item.bomItemsDetail.map((bom: any, bIdx: number) => ( {item.bomItemsDetail.map((bom: any, bIdx: number) => (
@@ -337,15 +353,15 @@ export default function TiktokOrderDetailPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4"> <div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10"> <div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Truck className="w-4 h-4 text-blue-400" /> <Truck className="w-4 h-4 text-blue-400" />
<h2 className="font-bold text-sm text-white">ข้อมูลการจัดส่งและขนส่ง</h2> <h2 className="font-bold text-sm text-white">{t('tiktokOrderDetail.shippingTitle')}</h2>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
<InfoRow label="Tracking ID" value={order.trackingId} mono /> <InfoRow label="Tracking ID" value={order.trackingId} mono />
<InfoRow label="ผู้ให้บริการขนส่ง" value={order.shippingProvider} /> <InfoRow label={t('tiktokOrderDetail.shippingProvider')} value={order.shippingProvider} />
<InfoRow label="ประเภทการส่ง" value={order.deliveryOption} /> <InfoRow label={t('tiktokOrderDetail.deliveryOption')} value={order.deliveryOption} />
<InfoRow label="Fulfillment" value={order.fulfillmentType} /> <InfoRow label="Fulfillment" value={order.fulfillmentType} />
<InfoRow label="คลังสินค้า" value={order.warehouseName} /> <InfoRow label={t('tiktokOrderDetail.warehouseName')} value={order.warehouseName} />
<InfoRow label="ประเภทออเดอร์" value={order.orderType} /> <InfoRow label={t('tiktokOrderDetail.orderType')} value={order.orderType} />
</div> </div>
</div> </div>
@@ -353,15 +369,15 @@ export default function TiktokOrderDetailPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4"> <div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10"> <div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Calendar className="w-4 h-4 text-amber-400" /> <Calendar className="w-4 h-4 text-amber-400" />
<h2 className="font-bold text-sm text-white">ไทม์ไลน์คำสั่งซื้อ</h2> <h2 className="font-bold text-sm text-white">{t('tiktokOrderDetail.timelineTitle')}</h2>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-xs">
<InfoRow label="วันที่สร้างออเดอร์" value={order.createdTime} /> <InfoRow label={t('tiktokOrderDetail.createdDate')} value={order.createdTime} />
<InfoRow label="วันที่ชำระเงิน" value={order.paidTime} /> <InfoRow label={t('tiktokOrderDetail.paidDate')} value={order.paidTime} />
<InfoRow label="เวลา RTS" value={order.rtsTime} /> <InfoRow label={t('tiktokOrderDetail.rtsTime')} value={order.rtsTime} />
<InfoRow label="เวลาจัดส่งออก" value={order.shippedTime} /> <InfoRow label={t('tiktokOrderDetail.shippedTime')} value={order.shippedTime} />
<InfoRow label="เวลาส่งถึงลูกค้า" value={order.deliveredTime} /> <InfoRow label={t('tiktokOrderDetail.deliveredTime')} value={order.deliveredTime} />
<InfoRow label="เวลายกเลิก" value={order.cancelledTime} /> <InfoRow label={t('tiktokOrderDetail.cancelledTime')} value={order.cancelledTime} />
</div> </div>
</div> </div>
</div> </div>
@@ -376,33 +392,33 @@ export default function TiktokOrderDetailPage() {
}`}> }`}>
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10"> <div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Calculator className={`w-4 h-4 ${hasIncompleteBom ? 'text-rose-400' : 'text-emerald-400'}`} /> <Calculator className={`w-4 h-4 ${hasIncompleteBom ? 'text-rose-400' : 'text-emerald-400'}`} />
<h2 className="font-bold text-sm text-white">สรุปต้นทุน & กำไร (BOM)</h2> <h2 className="font-bold text-sm text-white">{t('tiktokOrderDetail.bomSummaryTitle')}</h2>
</div> </div>
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ยอดขายสุทธิ (Revenue)</span> <span>{t('tiktokOrderDetail.revenueLabel')}</span>
<span className="font-mono text-slate-200 font-bold"> <span className="font-mono text-slate-200 font-bold">
฿{fmt(order.orderAmount)} ฿{fmt(order.orderAmount)}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-slate-400">ต้นทุนวัตถุดิบ (Total BOM Cost)</span> <span className="text-slate-400">{t('tiktokOrderDetail.totalBomCostSummary')}</span>
<span className={`font-mono font-bold ${order.orderTotalBomCost > 0 ? 'text-amber-400' : 'text-rose-400'}`}> <span className={`font-mono font-bold ${order.orderTotalBomCost > 0 ? 'text-amber-400' : 'text-rose-400'}`}>
฿{fmt(order.orderTotalBomCost)} ฿{fmt(order.orderTotalBomCost)}
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-violet-300"> <div className="flex justify-between items-center text-violet-300">
<span className="text-slate-400">ค่าคอมมิชชั่น (Commission)</span> <span className="text-slate-400">{t('tiktokOrderDetail.commissionSummary')}</span>
<span className="font-mono font-semibold"> <span className="font-mono font-semibold">
{order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'} {order.commissionFee > 0 ? `-฿${fmt(order.commissionFee)}` : '฿0.00'}
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-cyan-300"> <div className="flex justify-between items-center text-cyan-300">
<span className="text-slate-400">ค่าพันธมิตร (Affiliate)</span> <span className="text-slate-400">{t('tiktokOrderDetail.affiliateSummary')}</span>
<span className="font-mono font-semibold"> <span className="font-mono font-semibold">
{order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'} {order.affiliateFee > 0 ? `-฿${fmt(order.affiliateFee)}` : '฿0.00'}
</span> </span>
@@ -410,8 +426,8 @@ export default function TiktokOrderDetailPage() {
<div className="pt-3 border-t border-white/10 flex justify-between items-center"> <div className="pt-3 border-t border-white/10 flex justify-between items-center">
<div> <div>
<span className="text-sm font-bold text-white">กำไรสุทธิ (Net Profit)</span> <span className="text-sm font-bold text-white">{t('tiktokOrderDetail.netProfitSummary')}</span>
<p className="text-[10px] text-slate-400">Margin: {order.orderGrossMargin?.toFixed(1)}%</p> <p className="text-[10px] text-slate-400">{t('tiktokOrderDetail.marginLabel', { margin: order.orderGrossMargin?.toFixed(1) || '0.0' })}</p>
</div> </div>
<span className={`text-xl font-bold font-mono ${ <span className={`text-xl font-bold font-mono ${
(order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400' (order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400'
@@ -424,10 +440,14 @@ export default function TiktokOrderDetailPage() {
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 space-y-1 text-rose-300"> <div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 space-y-1 text-rose-300">
<div className="flex items-center gap-1.5 font-bold text-rose-200 text-xs"> <div className="flex items-center gap-1.5 font-bold text-rose-200 text-xs">
<AlertTriangle className="w-3.5 h-3.5" /> <AlertTriangle className="w-3.5 h-3.5" />
<span>คำเตือน: ตัวเลขยังไม่สมบูรณ์</span> <span>{t('tiktokOrderDetail.incompleteWarningTitle')}</span>
</div> </div>
<p className="text-[11px] leading-relaxed"> <p className="text-[11px] leading-relaxed">
มีสินค้า <strong>{order.missingBomCount} รายการ</strong> ยังไม่มีสูตร BOM หรือไม่มีราคาต้นทุน กรุณาใส่ราคา BOM เพื่อให้กำไรถูกต้อง <Trans
i18nKey="tiktokOrderDetail.incompleteWarningDesc"
values={{ count: order.missingBomCount }}
components={{ 1: <strong /> }}
/>
</p> </p>
</div> </div>
)} )}
@@ -438,19 +458,20 @@ export default function TiktokOrderDetailPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4"> <div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10"> <div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<User className="w-4 h-4 text-violet-400" /> <User className="w-4 h-4 text-violet-400" />
<h2 className="font-bold text-sm text-white">ข้อมูลลูกค้าและพื้นที่</h2> <h2 className="font-bold text-sm text-white">{t('tiktokOrderDetail.customerTitle')}</h2>
</div> </div>
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
<InfoRow label="ชื่อผู้ใช้ TikTok" value={order.buyerUsername} /> <InfoRow label={t('tiktokOrderDetail.tiktokUsername')} value={order.buyerUsername} />
<InfoRow label="ช่องทางชำระเงิน" value={order.paymentMethod} /> <InfoRow label={t('tiktokOrderDetail.paymentMethod')} value={order.paymentMethod} />
<div className="pt-2 border-t border-white/5 space-y-2"> <div className="pt-2 border-t border-white/5 space-y-2">
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-500"> <p className="text-[10px] uppercase font-bold tracking-widest text-slate-500">
พิกัดปลายทาง {t('tiktokOrderDetail.destination')}
</p> </p>
<div className="flex items-center gap-1.5 text-sm text-slate-200"> <div className="flex items-center gap-1.5 text-sm text-slate-200">
<MapPin className="w-4 h-4 text-emerald-400" /> <MapPin className="w-4 h-4 text-emerald-400" />
<span> <span>
{order.district ? `อ.${order.district}, ` : ''}จ.{order.province || 'ไม่ระบุ'} {order.district ? `${t('tiktokOrders.districtPrefix')}${locDistrict}, ` : ''}
{t('tiktokOrders.provincePrefix')}{locProvince}
</span> </span>
</div> </div>
</div> </div>
@@ -461,43 +482,43 @@ export default function TiktokOrderDetailPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4"> <div className="bg-slate-900/60 border border-white/10 rounded-2xl p-6 space-y-4">
<div className="flex items-center gap-2.5 pb-3 border-b border-white/10"> <div className="flex items-center gap-2.5 pb-3 border-b border-white/10">
<Receipt className="w-4 h-4 text-emerald-400" /> <Receipt className="w-4 h-4 text-emerald-400" />
<h2 className="font-bold text-sm text-white">สรุปยอดเงิน (Financials)</h2> <h2 className="font-bold text-sm text-white">{t('tiktokOrderDetail.financialsTitle')}</h2>
</div> </div>
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ยอดรวมสินค้า</span> <span>{t('tiktokOrderDetail.itemsSubtotal')}</span>
<span className="font-mono text-slate-200"> <span className="font-mono text-slate-200">
฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))} ฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.subtotalBeforeDiscount || 0), 0))}
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ส่วนลดแพลตฟอร์ม</span> <span>{t('tiktokOrderDetail.platformDiscount')}</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))} -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.platformDiscount || 0), 0))}
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ส่วนลดร้านค้า</span> <span>{t('tiktokOrderDetail.sellerDiscount')}</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))} -฿{fmt(order.items?.reduce((s: number, i: any) => s + (i.sellerDiscount || 0), 0))}
</span> </span>
</div> </div>
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ค่าจัดส่ง</span> <span>{t('tiktokOrderDetail.shippingFee')}</span>
<span className="font-mono text-amber-400">฿{fmt(order.shippingFee)}</span> <span className="font-mono text-amber-400">฿{fmt(order.shippingFee)}</span>
</div> </div>
<div className="flex justify-between items-center text-slate-400"> <div className="flex justify-between items-center text-slate-400">
<span>ภาษี (Taxes)</span> <span>{t('tiktokOrderDetail.taxes')}</span>
<span className="font-mono text-blue-400">฿{fmt(order.taxes)}</span> <span className="font-mono text-blue-400">฿{fmt(order.taxes)}</span>
</div> </div>
{order.refundAmount > 0 && ( {order.refundAmount > 0 && (
<div className="flex justify-between items-center text-rose-400 font-bold"> <div className="flex justify-between items-center text-rose-400 font-bold">
<span>ยอดคืนเงิน (Refund)</span> <span>{t('tiktokOrderDetail.refundAmount')}</span>
<span className="font-mono">-฿{fmt(order.refundAmount)}</span> <span className="font-mono">-฿{fmt(order.refundAmount)}</span>
</div> </div>
)} )}
<div className="pt-3 border-t border-white/10 flex justify-between items-center"> <div className="pt-3 border-t border-white/10 flex justify-between items-center">
<span className="text-sm font-bold text-white">ยอดสุทธิ (Order Amount)</span> <span className="text-sm font-bold text-white">{t('tiktokOrderDetail.totalOrderAmount')}</span>
<span className="text-xl font-bold font-mono text-emerald-400"> <span className="text-xl font-bold font-mono text-emerald-400">
฿{fmt(order.orderAmount)} ฿{fmt(order.orderAmount)}
</span> </span>
@@ -509,4 +530,3 @@ export default function TiktokOrderDetailPage() {
</div> </div>
); );
} }
+192 -96
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Papa from 'papaparse'; import Papa from 'papaparse';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { useTranslation, Trans } from 'react-i18next';
import { import {
ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign, ShoppingBag, Upload, Table2, TrendingUp, Package, DollarSign,
Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2, Coins, ChevronLeft, ChevronRight, ExternalLink, Store, Trash2,
@@ -9,6 +10,7 @@ import {
BadgePercent, Wallet, Receipt BadgePercent, Wallet, Receipt
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { getLocalizedProvinceName, getLocalizedDistrictName } from '../utils/thaiGeoUtils';
// ── Shop Config ─────────────────────────────────────────────────────────────── // ── Shop Config ───────────────────────────────────────────────────────────────
export const TIKTOK_SHOPS = [ export const TIKTOK_SHOPS = [
@@ -34,6 +36,14 @@ function statusColor(status: string) {
return 'bg-white/5 text-slate-400 border-white/10'; return 'bg-white/5 text-slate-400 border-white/10';
} }
function getLocalizedStatus(status: string, t: any) {
if (status.includes('เสร็จ') || status.includes('สำเร็จ')) return t('tiktokOrders.statusCompleted', 'เสร็จสิ้น');
if (status.includes('จัดส่งแล้ว') || status.includes('ส่งแล้ว')) return t('tiktokOrders.statusShipped', 'จัดส่งแล้ว');
if (status.includes('จะจัดส่ง') || status.includes('รอ')) return t('tiktokOrders.statusToShip', 'ที่ต้องจัดส่ง');
if (status.includes('ยกเลิก') || status.includes('cancel')) return t('tiktokOrders.statusCancelled', 'ยกเลิก');
return status;
}
// ── Shop Switcher ───────────────────────────────────────────────────────────── // ── Shop Switcher ─────────────────────────────────────────────────────────────
function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) => void }) { function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string) => void }) {
return ( return (
@@ -53,6 +63,8 @@ function ShopSwitcher({ shop, onChange }: { shop: string; onChange: (s: string)
// ── Summary Cards ───────────────────────────────────────────────────────────── // ── Summary Cards ─────────────────────────────────────────────────────────────
function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor: string }) { function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor: string }) {
const { t } = useTranslation();
if (!summary) return ( if (!summary) return (
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => <div key={i} className="bg-white/5 border border-white/10 rounded-2xl p-5 animate-pulse h-24" />)} {Array.from({ length: 5 }).map((_, i) => <div key={i} className="bg-white/5 border border-white/10 rounded-2xl p-5 animate-pulse h-24" />)}
@@ -63,22 +75,50 @@ function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor:
const hasSettlement = (summary.totalCommission || 0) > 0 || (summary.totalAffiliate || 0) > 0; const hasSettlement = (summary.totalCommission || 0) > 0 || (summary.totalAffiliate || 0) > 0;
const cards = [ const cards = [
{ label: 'คำสั่งซื้อทั้งหมด', value: summary.orderCount.toLocaleString(), sub: `${summary.totalItems?.toLocaleString()} รายการ SKU`, icon: ShoppingBag, color: shopColor },
{ label: 'ยอดขายรวม (Gross)', value: `฿${fmt(summary.totalAmount)}`, sub: `SKU Revenue ฿${fmt(summary.totalSkuRevenue)}`, icon: DollarSign, color: 'text-emerald-400' },
{ label: 'ต้นทุน BOM รวม', value: `฿${fmt(summary.totalBOMCost || 0)}`, sub: `กำไรประเมิน ฿${fmt(summary.totalEstProfit || 0)} (${(summary.grossProfitMargin || 0).toFixed(1)}%)`, icon: Coins, color: 'text-amber-400' },
{ {
label: 'Commission & Affiliate', label: t('tiktokOrders.kpiTotalOrders'),
value: summary.orderCount.toLocaleString(),
sub: t('tiktokOrders.skuCount', { count: summary.totalItems?.toLocaleString() }),
icon: ShoppingBag,
color: shopColor
},
{
label: t('tiktokOrders.kpiGrossSales'),
value: `฿${fmt(summary.totalAmount)}`,
sub: t('tiktokOrders.skuRevenue', { amount: fmt(summary.totalSkuRevenue) }),
icon: DollarSign,
color: 'text-emerald-400'
},
{
label: t('tiktokOrders.kpiTotalBomCost'),
value: `฿${fmt(summary.totalBOMCost || 0)}`,
sub: t('tiktokOrders.estProfit', {
amount: fmt(summary.totalEstProfit || 0),
margin: (summary.grossProfitMargin || 0).toFixed(1)
}),
icon: Coins,
color: 'text-amber-400'
},
{
label: t('tiktokOrders.kpiCommissionAffiliate'),
value: `฿${fmt((summary.totalCommission || 0) + (summary.totalAffiliate || 0))}`, value: `฿${fmt((summary.totalCommission || 0) + (summary.totalAffiliate || 0))}`,
sub: hasSettlement sub: hasSettlement
? `Comm: ฿${fmt(summary.totalCommission || 0)} | Aff: ฿${fmt(summary.totalAffiliate || 0)}` ? t('tiktokOrders.commAffBreakdown', {
: 'รอ Import ใบแจ้งยอด Settlement', comm: fmt(summary.totalCommission || 0),
aff: fmt(summary.totalAffiliate || 0)
})
: t('tiktokOrders.waitingSettlement'),
icon: BadgePercent, icon: BadgePercent,
color: 'text-violet-400', color: 'text-violet-400',
}, },
{ {
label: 'สถานะ BOM สินค้า', label: t('tiktokOrders.kpiBomStatus'),
value: missingBom ? `ขาด ${summary.ordersMissingBomCount} ออเดอร์` : 'BOM ครบ 100%', value: missingBom
sub: missingBom ? '🔴 มีสินค้ายังไม่ได้ใส่ราคา BOM' : '✓ คำนวณต้นทุนครบทุกออเดอร์', ? t('tiktokOrders.missingBomOrders', { count: summary.ordersMissingBomCount })
: t('tiktokOrders.bomComplete100'),
sub: missingBom
? t('tiktokOrders.missingBomWarning')
: t('tiktokOrders.bomCostCalculated'),
icon: missingBom ? AlertTriangle : CheckCircle2, icon: missingBom ? AlertTriangle : CheckCircle2,
color: missingBom ? 'text-rose-400' : 'text-emerald-400', color: missingBom ? 'text-rose-400' : 'text-emerald-400',
isAlert: missingBom, isAlert: missingBom,
@@ -114,6 +154,7 @@ function SummaryCards({ summary, shopColor }: { summary: any | null; shopColor:
// ── Orders Table ────────────────────────────────────────────────────────────── // ── Orders Table ──────────────────────────────────────────────────────────────
function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) { function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number }) {
const { t, i18n } = useTranslation();
const [items, setItems] = useState<any[]>([]); const [items, setItems] = useState<any[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
@@ -179,7 +220,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
type="text" type="text"
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
placeholder="ค้นหา Order ID, ผู้ซื้อ, Tracking, SKU, ชื่อสินค้า..." placeholder={t('tiktokOrders.searchPlaceholder')}
className="w-full pl-10 pr-9 py-2 rounded-xl bg-black/30 border border-white/10 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-rose-500/50 transition-colors font-mono" className="w-full pl-10 pr-9 py-2 rounded-xl bg-black/30 border border-white/10 text-white placeholder-slate-500 text-xs focus:outline-none focus:border-rose-500/50 transition-colors font-mono"
/> />
{searchInput && ( {searchInput && (
@@ -202,7 +243,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
bomFilter === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white' bomFilter === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
}`} }`}
> >
BOM ทั้งหมด {t('tiktokOrders.bomFilterAll')}
</button> </button>
<button <button
onClick={() => setBomFilter('missing')} onClick={() => setBomFilter('missing')}
@@ -213,7 +254,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-rose-500" /> <span className="w-2 h-2 rounded-full bg-rose-500" />
ขาด BOM {t('tiktokOrders.bomFilterMissing')}
</button> </button>
<button <button
onClick={() => setBomFilter('complete')} onClick={() => setBomFilter('complete')}
@@ -224,7 +265,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-emerald-400" /> <span className="w-2 h-2 rounded-full bg-emerald-400" />
BOM ครบ {t('tiktokOrders.bomFilterComplete')}
</button> </button>
</div> </div>
@@ -236,7 +277,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
statusFilter === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white' statusFilter === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
}`} }`}
> >
ทุกสถานะ {t('tiktokOrders.statusFilterAll')}
</button> </button>
<button <button
onClick={() => setStatusFilter('เสร็จ')} onClick={() => setStatusFilter('เสร็จ')}
@@ -246,7 +287,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
: 'text-slate-400 hover:text-emerald-400' : 'text-slate-400 hover:text-emerald-400'
}`} }`}
> >
เสร็จสิ้น {t('tiktokOrders.statusCompleted')}
</button> </button>
<button <button
onClick={() => setStatusFilter('จัดส่งแล้ว')} onClick={() => setStatusFilter('จัดส่งแล้ว')}
@@ -256,7 +297,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
: 'text-slate-400 hover:text-blue-400' : 'text-slate-400 hover:text-blue-400'
}`} }`}
> >
จัดส่งแล้ว {t('tiktokOrders.statusShipped')}
</button> </button>
<button <button
onClick={() => setStatusFilter('จะจัดส่ง')} onClick={() => setStatusFilter('จะจัดส่ง')}
@@ -266,7 +307,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
: 'text-slate-400 hover:text-amber-400' : 'text-slate-400 hover:text-amber-400'
}`} }`}
> >
ที่ต้องจัดส่ง {t('tiktokOrders.statusToShip')}
</button> </button>
<button <button
onClick={() => setStatusFilter('ยกเลิก')} onClick={() => setStatusFilter('ยกเลิก')}
@@ -276,7 +317,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
: 'text-slate-400 hover:text-rose-400' : 'text-slate-400 hover:text-rose-400'
}`} }`}
> >
ยกเลิก {t('tiktokOrders.statusCancelled')}
</button> </button>
</div> </div>
@@ -284,7 +325,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<button <button
onClick={clearFilters} onClick={clearFilters}
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-colors" className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white border border-white/10 transition-colors"
title="ล้างตัวกรองทั้งหมด" title={t('tiktokOrders.clearFilters')}
> >
<RotateCcw className="w-3.5 h-3.5" /> <RotateCcw className="w-3.5 h-3.5" />
</button> </button>
@@ -297,14 +338,30 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<p className="text-slate-400"> <p className="text-slate-400">
{search || statusFilter !== 'all' || bomFilter !== 'all' ? ( {search || statusFilter !== 'all' || bomFilter !== 'all' ? (
<> <>
พบ <span className="text-rose-400 font-bold font-mono">{total.toLocaleString()}</span> รายการ{' '} <Trans
{search && <span className="text-slate-400">(จากคำค้นหา <strong className="text-white">"{search}"</strong>)</span>} i18nKey="tiktokOrders.foundOrders"
{bomFilter === 'missing' && <span className="text-rose-400 font-semibold ml-1">[กรองเฉพาะที่ขาด BOM]</span>} values={{ count: total.toLocaleString() }}
components={{ 1: <span className="text-rose-400 font-bold font-mono" /> }}
/>{' '}
{search && (
<span className="text-slate-400">
<Trans
i18nKey="tiktokOrders.fromSearch"
values={{ search }}
components={{ 1: <strong className="text-white" /> }}
/>
</span>
)}
{bomFilter === 'missing' && (
<span className="text-rose-400 font-semibold ml-1">{t('tiktokOrders.filteredMissingBom')}</span>
)}
</> </>
) : ( ) : (
<> <Trans
ทั้งหมด <span className="text-white font-bold font-mono">{total.toLocaleString()}</span> คำสั่งซื้อ i18nKey="tiktokOrders.totalOrdersCount"
</> values={{ count: total.toLocaleString() }}
components={{ 1: <span className="text-white font-bold font-mono" /> }}
/>
)} )}
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -332,16 +389,16 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="text-slate-400 uppercase bg-white/5 border-b border-white/10"> <thead className="text-slate-400 uppercase bg-white/5 border-b border-white/10">
<tr> <tr>
<th className="px-4 py-3.5 whitespace-nowrap">Order ID</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thOrderId')}</th>
<th className="px-4 py-3.5 whitespace-nowrap">สถานะ</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thStatus')}</th>
<th className="px-4 py-3.5 whitespace-nowrap">ผู้ซื้อ / พื้นที่</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thBuyerArea')}</th>
<th className="px-4 py-3.5 whitespace-nowrap">วันที่สร้าง</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thCreatedDate')}</th>
<th className="px-4 py-3.5 whitespace-nowrap">SKUs & BOM</th> <th className="px-4 py-3.5 whitespace-nowrap">{t('tiktokOrders.thSkusBom')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">ยอดขาย (฿)</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thOrderAmount')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">ต้นทุน BOM (฿)</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thBomCost')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">Commission (฿)</th> <th className="px-4 py-3.5 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">Affiliate (฿)</th> <th className="px-4 py-3.5 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
<th className="px-4 py-3.5 whitespace-nowrap text-right">กำไรสุทธิ</th> <th className="px-4 py-3.5 whitespace-nowrap text-right">{t('tiktokOrders.thNetProfit')}</th>
<th className="px-4 py-3.5 text-center" /> <th className="px-4 py-3.5 text-center" />
</tr> </tr>
</thead> </thead>
@@ -349,16 +406,19 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={11} className="px-4 py-12 text-center text-slate-500"> <td colSpan={11} className="px-4 py-12 text-center text-slate-500">
<div className="inline-block animate-spin mr-2 text-rose-400">⏳</div> กำลังประมวลผลข้อมูล... <div className="inline-block animate-spin mr-2 text-rose-400">⏳</div> {t('tiktokOrders.processingData')}
</td> </td>
</tr> </tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr> <tr>
<td colSpan={11} className="px-4 py-16 text-center text-slate-400">ยังไม่มีข้อมูล</td> <td colSpan={11} className="px-4 py-16 text-center text-slate-400">{t('tiktokOrders.noOrders')}</td>
</tr> </tr>
) : ( ) : (
items.map((order) => { items.map((order) => {
const hasMissingBom = order.hasIncompleteBom; const hasMissingBom = order.hasIncompleteBom;
const locDistrict = getLocalizedDistrictName(order.district, undefined, i18n.language) || order.district || '';
const locProvince = getLocalizedProvinceName(order.province, i18n.language);
return ( return (
<tr key={order.orderId} className={`border-b border-white/5 transition-colors group ${ <tr key={order.orderId} className={`border-b border-white/5 transition-colors group ${
hasMissingBom ? 'hover:bg-rose-500/[0.04]' : 'hover:bg-white/[0.04]' hasMissingBom ? 'hover:bg-rose-500/[0.04]' : 'hover:bg-white/[0.04]'
@@ -371,10 +431,10 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
</Link> </Link>
{order.isSettled ? ( {order.isSettled ? (
<span className="text-[10px] font-normal text-emerald-400 inline-flex items-center gap-1"> <span className="text-[10px] font-normal text-emerald-400 inline-flex items-center gap-1">
<CheckCircle2 className="w-2.5 h-2.5" /> โอนแล้ว ฿{fmt(order.netSettlement)} <CheckCircle2 className="w-2.5 h-2.5" /> {t('tiktokOrders.settledTransferred', { amount: fmt(order.netSettlement) })}
</span> </span>
) : ( ) : (
<span className="text-[9px] font-normal text-slate-500">รอโอนเงิน</span> <span className="text-[9px] font-normal text-slate-500">{t('tiktokOrders.waitingSettlementTransfer')}</span>
)} )}
</div> </div>
</td> </td>
@@ -383,7 +443,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
<td className="px-4 py-3.5"> <td className="px-4 py-3.5">
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap border ${statusColor(order.status)}`}> <span className={`px-2 py-0.5 rounded-full text-[10px] font-medium whitespace-nowrap border ${statusColor(order.status)}`}>
{order.status} {getLocalizedStatus(order.status, t)}
</span> </span>
{order.subStatus && <span className="text-[9px] text-slate-500">{order.subStatus}</span>} {order.subStatus && <span className="text-[9px] text-slate-500">{order.subStatus}</span>}
</div> </div>
@@ -392,7 +452,12 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Buyer */} {/* Buyer */}
<td className="px-4 py-3.5 text-slate-300 max-w-[140px] truncate"> <td className="px-4 py-3.5 text-slate-300 max-w-[140px] truncate">
<p className="font-semibold text-white truncate">{order.buyerUsername || '-'}</p> <p className="font-semibold text-white truncate">{order.buyerUsername || '-'}</p>
{order.province && <p className="text-[10px] text-slate-400 truncate">อ.{order.district || ''} จ.{order.province}</p>} {order.province && (
<p className="text-[10px] text-slate-400 truncate">
{order.district ? `${t('tiktokOrders.districtPrefix')}${locDistrict} ` : ''}
{t('tiktokOrders.provincePrefix')}{locProvince}
</p>
)}
</td> </td>
{/* Date */} {/* Date */}
@@ -408,12 +473,12 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{hasMissingBom ? ( {hasMissingBom ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 text-[10px] font-bold"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-rose-500/20 text-rose-300 border border-rose-500/30 text-[10px] font-bold">
<AlertTriangle className="w-3 h-3 text-rose-400" /> <AlertTriangle className="w-3 h-3 text-rose-400" />
ขาด BOM ({order.missingBomCount}) {t('tiktokOrders.missingBomCount', { count: order.missingBomCount })}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/25 text-[10px] font-bold"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-emerald-500/15 text-emerald-400 border border-emerald-500/25 text-[10px] font-bold">
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> <CheckCircle2 className="w-3 h-3 text-emerald-400" />
BOM ครบ {t('tiktokOrders.bomComplete')}
</span> </span>
)} )}
</div> </div>
@@ -429,10 +494,10 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{hasMissingBom ? ( {hasMissingBom ? (
<div className="space-y-0.5 text-right"> <div className="space-y-0.5 text-right">
<span className="text-rose-400 font-bold text-[11px] inline-flex items-center justify-end gap-1 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/20"> <span className="text-rose-400 font-bold text-[11px] inline-flex items-center justify-end gap-1 bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/20">
<AlertTriangle className="w-3 h-3 text-rose-400" /> ยังไม่ระบุ <AlertTriangle className="w-3 h-3 text-rose-400" /> {t('tiktokOrders.bomUnspecified')}
</span> </span>
{order.orderTotalBomCost > 0 && ( {order.orderTotalBomCost > 0 && (
<p className="text-[10px] text-slate-500">มีบางส่วน ฿{fmt(order.orderTotalBomCost)}</p> <p className="text-[10px] text-slate-500">{t('tiktokOrders.bomPartial', { amount: fmt(order.orderTotalBomCost) })}</p>
)} )}
</div> </div>
) : ( ) : (
@@ -461,7 +526,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
{/* Profit */} {/* Profit */}
<td className="px-4 py-3.5 text-right font-mono whitespace-nowrap"> <td className="px-4 py-3.5 text-right font-mono whitespace-nowrap">
{hasMissingBom ? ( {hasMissingBom ? (
<span className="text-rose-400 text-[10px] font-bold">รอใส่ราคา BOM</span> <span className="text-rose-400 text-[10px] font-bold">{t('tiktokOrders.waitingBomPrice')}</span>
) : ( ) : (
<div> <div>
<p className={`font-bold ${(order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}> <p className={`font-bold ${(order.orderNetProfit ?? order.orderEstProfit) >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>
@@ -478,7 +543,7 @@ function OrdersTable({ shop, refreshKey }: { shop: string; refreshKey: number })
to={`/tiktok-order/${encodeURIComponent(order.orderId)}`} to={`/tiktok-order/${encodeURIComponent(order.orderId)}`}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-xs hover:bg-rose-500/15 hover:border-rose-500/30 hover:text-rose-300 transition-all" className="inline-flex items-center gap-1 px-3 py-1.5 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-xs hover:bg-rose-500/15 hover:border-rose-500/30 hover:text-rose-300 transition-all"
> >
<span>ดูออเดอร์</span> <ExternalLink className="w-3 h-3" /> <span>{t('tiktokOrders.viewOrder')}</span> <ExternalLink className="w-3 h-3" />
</Link> </Link>
</td> </td>
</tr> </tr>
@@ -501,6 +566,7 @@ interface FileSummary {
} }
function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) { function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void }) {
const { t } = useTranslation();
const [importType, setImportType] = useState<'orders' | 'settlement'>('orders'); const [importType, setImportType] = useState<'orders' | 'settlement'>('orders');
// Orders CSV State // Orders CSV State
@@ -578,7 +644,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
} }
if (combinedRows.length === 0) { if (combinedRows.length === 0) {
setMsg({ text: 'ไม่พบข้อมูลคำสั่งซื้อในไฟล์ CSV ที่เลือก', type: 'error' }); setMsg({ text: t('tiktokOrders.noOrdersInCsv'), type: 'error' });
setReading(false); setReading(false);
return; return;
} }
@@ -595,7 +661,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
setPreviewRows(combinedRows.slice(0, 5)); setPreviewRows(combinedRows.slice(0, 5));
setCrossFileDuplicateCount(duplicatesAcrossFiles); setCrossFileDuplicateCount(duplicatesAcrossFiles);
} catch (err: any) { } catch (err: any) {
setMsg({ text: `อ่านไฟล์ไม่ได้: ${err.message}`, type: 'error' }); setMsg({ text: t('tiktokOrders.cannotReadFile', { error: err.message }), type: 'error' });
} finally { } finally {
setReading(false); setReading(false);
} }
@@ -654,7 +720,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
}); });
if (validRows.length === 0) { if (validRows.length === 0) {
throw new Error(`ไม่พบรายการคำสั่งซื้อใน Sheet "${targetSheetName}"`); throw new Error(t('tiktokOrders.noOrdersInSheet', { sheet: targetSheetName }));
} }
let totalSettlement = 0; let totalSettlement = 0;
@@ -688,13 +754,13 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
totalFee, totalFee,
}); });
} catch (err: any) { } catch (err: any) {
setMsg({ text: `อ่านไฟล์ XLSX ไม่สำเร็จ: ${err.message}`, type: 'error' }); setMsg({ text: t('tiktokOrders.cannotReadXlsx', { error: err.message }), type: 'error' });
} finally { } finally {
setReading(false); setReading(false);
} }
}; };
reader.onerror = () => { reader.onerror = () => {
setMsg({ text: 'เกิดข้อผิดพลาดในการอ่านไฟล์', type: 'error' }); setMsg({ text: t('tiktokOrders.errorReadingFile'), type: 'error' });
setReading(false); setReading(false);
}; };
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
@@ -760,7 +826,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
}; };
const handleClearData = async () => { const handleClearData = async () => {
if (!confirm(`ยืนยันการลบข้อมูล TikTok ทั้งหมด (ทั้งคำสั่งซื้อและใบแจ้งยอด) ของร้าน "${shopCfg.label}"?\nการกระทำนี้ไม่สามารถกู้คืนได้`)) return; if (!confirm(t('tiktokOrders.confirmClearData', { shop: shopCfg.label }))) return;
setClearing(true); setClearing(true);
setMsg(null); setMsg(null);
try { try {
@@ -791,7 +857,8 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<div className={`flex items-center gap-3 px-4 py-2.5 rounded-xl ${shopCfg.bg} border ${shopCfg.border}`}> <div className={`flex items-center gap-3 px-4 py-2.5 rounded-xl ${shopCfg.bg} border ${shopCfg.border}`}>
<Store className={`w-4 h-4 ${shopCfg.color}`} /> <Store className={`w-4 h-4 ${shopCfg.color}`} />
<p className="text-sm font-medium text-white"> <p className="text-sm font-medium text-white">
Import เข้าร้าน: <span className={`font-bold ${shopCfg.color}`}>{shopCfg.label}</span> {t('tiktokOrders.targetShop')}{' '}
<span className={`font-bold ${shopCfg.color}`}>{shopCfg.label}</span>
</p> </p>
<span className={`ml-auto text-xs font-mono ${shopCfg.color} opacity-60`}>{shopCfg.id}</span> <span className={`ml-auto text-xs font-mono ${shopCfg.color} opacity-60`}>{shopCfg.id}</span>
</div> </div>
@@ -807,7 +874,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
}`} }`}
> >
<Package className="w-3.5 h-3.5" /> <Package className="w-3.5 h-3.5" />
1. คำสั่งซื้อ (Orders CSV) {t('tiktokOrders.tabOrdersCsv')}
</button> </button>
<button <button
onClick={() => { setImportType('settlement'); setMsg(null); }} onClick={() => { setImportType('settlement'); setMsg(null); }}
@@ -818,7 +885,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
}`} }`}
> >
<Wallet className="w-3.5 h-3.5" /> <Wallet className="w-3.5 h-3.5" />
2. ใบแจ้งยอดการเงิน (Settlement XLSX) {t('tiktokOrders.tabSettlementXlsx')}
</button> </button>
</div> </div>
</div> </div>
@@ -840,15 +907,23 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<Upload className={`w-8 h-8 ${shopCfg.color}`} /> <Upload className={`w-8 h-8 ${shopCfg.color}`} />
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold text-base">คลิกเพื่อเลือกไฟล์คำสั่งซื้อ CSV (เลือกได้หลายไฟล์พร้อมกัน)</p> <p className="text-white font-bold text-base">{t('tiktokOrders.csvDropzoneTitle')}</p>
<p className="text-slate-400 text-xs mt-1">กด <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Ctrl</kbd> หรือ <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Shift</kbd> ค้างไว้เพื่อเลือกหลายไฟล์ (.csv จาก TikTok Seller Center)</p> <p className="text-slate-400 text-xs mt-1">
<Trans
i18nKey="tiktokOrders.csvDropzoneSub"
components={{
kbd1: <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Ctrl</kbd>,
kbd2: <kbd className="px-1.5 py-0.5 rounded bg-white/10 text-slate-300 font-mono">Shift</kbd>
}}
/>
</p>
</div> </div>
<input id="tiktok-file-input" type="file" multiple accept=".csv" onChange={handleOrdersFileChange} className="hidden" /> <input id="tiktok-file-input" type="file" multiple accept=".csv" onChange={handleOrdersFileChange} className="hidden" />
</label> </label>
{reading && ( {reading && (
<div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2"> <div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2">
<span className="animate-spin">⏳</span> กำลังประมวลผลไฟล์ CSV ทั้งหมด... <span className="animate-spin">⏳</span> {t('tiktokOrders.readingCsv')}
</div> </div>
)} )}
@@ -858,10 +933,13 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-white"> <div className="flex items-center gap-2 text-sm font-bold text-white">
<Files className="w-4 h-4 text-rose-400" /> <Files className="w-4 h-4 text-rose-400" />
ไฟล์ที่เลือก ({fileSummaries.length} ไฟล์) {t('tiktokOrders.selectedFilesCount', { count: fileSummaries.length })}
</div> </div>
<div className="text-xs text-slate-400"> <div className="text-xs text-slate-400">
รวมทั้งหมด <span className="text-white font-bold">{allRows.length}</span> แถวสินค้า → <span className="text-emerald-400 font-bold">{uniqueOrders}</span> คำสั่งซื้อไม่ซ้ำ {t('tiktokOrders.totalRowsToOrders', {
rows: allRows.length.toLocaleString(),
orders: uniqueOrders.toLocaleString()
})}
</div> </div>
</div> </div>
@@ -878,8 +956,8 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-xs pt-2 border-t border-white/5 text-slate-400"> <div className="flex items-center justify-between text-xs pt-2 border-t border-white/5 text-slate-400">
<span>{f.rowsCount} แถว</span> <span>{f.rowsCount} {t('tiktokOrders.rowsUnit')}</span>
<span className="font-bold text-rose-300">{f.ordersCount} ออเดอร์</span> <span className="font-bold text-rose-300">{f.ordersCount} {t('tiktokOrders.ordersUnit')}</span>
</div> </div>
</div> </div>
))} ))}
@@ -889,16 +967,18 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3"> <div className="p-3.5 bg-amber-500/10 border border-amber-500/20 rounded-xl flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" /> <AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="text-xs space-y-1"> <div className="text-xs space-y-1">
<p className="font-bold text-amber-300">ตรวจพบคำสั่งซื้อซ้ำกันระหว่างไฟล์ {crossFileDuplicateCount} รายการ</p> <p className="font-bold text-amber-300">
{t('tiktokOrders.duplicateOrdersAlertTitle', { count: crossFileDuplicateCount })}
</p>
<p className="text-slate-300"> <p className="text-slate-300">
ระบบจะทำการรวมข้อมูลและ <strong>Upsert (อัปเดตอัตโนมัติ)</strong> ตาม Order ID โดยไม่สร้างข้อมูลเบิ้ล และยอดขายจะไม่ทับซ้อน {t('tiktokOrders.duplicateOrdersAlertDesc')}
</p> </p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2.5 text-xs text-emerald-300"> <div className="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-2.5 text-xs text-emerald-300">
<CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" /> <CheckCircle2 className="w-4 h-4 shrink-0 text-emerald-400" />
<span>ไฟล์ทั้งหมดไม่พบหมายเลขคำสั่งซื้อที่ซ้ำกันข้ามไฟล์</span> <span>{t('tiktokOrders.noCrossFileDuplicates')}</span>
</div> </div>
)} )}
</div> </div>
@@ -907,7 +987,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
{/* Preview Table */} {/* Preview Table */}
{previewRows.length > 0 && ( {previewRows.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-sm text-slate-400 font-medium">Preview ตัวอย่างข้อมูลรวม (5 แถวแรก):</p> <p className="text-sm text-slate-400 font-medium">{t('tiktokOrders.previewTitle')}</p>
<div className="overflow-x-auto border border-white/10 rounded-xl"> <div className="overflow-x-auto border border-white/10 rounded-xl">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10"> <thead className="bg-white/5 text-slate-400 border-b border-white/10">
@@ -930,7 +1010,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex justify-between items-center mt-4"> <div className="flex justify-between items-center mt-4 flex-wrap gap-3">
<button <button
onClick={handleClearData} onClick={handleClearData}
disabled={clearing} disabled={clearing}
@@ -940,12 +1020,23 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
: 'bg-rose-500/10 text-rose-400 hover:bg-rose-500 hover:text-white border border-rose-500/20' : 'bg-rose-500/10 text-rose-400 hover:bg-rose-500 hover:text-white border border-rose-500/20'
}`} }`}
> >
{clearing ? <><span className="animate-spin">⏳</span> กำลังลบข้อมูล...</> : <><Trash2 className="w-4 h-4" /> ลบข้อมูลทั้งหมดของร้าน {shopCfg.label}</>} {clearing ? (
<><span className="animate-spin">⏳</span> {t('tiktokOrders.clearing')}</>
) : (
<><Trash2 className="w-4 h-4" /> {t('tiktokOrders.clearShopData', { shop: shopCfg.label })}</>
)}
</button> </button>
<button onClick={handleImportOrders} disabled={loading || reading} <button
className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`}> onClick={handleImportOrders}
{loading ? <><span className="animate-spin">⏳</span> กำลัง Import {fileSummaries.length} ไฟล์...</> : <><Upload className="w-4 h-4" /> Import {uniqueOrders} คำสั่งซื้อ ({allRows.length} แถวสินค้า) → {shopCfg.label}</>} disabled={loading || reading}
className={`px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 ${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder} hover:opacity-90`}
>
{loading ? (
<><span className="animate-spin">⏳</span> {t('tiktokOrders.importingFiles', { count: fileSummaries.length })}</>
) : (
<><Upload className="w-4 h-4" /> {t('tiktokOrders.importOrdersButton', { orders: uniqueOrders, rows: allRows.length, shop: shopCfg.label })}</>
)}
</button> </button>
</div> </div>
</div> </div>
@@ -964,9 +1055,9 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
<Wallet className="w-8 h-8 text-violet-400" /> <Wallet className="w-8 h-8 text-violet-400" />
</div> </div>
<div className="text-center"> <div className="text-center">
<p className="text-white font-bold text-base">คลิกเพื่อเลือกไฟล์รายงานการเงิน (Income / Settlement XLSX)</p> <p className="text-white font-bold text-base">{t('tiktokOrders.xlsxDropzoneTitle')}</p>
<p className="text-slate-400 text-xs mt-1"> <p className="text-slate-400 text-xs mt-1">
รองรับไฟล์ <code className="text-violet-300 font-mono">income_*.xlsx</code> จากเมนู การเงิน ➔ ใบแจ้งยอด ➔ ส่งออกรายละเอียด {t('tiktokOrders.xlsxDropzoneSub')}
</p> </p>
</div> </div>
<input id="tiktok-settlement-input" type="file" accept=".xlsx,.xls,.csv" onChange={handleSettlementFileChange} className="hidden" /> <input id="tiktok-settlement-input" type="file" accept=".xlsx,.xls,.csv" onChange={handleSettlementFileChange} className="hidden" />
@@ -974,7 +1065,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
{reading && ( {reading && (
<div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2"> <div className="p-4 bg-white/5 rounded-xl border border-white/10 text-center text-slate-300 text-sm flex items-center justify-center gap-2">
<span className="animate-spin">⏳</span> กำลังอ่านข้อมูลและแจกแจงค่าคอมมิชชั่นจากไฟล์ Excel... <span className="animate-spin">⏳</span> {t('tiktokOrders.readingXlsx')}
</div> </div>
)} )}
@@ -982,36 +1073,36 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
{settlementStats && ( {settlementStats && (
<div className="space-y-4"> <div className="space-y-4">
<div className="p-5 rounded-2xl bg-violet-500/10 border border-violet-500/30 space-y-4 backdrop-blur-sm"> <div className="p-5 rounded-2xl bg-violet-500/10 border border-violet-500/30 space-y-4 backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-white/10 pb-3"> <div className="flex items-center justify-between border-b border-white/10 pb-3 flex-wrap gap-2">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Receipt className="w-5 h-5 text-violet-400" /> <Receipt className="w-5 h-5 text-violet-400" />
<div> <div>
<p className="text-sm font-bold text-white"> <p className="text-sm font-bold text-white">
สรุปข้อมูลรายงานการเงิน ({settlementFile?.name}) {t('tiktokOrders.settlementSummaryTitle', { file: settlementFile?.name })}
</p> </p>
<p className="text-xs text-slate-400">Sheet: {settlementStats.sheetName}</p> <p className="text-xs text-slate-400">{t('tiktokOrders.sheetLabel', { sheet: settlementStats.sheetName })}</p>
</div> </div>
</div> </div>
<span className="px-3 py-1 rounded-full text-xs font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30"> <span className="px-3 py-1 rounded-full text-xs font-bold bg-violet-500/20 text-violet-300 border border-violet-500/30">
พบ {settlementStats.rowsCount} รายการธุรกรรม {t('tiktokOrders.transactionsFound', { count: settlementStats.rowsCount })}
</span> </span>
</div> </div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="bg-white/5 border border-white/10 rounded-xl p-3"> <div className="bg-white/5 border border-white/10 rounded-xl p-3">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดเงินโอนสุทธิ (Net Payout)</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('tiktokOrders.netPayout')}</p>
<p className="text-base font-bold font-mono text-emerald-400 mt-0.5">฿{fmt(settlementStats.totalSettlement)}</p> <p className="text-base font-bold font-mono text-emerald-400 mt-0.5">฿{fmt(settlementStats.totalSettlement)}</p>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-3"> <div className="bg-white/5 border border-white/10 rounded-xl p-3">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ยอดขายสินค้ารวม (Revenue)</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('tiktokOrders.revenue')}</p>
<p className="text-base font-bold font-mono text-white mt-0.5">฿{fmt(settlementStats.totalRevenue)}</p> <p className="text-base font-bold font-mono text-white mt-0.5">฿{fmt(settlementStats.totalRevenue)}</p>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-3"> <div className="bg-white/5 border border-white/10 rounded-xl p-3">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ค่าคอมมิชชั่น TikTok</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('tiktokOrders.tiktokCommission')}</p>
<p className="text-base font-bold font-mono text-violet-400 mt-0.5">฿{fmt(settlementStats.totalCommission)}</p> <p className="text-base font-bold font-mono text-violet-400 mt-0.5">฿{fmt(settlementStats.totalCommission)}</p>
</div> </div>
<div className="bg-white/5 border border-white/10 rounded-xl p-3"> <div className="bg-white/5 border border-white/10 rounded-xl p-3">
<p className="text-[10px] text-slate-400 uppercase tracking-wider">ค่า Affiliate พันธมิตร</p> <p className="text-[10px] text-slate-400 uppercase tracking-wider">{t('tiktokOrders.partnerAffiliate')}</p>
<p className="text-base font-bold font-mono text-cyan-400 mt-0.5">฿{fmt(settlementStats.totalAffiliate)}</p> <p className="text-base font-bold font-mono text-cyan-400 mt-0.5">฿{fmt(settlementStats.totalAffiliate)}</p>
</div> </div>
</div> </div>
@@ -1019,17 +1110,17 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
{/* Settlement Preview Table */} {/* Settlement Preview Table */}
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-slate-400 font-medium">ตัวอย่างรายการใบแจ้งยอด (5 แถวแรก):</p> <p className="text-xs text-slate-400 font-medium">{t('tiktokOrders.settlementPreviewTitle')}</p>
<div className="overflow-x-auto border border-white/10 rounded-xl"> <div className="overflow-x-auto border border-white/10 rounded-xl">
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10"> <thead className="bg-white/5 text-slate-400 border-b border-white/10">
<tr> <tr>
<th className="px-3 py-2 whitespace-nowrap">Order ID</th> <th className="px-3 py-2 whitespace-nowrap">{t('tiktokOrders.thOrderId')}</th>
<th className="px-3 py-2 whitespace-nowrap">วันที่ชำระ</th> <th className="px-3 py-2 whitespace-nowrap">{t('tiktokOrders.thSettledDate')}</th>
<th className="px-3 py-2 whitespace-nowrap text-right">ยอดโอนสุทธิ</th> <th className="px-3 py-2 whitespace-nowrap text-right">{t('tiktokOrders.thNetSettlement')}</th>
<th className="px-3 py-2 whitespace-nowrap text-right text-violet-400">Commission</th> <th className="px-3 py-2 whitespace-nowrap text-right text-violet-400">{t('tiktokOrders.thCommission')}</th>
<th className="px-3 py-2 whitespace-nowrap text-right text-cyan-400">Affiliate</th> <th className="px-3 py-2 whitespace-nowrap text-right text-cyan-400">{t('tiktokOrders.thAffiliate')}</th>
<th className="px-3 py-2 whitespace-nowrap text-right">ค่าธรรมเนียมคำสั่งซื้อ</th> <th className="px-3 py-2 whitespace-nowrap text-right">{t('tiktokOrders.thOrderFee')}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1064,7 +1155,11 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
disabled={loading || reading} disabled={loading || reading}
className="px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 bg-violet-500 hover:bg-violet-600 text-white shadow-lg shadow-violet-950/50" className="px-8 py-2.5 font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 bg-violet-500 hover:bg-violet-600 text-white shadow-lg shadow-violet-950/50"
> >
{loading ? <><span className="animate-spin">⏳</span> กำลังบันทึกข้อมูลใบแจ้งยอด...</> : <><Wallet className="w-4 h-4" /> ยืนยัน Import ใบแจ้งยอด ({settlementStats.rowsCount} รายการ) → {shopCfg.label}</>} {loading ? (
<><span className="animate-spin">⏳</span> {t('tiktokOrders.savingSettlement')}</>
) : (
<><Wallet className="w-4 h-4" /> {t('tiktokOrders.confirmImportSettlement', { count: settlementStats.rowsCount, shop: shopCfg.label })}</>
)}
</button> </button>
</div> </div>
</div> </div>
@@ -1079,6 +1174,7 @@ function ImportPanel({ shop, onSuccess }: { shop: string; onSuccess: () => void
type ViewMode = 'list' | 'import'; type ViewMode = 'list' | 'import';
export default function TiktokOrderPage() { export default function TiktokOrderPage() {
const { t } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('list'); const [viewMode, setViewMode] = useState<ViewMode>('list');
const [summary, setSummary] = useState<any>(null); const [summary, setSummary] = useState<any>(null);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
@@ -1104,7 +1200,7 @@ export default function TiktokOrderPage() {
<ShoppingBag className={`w-5 h-5 ${shopCfg.color}`} /> <ShoppingBag className={`w-5 h-5 ${shopCfg.color}`} />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold text-white tracking-tight">TikTok Orders</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('tiktokOrders.title')}</h1>
<p className={`text-xs uppercase tracking-widest font-bold ${shopCfg.color}`}>{shopCfg.label}</p> <p className={`text-xs uppercase tracking-widest font-bold ${shopCfg.color}`}>{shopCfg.label}</p>
</div> </div>
</div> </div>
@@ -1113,11 +1209,11 @@ export default function TiktokOrderPage() {
<div className="h-5 w-px bg-white/10 hidden sm:block" /> <div className="h-5 w-px bg-white/10 hidden sm:block" />
<button onClick={() => setViewMode('list')} <button onClick={() => setViewMode('list')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${viewMode === 'list' ? 'bg-rose-500/15 text-rose-400 border border-rose-500/30' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'}`}> className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${viewMode === 'list' ? 'bg-rose-500/15 text-rose-400 border border-rose-500/30' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'}`}>
<Table2 className="w-4 h-4" /> ดูข้อมูล <Table2 className="w-4 h-4" /> {t('tiktokOrders.viewData')}
</button> </button>
<button onClick={() => setViewMode('import')} <button onClick={() => setViewMode('import')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${viewMode === 'import' ? `${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder}` : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'}`}> className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${viewMode === 'import' ? `${shopCfg.activeBg} ${shopCfg.color} border ${shopCfg.activeBorder}` : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'}`}>
<Upload className="w-4 h-4" /> Import CSV / XLSX <Upload className="w-4 h-4" /> {t('tiktokOrders.importData')}
</button> </button>
</div> </div>
</div> </div>
@@ -1127,9 +1223,9 @@ export default function TiktokOrderPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm"> <div className="bg-white/5 border border-white/10 rounded-2xl overflow-hidden backdrop-blur-sm">
<div className="flex items-center gap-3 px-6 py-4 border-b border-white/10 bg-white/[0.02]"> <div className="flex items-center gap-3 px-6 py-4 border-b border-white/10 bg-white/[0.02]">
{viewMode === 'list' ? ( {viewMode === 'list' ? (
<><TrendingUp className={`w-4 h-4 ${shopCfg.color}`} /><h2 className="font-bold text-white">คำสั่งซื้อ — {shopCfg.label}</h2></> <><TrendingUp className={`w-4 h-4 ${shopCfg.color}`} /><h2 className="font-bold text-white">{t('tiktokOrders.ordersTitle', { shop: shopCfg.label })}</h2></>
) : ( ) : (
<><Upload className={`w-4 h-4 ${shopCfg.color}`} /><h2 className="font-bold text-white">นำเข้าข้อมูลจาก TikTok Shop</h2><span className="text-xs text-slate-500">อัพโหลดไฟล์ Orders Report (.csv)</span></> <><Upload className={`w-4 h-4 ${shopCfg.color}`} /><h2 className="font-bold text-white">{t('tiktokOrders.importTitle')}</h2><span className="text-xs text-slate-500">{t('tiktokOrders.importSubtitle')}</span></>
)} )}
</div> </div>
<div className="p-6"> <div className="p-6">
+52 -49
View File
@@ -8,6 +8,7 @@ import {
TrendingUp, DollarSign, Package, TrendingUp, DollarSign, Package,
BadgePercent, ShoppingCart, Loader2, Store, Calendar, BadgePercent, ShoppingCart, Loader2, Store, Calendar,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { TIKTOK_SHOPS } from './TiktokOrderPage';
const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, ''); const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:3000').replace(/\/api\/?$/, '');
@@ -42,17 +43,6 @@ function getPresetRange(preset: Preset): { from: string; to: string } {
if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; } if (preset === 'last12m') { const f = new Date(now); f.setFullYear(f.getFullYear() - 1); return { from: toISO(f), to: today }; }
return { from: '', to: '' }; return { from: '', to: '' };
} }
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: '7 วัน' },
{ key: '30d', label: '30 วัน' },
{ key: '90d', label: '90 วัน' },
{ key: 'thisMonth', label: 'เดือนนี้' },
{ key: 'lastMonth', label: 'เดือนที่แล้ว' },
{ key: 'year', label: 'ปีนี้' },
{ key: 'lastYear', label: 'ปีที่แล้ว' },
{ key: 'last12m', label: '12 เดือนที่ผ่านมา' },
{ key: 'all', label: 'ทั้งหมด' },
];
function authHeaders() { function authHeaders() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` }; return { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}` };
@@ -92,6 +82,7 @@ const CustomTooltip = ({ active, payload, label }: any) => {
}; };
export default function TiktokOverviewPage() { export default function TiktokOverviewPage() {
const { t } = useTranslation();
const [data, setData] = useState<any>(null); const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -101,6 +92,18 @@ export default function TiktokOverviewPage() {
const [dateFrom, setDateFrom] = useState(''); const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState(''); const [dateTo, setDateTo] = useState('');
const PRESETS: { key: Preset; label: string }[] = [
{ key: '7d', label: t('analytics.preset7d', '7 วัน') },
{ key: '30d', label: t('analytics.preset30d', '30 วัน') },
{ key: '90d', label: t('analytics.preset90d', '90 วัน') },
{ key: 'thisMonth', label: t('analytics.presetThisMonth', 'เดือนนี้') },
{ key: 'lastMonth', label: t('analytics.presetLastMonth', 'เดือนที่แล้ว') },
{ key: 'year', label: t('analytics.presetThisYear', 'ปีนี้') },
{ key: 'lastYear', label: t('analytics.presetLastYear', 'ปีที่แล้ว') },
{ key: 'last12m', label: t('analytics.presetLast12m', '12 เดือนที่ผ่านมา') },
{ key: 'all', label: t('analytics.presetAll', 'ทั้งหมด') },
];
const loadData = (from: string, to: string) => { const loadData = (from: string, to: string) => {
setLoading(true); setError(null); setData(null); setLoading(true); setError(null); setData(null);
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -132,7 +135,7 @@ export default function TiktokOverviewPage() {
return ( return (
<div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse"> <div className="flex items-center justify-center h-[60vh] gap-3 animate-pulse">
<Loader2 className="w-8 h-8 text-rose-400 animate-spin" /> <Loader2 className="w-8 h-8 text-rose-400 animate-spin" />
<span className="text-slate-500">กำลังรวมข้อมูลทุกร้าน...</span> <span className="text-slate-500">{t('tiktokOverview.loading')}</span>
</div> </div>
); );
} }
@@ -141,9 +144,9 @@ export default function TiktokOverviewPage() {
return ( return (
<div className="flex flex-col items-center justify-center h-[60vh] gap-4"> <div className="flex flex-col items-center justify-center h-[60vh] gap-4">
<Store className="w-14 h-14 text-slate-700" /> <Store className="w-14 h-14 text-slate-700" />
<p className="text-slate-500">{error || 'ยังไม่มีข้อมูล — กรุณา Import ไฟล์ก่อน'}</p> <p className="text-slate-500">{error || t('tiktokOverview.noData')}</p>
<Link to="/tiktok-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm"> <Link to="/tiktok-order" className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-slate-300 text-sm">
← กลับไปหน้า TikTok Orders ← {t('tiktokOverview.backToOrders')}
</Link> </Link>
</div> </div>
); );
@@ -165,7 +168,7 @@ export default function TiktokOverviewPage() {
</div> </div>
<div> <div>
<p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">TikTok</p> <p className="text-xs text-slate-500 uppercase tracking-widest mb-0.5">TikTok</p>
<h1 className="text-3xl font-bold text-white tracking-tight">ภาพรวมทุกร้าน</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('tiktokOverview.title')}</h1>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -206,21 +209,21 @@ export default function TiktokOverviewPage() {
{/* Custom date range */} {/* Custom date range */}
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-slate-500">กำหนดเอง:</span> <span className="text-xs text-slate-500">{t('analytics.customRange', 'กำหนดเอง:')}</span>
<input <input
type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)} type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-rose-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-rose-500/40 focus:outline-none"
/> />
<span className="text-slate-600 text-xs">ถึง</span> <span className="text-slate-600 text-xs">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" value={dateTo} onChange={e => setDateTo(e.target.value)} type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-rose-500/40 focus:outline-none" className="px-3 py-1.5 rounded-lg bg-black/30 border border-white/10 text-slate-300 text-xs focus:border-rose-500/40 focus:outline-none"
/> />
<button <button
onClick={applyCustom} onClick={applyCustom}
className="px-4 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-medium hover:bg-rose-500/20 transition-all" className="px-4 py-1.5 rounded-lg bg-rose-500/10 border border-rose-500/20 text-rose-400 text-xs font-medium hover:bg-rose-500/20 transition-all cursor-pointer"
> >
ดูข้อมูล {t('analytics.apply', 'ดูข้อมูล')}
</button> </button>
</div> </div>
@@ -232,9 +235,9 @@ export default function TiktokOverviewPage() {
</span> </span>
<button <button
onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }} onClick={() => { setDateFrom(''); setDateTo(''); setPreset('all'); loadData('', ''); }}
className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors" className="text-[10px] text-slate-500 hover:text-rose-400 transition-colors cursor-pointer"
> >
✕ ล้าง ✕ {t('analytics.clear', 'ล้าง')}
</button> </button>
</div> </div>
)} )}
@@ -242,10 +245,10 @@ export default function TiktokOverviewPage() {
{/* Grand KPI Row */} {/* Grand KPI Row */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<KPICard label="ยอดขายรวมทุกร้าน" value={fmtB(summary.totalRevenue)} sub={`${fmt(summary.totalOrders)} คำสั่งซื้อ`} color="text-emerald-400" icon={DollarSign} /> <KPICard label={t('tiktokOverview.grandRevenue')} value={fmtB(summary.totalRevenue)} sub={t('tiktokOverview.ordersCount', { count: fmt(summary.totalOrders) })} color="text-emerald-400" icon={DollarSign} />
<KPICard label="รายได้สุทธิรวม" value={fmtB(summary.netRevenue)} sub={`Margin ${fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1)}%`} color="text-cyan-glow" icon={TrendingUp} /> <KPICard label={t('tiktokOverview.grandNetRevenue')} value={fmtB(summary.netRevenue)} sub={t('tiktokOverview.marginSub', { margin: fmt(summary.totalRevenue > 0 ? (summary.netRevenue / summary.totalRevenue) * 100 : 0, 1) })} color="text-cyan-glow" icon={TrendingUp} />
<KPICard label="ส่วนลด Platform รวม" value={fmtB(summary.totalPlatformDisc)} sub="TikTok อุดหนุนส่วนลด" color="text-purple-400" icon={BadgePercent} /> <KPICard label={t('tiktokOverview.grandPlatformDiscount')} value={fmtB(summary.totalPlatformDisc)} sub={t('tiktokOverview.platformDiscountSub')} color="text-purple-400" icon={BadgePercent} />
<KPICard label="AOV รวม" value={fmtB(summary.aov)} sub={`ยกเลิก ${fmt(summary.cancelRate, 1)}%`} color="text-amber-400" icon={ShoppingCart} /> <KPICard label={t('tiktokOverview.grandAov')} value={fmtB(summary.aov)} sub={t('tiktokOverview.cancelRateSub', { rate: fmt(summary.cancelRate, 1) })} color="text-amber-400" icon={ShoppingCart} />
</div> </div>
{/* Per-Shop Cards */} {/* Per-Shop Cards */}
@@ -266,19 +269,19 @@ export default function TiktokOverviewPage() {
<p className="text-[10px] font-mono text-slate-500">{s.shop}</p> <p className="text-[10px] font-mono text-slate-500">{s.shop}</p>
</div> </div>
<span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}> <span className="text-xs font-bold px-2 py-1 rounded-lg" style={{ backgroundColor: `${hex}20`, color: hex }}>
{fmt(revShare, 1)}% of total {t('tiktokOverview.percentOfTotal', { pct: fmt(revShare, 1) })}
</span> </span>
</div> </div>
{/* Stats grid */} {/* Stats grid */}
<div className="grid grid-cols-2 gap-3 text-xs"> <div className="grid grid-cols-2 gap-3 text-xs">
{[ {[
{ label: 'ยอดขาย', value: fmtB(s.revenue), color: 'text-emerald-400' }, { label: t('tiktokOverview.statSales'), value: fmtB(s.revenue), color: 'text-emerald-400' },
{ label: 'ส่วนลด TikTok', value: fmtB(s.platformDiscount), color: 'text-purple-400' }, { label: t('tiktokOverview.statPlatformDiscount'), value: fmtB(s.platformDiscount), color: 'text-purple-400' },
{ label: 'คำสั่งซื้อ', value: fmt(s.orders), color: 'text-white' }, { label: t('tiktokOverview.statOrders'), value: fmt(s.orders), color: 'text-white' },
{ label: 'ส่วนลดร้าน', value: fmtB(s.sellerDiscount), color: 'text-rose-400' }, { label: t('tiktokOverview.statSellerDiscount'), value: fmtB(s.sellerDiscount), color: 'text-rose-400' },
{ label: 'ยกเลิก', value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' }, { label: t('tiktokOverview.statCancelled'), value: `${fmt(s.orders > 0 ? (s.cancelled / s.orders) * 100 : 0, 1)}%`, color: 'text-amber-400' },
{ label: 'ค่าส่งรวม', value: fmtB(s.shippingFee), color: 'text-slate-300' }, { label: t('tiktokOverview.statShipping'), value: fmtB(s.shippingFee), color: 'text-slate-300' },
].map(item => ( ].map(item => (
<div key={item.label}> <div key={item.label}>
<p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p> <p className="text-slate-500 text-[10px] uppercase tracking-wider">{item.label}</p>
@@ -299,7 +302,7 @@ export default function TiktokOverviewPage() {
className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90" className="flex items-center justify-center gap-1.5 w-full py-2 rounded-xl text-xs font-medium border transition-all hover:opacity-90"
style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }} style={{ borderColor: `${hex}30`, backgroundColor: `${hex}10`, color: hex }}
> >
<TrendingUp className="w-3.5 h-3.5" /> ดู Analytics <TrendingUp className="w-3.5 h-3.5" /> {t('tiktokOverview.viewAnalytics')}
</Link> </Link>
</div> </div>
); );
@@ -310,7 +313,7 @@ export default function TiktokOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<TrendingUp className="w-5 h-5 text-rose-400" /> <TrendingUp className="w-5 h-5 text-rose-400" />
<h2 className="text-lg font-bold text-white">ยอดขายรายเดือน (แยกตามร้าน)</h2> <h2 className="text-lg font-bold text-white">{t('tiktokOverview.monthlySalesTitle')}</h2>
</div> </div>
{monthly.length > 0 ? ( {monthly.length > 0 ? (
<div className="h-[320px]"> <div className="h-[320px]">
@@ -328,7 +331,7 @@ export default function TiktokOverviewPage() {
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<p className="text-slate-500 text-center py-10">ยังไม่มีข้อมูลรายเดือน</p> <p className="text-slate-500 text-center py-10">{t('tiktokOverview.noMonthlyData')}</p>
)} )}
</div> </div>
@@ -339,7 +342,7 @@ export default function TiktokOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<BadgePercent className="w-5 h-5 text-amber-400" /> <BadgePercent className="w-5 h-5 text-amber-400" />
<h2 className="text-lg font-bold text-white">สัดส่วนยอดขายตามร้าน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokOverview.salesShareTitle')}</h2>
</div> </div>
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
<div className="h-[220px] flex-1"> <div className="h-[220px] flex-1">
@@ -371,17 +374,17 @@ export default function TiktokOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Store className="w-5 h-5 text-slate-400" /> <Store className="w-5 h-5 text-slate-400" />
<h2 className="text-lg font-bold text-white">เปรียบเทียบร้าน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokOverview.shopComparisonTitle')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="text-slate-500 uppercase border-b border-white/10"> <thead className="text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left">ร้าน</th> <th className="pb-3 text-left">{t('tiktokOverview.thShop')}</th>
<th className="pb-3 text-right">ยอดขาย</th> <th className="pb-3 text-right">{t('tiktokOverview.thRevenue')}</th>
<th className="pb-3 text-right">ส่วนลดแพลตฟอร์ม</th> <th className="pb-3 text-right">{t('tiktokOverview.thPlatformDiscount')}</th>
<th className="pb-3 text-right">ออเดอร์</th> <th className="pb-3 text-right">{t('tiktokOverview.thOrders')}</th>
<th className="pb-3 text-right">ยกเลิก</th> <th className="pb-3 text-right">{t('tiktokOverview.thCancelled')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -401,7 +404,7 @@ export default function TiktokOverviewPage() {
))} ))}
{/* Total row */} {/* Total row */}
<tr className="bg-white/[0.03]"> <tr className="bg-white/[0.03]">
<td className="py-3 font-bold text-white text-xs">รวมทั้งหมด</td> <td className="py-3 font-bold text-white text-xs">{t('tiktokOverview.totalAll')}</td>
<td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(summary.totalRevenue)}</td> <td className="py-3 text-right font-mono font-bold text-emerald-400">{fmtB(summary.totalRevenue)}</td>
<td className="py-3 text-right font-mono font-bold text-purple-400">{fmtB(summary.totalPlatformDisc)}</td> <td className="py-3 text-right font-mono font-bold text-purple-400">{fmtB(summary.totalPlatformDisc)}</td>
<td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(summary.totalOrders)}</td> <td className="py-3 text-right font-mono font-bold text-slate-300">{fmt(summary.totalOrders)}</td>
@@ -417,18 +420,18 @@ export default function TiktokOverviewPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-6"> <div className="bg-white/5 border border-white/10 rounded-2xl p-6">
<div className="flex items-center gap-2 mb-6"> <div className="flex items-center gap-2 mb-6">
<Package className="w-5 h-5 text-purple-400" /> <Package className="w-5 h-5 text-purple-400" />
<h2 className="text-lg font-bold text-white">Top 10 สินค้าขายดี — รวมทุกร้าน</h2> <h2 className="text-lg font-bold text-white">{t('tiktokOverview.topProductsTitle')}</h2>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="text-xs text-slate-500 uppercase border-b border-white/10"> <thead className="text-xs text-slate-500 uppercase border-b border-white/10">
<tr> <tr>
<th className="pb-3 text-left w-8">#</th> <th className="pb-3 text-left w-8">#</th>
<th className="pb-3 text-left">สินค้า</th> <th className="pb-3 text-left">{t('tiktokOverview.thProduct')}</th>
<th className="pb-3 text-left">ตัวเลือก</th> <th className="pb-3 text-left">{t('tiktokOverview.thVariation')}</th>
<th className="pb-3 text-right">ชิ้น</th> <th className="pb-3 text-right">{t('tiktokOverview.thUnits')}</th>
<th className="pb-3 text-right">ยอดรายได้</th> <th className="pb-3 text-right">{t('tiktokOverview.thProductRevenue')}</th>
<th className="pb-3 text-right">% รวม</th> <th className="pb-3 text-right">{t('tiktokOverview.thPercentTotal')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
+155 -153
View File
@@ -16,6 +16,7 @@ import {
Gift, Gift,
RefreshCw, RefreshCw,
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next'
// ─── Utility ─────────────────────────────────────────────────────────────────── // ─── Utility ───────────────────────────────────────────────────────────────────
@@ -76,7 +77,7 @@ function SectionCard({
<div className="admin-card admin-card-hover mb-6 animate-fade-in"> <div className="admin-card admin-card-hover mb-6 animate-fade-in">
<button <button
onClick={() => setOpen(v => !v)} onClick={() => setOpen(v => !v)}
className="w-full flex items-start gap-4 text-left group" className="w-full flex items-start gap-4 text-left group cursor-pointer"
> >
<div className={`mt-0.5 ${accent.split(' ')[0]}`}>{icon}</div> <div className={`mt-0.5 ${accent.split(' ')[0]}`}>{icon}</div>
<div className="flex-1"> <div className="flex-1">
@@ -189,7 +190,7 @@ function ResultBadge({
{sub && <p className="text-xs text-slate-400 mt-1">{sub}</p>} {sub && <p className="text-xs text-slate-400 mt-1">{sub}</p>}
{formula && ( {formula && (
<div className="mt-3 pt-3 border-t border-white/5"> <div className="mt-3 pt-3 border-t border-white/5">
<p className="text-[10px] text-slate-500 font-mono leading-tight">{formula}</p> <div className="text-[10px] text-slate-500 font-mono leading-tight">{formula}</div>
</div> </div>
)} )}
</div> </div>
@@ -199,6 +200,7 @@ function ResultBadge({
// ─── Section 1: Price Buffer Calculator ──────────────────────────────────────── // ─── Section 1: Price Buffer Calculator ────────────────────────────────────────
function PriceBufferCalculator() { function PriceBufferCalculator() {
const { t } = useTranslation()
const [cost, setCost] = useState(100) const [cost, setCost] = useState(100)
const [commissionPct, setCommissionPct] = useState(8) const [commissionPct, setCommissionPct] = useState(8)
const [shipping, setShipping] = useState(30) const [shipping, setShipping] = useState(30)
@@ -211,11 +213,8 @@ function PriceBufferCalculator() {
const results = useMemo(() => { const results = useMemo(() => {
const shippingCost = sellerPaysShipping ? shipping : 0 const shippingCost = sellerPaysShipping ? shipping : 0
// Break-even: ราคาที่ต่ำสุดที่ไม่ขาดทุน
// breakEven = (cost + shippingCost) / (1 - commissionPct/100)
const breakEven = (cost + shippingCost) / (1 - commissionPct / 100) const breakEven = (cost + shippingCost) / (1 - commissionPct / 100)
// ราคาขายปกติที่ได้กำไรตาม target
let sellingPrice = 0 let sellingPrice = 0
if (targetProfitType === 'pct') { if (targetProfitType === 'pct') {
sellingPrice = breakEven / (1 - targetProfitPct / 100) sellingPrice = breakEven / (1 - targetProfitPct / 100)
@@ -223,11 +222,8 @@ function PriceBufferCalculator() {
sellingPrice = (cost + shippingCost + targetProfitAmount) / (1 - commissionPct / 100) sellingPrice = (cost + shippingCost + targetProfitAmount) / (1 - commissionPct / 100)
} }
// List Price เผื่อ Campaign discount เพิ่ม const listPrice = (breakEven / (1 - (campaignDiscountPct + platformVoucherPct) / 100)) * 1.05
// เราต้องการให้เมื่อลด campaignDiscountPct จาก List แล้ว ยังอยู่เหนือ breakEven
const listPrice = breakEven / (1 - (campaignDiscountPct + platformVoucherPct) / 100) * 1.05
// กำไรต่อชิ้นแต่ละระดับ
const profitNormal = sellingPrice * (1 - commissionPct / 100) - cost - shippingCost const profitNormal = sellingPrice * (1 - commissionPct / 100) - cost - shippingCost
const campaignPrice = listPrice * (1 - campaignDiscountPct / 100) const campaignPrice = listPrice * (1 - campaignDiscountPct / 100)
const profitCampaign = campaignPrice * (1 - commissionPct / 100) - cost - shippingCost const profitCampaign = campaignPrice * (1 - commissionPct / 100) - cost - shippingCost
@@ -253,15 +249,15 @@ function PriceBufferCalculator() {
<div className="space-y-6"> <div className="space-y-6">
{/* Inputs */} {/* Inputs */}
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<InputRow label="ต้นทุนสินค้า (Cost)" hint="ราคาซื้อต้นทุนต่อชิ้น"> <InputRow label={t('tiktokPriceSimulator.costLabel')} hint={t('tiktokPriceSimulator.costHint')}>
<NumInput value={cost} onChange={setCost} min={0} step={10} prefix="฿" /> <NumInput value={cost} onChange={setCost} min={0} step={10} prefix="฿" />
</InputRow> </InputRow>
<InputRow label="ค่าคอมมิชชัน TikTok" hint="ปกติ 8–12% ขึ้นอยู่กับหมวดหมู่"> <InputRow label={t('tiktokPriceSimulator.commLabel')} hint={t('tiktokPriceSimulator.commHint')}>
<NumInput value={commissionPct} onChange={setCommissionPct} min={0} max={30} step={0.5} suffix="%" /> <NumInput value={commissionPct} onChange={setCommissionPct} min={0} max={30} step={0.5} suffix="%" />
</InputRow> </InputRow>
<InputRow label="ค่าจัดส่ง" hint="ต่อออเดอร์ (ถ้าผู้ขายออกเอง)"> <InputRow label={t('tiktokPriceSimulator.shippingLabel')} hint={t('tiktokPriceSimulator.shippingHint')}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<NumInput value={shipping} onChange={setShipping} min={0} prefix="฿" /> <NumInput value={shipping} onChange={setShipping} min={0} prefix="฿" />
<label className="flex items-center gap-2 cursor-pointer shrink-0"> <label className="flex items-center gap-2 cursor-pointer shrink-0">
@@ -269,26 +265,29 @@ function PriceBufferCalculator() {
type="checkbox" type="checkbox"
checked={sellerPaysShipping} checked={sellerPaysShipping}
onChange={e => setSellerPaysShipping(e.target.checked)} onChange={e => setSellerPaysShipping(e.target.checked)}
className="accent-cyan-400 w-4 h-4" className="accent-cyan-400 w-4 h-4 cursor-pointer"
/> />
<span className="text-xs text-slate-400">ผู้ขายออก</span> <span className="text-xs text-slate-400">{t('tiktokPriceSimulator.sellerPays')}</span>
</label> </label>
</div> </div>
</InputRow> </InputRow>
<InputRow label="กำไรที่ต้องการ" hint={targetProfitType === 'pct' ? "% กำไรจากราคาขายปกติ" : "กำไรสุทธิต่อชิ้น (บาท)"}> <InputRow
label={t('tiktokPriceSimulator.targetProfitLabel')}
hint={targetProfitType === 'pct' ? t('tiktokPriceSimulator.targetProfitPctHint') : t('tiktokPriceSimulator.targetProfitAmountHint')}
>
<div className="flex items-center gap-3 w-full"> <div className="flex items-center gap-3 w-full">
<div className="flex p-1 bg-night-900 border border-white/10 rounded-lg shrink-0"> <div className="flex p-1 bg-night-900 border border-white/10 rounded-lg shrink-0">
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${targetProfitType === 'pct' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer ${targetProfitType === 'pct' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => setTargetProfitType('pct')} onClick={() => setTargetProfitType('pct')}
> >
% %
</button> </button>
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${targetProfitType === 'amount' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer ${targetProfitType === 'amount' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => setTargetProfitType('amount')} onClick={() => setTargetProfitType('amount')}
> >
฿ ฿
@@ -304,45 +303,45 @@ function PriceBufferCalculator() {
</div> </div>
</InputRow> </InputRow>
<InputRow label="ส่วนลด Campaign สูงสุด" hint="% ที่ TikTok อาจบังคับลด (เช่น 9.9 Mega Sale)"> <InputRow label={t('tiktokPriceSimulator.campaignDiscLabel')} hint={t('tiktokPriceSimulator.campaignDiscHint')}>
<NumInput value={campaignDiscountPct} onChange={setCampaignDiscountPct} min={0} max={70} suffix="%" /> <NumInput value={campaignDiscountPct} onChange={setCampaignDiscountPct} min={0} max={70} suffix="%" />
</InputRow> </InputRow>
<InputRow label="Platform Voucher (TikTok ออก)" hint="% ที่ TikTok สนับสนุน — ผู้ขายไม่ต้องออก"> <InputRow label={t('tiktokPriceSimulator.platformVoucherLabel')} hint={t('tiktokPriceSimulator.platformVoucherHint')}>
<NumInput value={platformVoucherPct} onChange={setPlatformVoucherPct} min={0} max={30} suffix="%" /> <NumInput value={platformVoucherPct} onChange={setPlatformVoucherPct} min={0} max={30} suffix="%" />
</InputRow> </InputRow>
</div> </div>
{/* Results */} {/* Results */}
<div className="pt-2"> <div className="pt-2">
<p className="text-xs text-slate-500 uppercase tracking-wider mb-3">ผลลัพธ์การคำนวณ</p> <p className="text-xs text-slate-500 uppercase tracking-wider mb-3">{t('tiktokPriceSimulator.calcResults')}</p>
<div className="grid grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-2 lg:grid-cols-3 gap-3">
<ResultBadge <ResultBadge
label="ราคาตั้ง (List Price)" label={t('tiktokPriceSimulator.listPriceLabel')}
value={fmt(results.listPrice)} value={fmt(results.listPrice)}
sub="ราคาที่ลงในระบบ TikTok" sub={t('tiktokPriceSimulator.listPriceSub')}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
<div className="font-bold text-slate-400">[จุดเท่าทุน] ÷ (1 - แคมเปญ% - คูปอง TikTok%) × เผื่อ 5%</div> <div className="font-bold text-slate-400">{t('tiktokPriceSimulator.listPriceFormula')}</div>
<div className="text-slate-500">= {fmt(results.breakEven)} ÷ (1 - {campaignDiscountPct}% - {platformVoucherPct}%) × 1.05</div> <div className="text-slate-500">= {fmt(results.breakEven)} ÷ (1 - {campaignDiscountPct}% - {platformVoucherPct}%) × 1.05</div>
</div> </div>
)} )}
highlight highlight
/> />
<ResultBadge <ResultBadge
label="ราคาขายปกติ" label={t('tiktokPriceSimulator.sellingPriceLabel')}
value={fmt(results.sellingPrice)} value={fmt(results.sellingPrice)}
sub={`กำไร ${pct(results.marginNormal)} / ชิ้น ${fmt(results.profitNormal)}`} sub={t('tiktokPriceSimulator.profitMarginSub', { margin: pct(results.marginNormal), profit: fmt(results.profitNormal) })}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
{targetProfitType === 'pct' ? ( {targetProfitType === 'pct' ? (
<> <>
<div className="font-bold text-slate-400">[จุดเท่าทุน] ÷ (1 - กำไรที่ต้องการ%)</div> <div className="font-bold text-slate-400">[Break-even] ÷ (1 - Profit%)</div>
<div className="text-slate-500">= {fmt(results.breakEven)} ÷ (1 - {targetProfitPct}%)</div> <div className="text-slate-500">= {fmt(results.breakEven)} ÷ (1 - {targetProfitPct}%)</div>
</> </>
) : ( ) : (
<> <>
<div className="font-bold text-slate-400">(ต้นทุน + ค่าส่ง + กำไรบาท) ÷ (1 - ค่าคอม%)</div> <div className="font-bold text-slate-400">(Cost + Shipping + Profit) ÷ (1 - Comm%)</div>
<div className="text-slate-500">= ({fmt(cost)} + {fmt(sellerPaysShipping ? shipping : 0)} + {fmt(targetProfitAmount)}) ÷ (1 - {commissionPct}%)</div> <div className="text-slate-500">= ({fmt(cost)} + {fmt(sellerPaysShipping ? shipping : 0)} + {fmt(targetProfitAmount)}) ÷ (1 - {commissionPct}%)</div>
</> </>
)} )}
@@ -350,12 +349,12 @@ function PriceBufferCalculator() {
)} )}
/> />
<ResultBadge <ResultBadge
label="ราคาแคมเปญ" label={t('tiktokPriceSimulator.campaignPriceLabel')}
value={fmt(results.campaignPrice)} value={fmt(results.campaignPrice)}
sub={`กำไร ${pct(results.marginCampaign)} / ชิ้น ${fmt(results.profitCampaign)}`} sub={t('tiktokPriceSimulator.profitMarginSub', { margin: pct(results.marginCampaign), profit: fmt(results.profitCampaign) })}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
<div className="font-bold text-slate-400">[ราคาตั้ง] - ส่วนลดแคมเปญ%</div> <div className="font-bold text-slate-400">[List Price] - Campaign%</div>
<div className="text-slate-500">= {fmt(results.listPrice)} - {campaignDiscountPct}%</div> <div className="text-slate-500">= {fmt(results.listPrice)} - {campaignDiscountPct}%</div>
</div> </div>
)} )}
@@ -363,24 +362,24 @@ function PriceBufferCalculator() {
danger={results.profitCampaign < 0} danger={results.profitCampaign < 0}
/> />
<ResultBadge <ResultBadge
label="จุด Break-even (ราคาต่ำสุด)" label={t('tiktokPriceSimulator.breakEvenLabel')}
value={fmt(results.breakEven)} value={fmt(results.breakEven)}
sub="ขายต่ำกว่านี้ = ขาดทุน" sub={t('tiktokPriceSimulator.breakEvenSub')}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
<div className="font-bold text-slate-400">(ต้นทุน + ค่าส่ง) ÷ (1 - ค่าคอม%)</div> <div className="font-bold text-slate-400">(Cost + Shipping) ÷ (1 - Comm%)</div>
<div className="text-slate-500">= ({fmt(cost)} + {fmt(sellerPaysShipping ? shipping : 0)}) ÷ (1 - {commissionPct}%)</div> <div className="text-slate-500">= ({fmt(cost)} + {fmt(sellerPaysShipping ? shipping : 0)}) ÷ (1 - {commissionPct}%)</div>
</div> </div>
)} )}
danger danger
/> />
<ResultBadge <ResultBadge
label="กำไรปกติ/ชิ้น" label={t('tiktokPriceSimulator.normalProfitLabel')}
value={fmt(results.profitNormal)} value={fmt(results.profitNormal)}
sub={pct(results.marginNormal)} sub={pct(results.marginNormal)}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
<div className="font-bold text-slate-400">ราคาขาย - ค่าคอม - ต้นทุน - ค่าส่ง</div> <div className="font-bold text-slate-400">Price - Comm - Cost - Shipping</div>
<div className="text-slate-500">= {fmt(results.sellingPrice)} - {fmt(results.sellingPrice * commissionPct / 100)} - {fmt(cost)} - {fmt(sellerPaysShipping ? shipping : 0)}</div> <div className="text-slate-500">= {fmt(results.sellingPrice)} - {fmt(results.sellingPrice * commissionPct / 100)} - {fmt(cost)} - {fmt(sellerPaysShipping ? shipping : 0)}</div>
</div> </div>
)} )}
@@ -388,12 +387,12 @@ function PriceBufferCalculator() {
danger={results.profitNormal <= 0} danger={results.profitNormal <= 0}
/> />
<ResultBadge <ResultBadge
label="กำไร Campaign/ชิ้น" label={t('tiktokPriceSimulator.campaignProfitLabel')}
value={fmt(results.profitCampaign)} value={fmt(results.profitCampaign)}
sub={pct(results.marginCampaign)} sub={pct(results.marginCampaign)}
formula={( formula={(
<div className="space-y-1"> <div className="space-y-1">
<div className="font-bold text-slate-400">ราคาแคมเปญ - ค่าคอม - ต้นทุน - ค่าส่ง</div> <div className="font-bold text-slate-400">Camp. Price - Comm - Cost - Shipping</div>
<div className="text-slate-500">= {fmt(results.campaignPrice)} - {fmt(results.campaignPrice * commissionPct / 100)} - {fmt(cost)} - {fmt(sellerPaysShipping ? shipping : 0)}</div> <div className="text-slate-500">= {fmt(results.campaignPrice)} - {fmt(results.campaignPrice * commissionPct / 100)} - {fmt(cost)} - {fmt(sellerPaysShipping ? shipping : 0)}</div>
</div> </div>
)} )}
@@ -407,11 +406,11 @@ function PriceBufferCalculator() {
{/* Formula hint */} {/* Formula hint */}
<div className="rounded-xl bg-night-800/50 border border-white/5 p-4 text-xs text-slate-500 space-y-1"> <div className="rounded-xl bg-night-800/50 border border-white/5 p-4 text-xs text-slate-500 space-y-1">
<p className="text-slate-400 font-medium mb-2 flex items-center gap-1.5"> <p className="text-slate-400 font-medium mb-2 flex items-center gap-1.5">
<Info className="w-3.5 h-3.5" /> สูตรที่ใช้คำนวณ <Info className="w-3.5 h-3.5" /> {t('tiktokPriceSimulator.formulaHelpTitle')}
</p> </p>
<p>Break-even = (ต้นทุน + ค่าส่ง) ÷ (1 − ค่าคอม%)</p> <p>{t('tiktokPriceSimulator.formulaBreakEven')}</p>
<p>ราคาขาย = Break-even ÷ (1 − กำไร%)</p> <p>{t('tiktokPriceSimulator.formulaSelling')}</p>
<p>List Price = Break-even ÷ (1 − Campaign% − Platform%) × 1.05 (safety buffer 5%)</p> <p>{t('tiktokPriceSimulator.formulaList')}</p>
</div> </div>
</div> </div>
) )
@@ -420,6 +419,7 @@ function PriceBufferCalculator() {
// ─── Section 2: Volume vs Margin Simulator ───────────────────────────────────── // ─── Section 2: Volume vs Margin Simulator ─────────────────────────────────────
function VolumeMarginSimulator() { function VolumeMarginSimulator() {
const { t } = useTranslation()
const [normalQty, setNormalQty] = useState(10) const [normalQty, setNormalQty] = useState(10)
const [normalProfit, setNormalProfit] = useState(50) const [normalProfit, setNormalProfit] = useState(50)
const [campaignQty, setCampaignQty] = useState(100) const [campaignQty, setCampaignQty] = useState(100)
@@ -439,16 +439,16 @@ function VolumeMarginSimulator() {
<div className="rounded-xl bg-night-800/50 border border-white/10 p-5 space-y-4"> <div className="rounded-xl bg-night-800/50 border border-white/10 p-5 space-y-4">
<p className="text-sm font-bold text-white flex items-center gap-2"> <p className="text-sm font-bold text-white flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-slate-500 inline-block" /> <span className="w-2 h-2 rounded-full bg-slate-500 inline-block" />
วันขายปกติ {t('tiktokPriceSimulator.normalDay')}
</p> </p>
<InputRow label="จำนวนชิ้น"> <InputRow label={t('tiktokPriceSimulator.unitQty')}>
<NumInput value={normalQty} onChange={setNormalQty} min={0} suffix="ชิ้น" /> <NumInput value={normalQty} onChange={setNormalQty} min={0} suffix={t('tiktokPriceSimulator.units')} />
</InputRow> </InputRow>
<InputRow label="กำไรต่อชิ้น"> <InputRow label={t('tiktokPriceSimulator.profitPerUnit')}>
<NumInput value={normalProfit} onChange={setNormalProfit} min={0} step={5} prefix="฿" /> <NumInput value={normalProfit} onChange={setNormalProfit} min={0} step={5} prefix="฿" />
</InputRow> </InputRow>
<div className="rounded-lg bg-night-900/60 border border-white/5 p-3 text-center"> <div className="rounded-lg bg-night-900/60 border border-white/5 p-3 text-center">
<p className="text-xs text-slate-500">กำไรรวม</p> <p className="text-xs text-slate-500">{t('tiktokPriceSimulator.totalProfit')}</p>
<p className="text-2xl font-bold text-white mt-1">{fmt(normalTotal)}</p> <p className="text-2xl font-bold text-white mt-1">{fmt(normalTotal)}</p>
</div> </div>
</div> </div>
@@ -457,16 +457,16 @@ function VolumeMarginSimulator() {
<div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/20 p-5 space-y-4"> <div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/20 p-5 space-y-4">
<p className="text-sm font-bold text-cyan-glow flex items-center gap-2"> <p className="text-sm font-bold text-cyan-glow flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-cyan-glow inline-block animate-pulse" /> <span className="w-2 h-2 rounded-full bg-cyan-glow inline-block animate-pulse" />
วัน Campaign / Mega Sale {t('tiktokPriceSimulator.campaignDay')}
</p> </p>
<InputRow label="จำนวนชิ้นคาดการณ์"> <InputRow label={t('tiktokPriceSimulator.campaignForecastQty')}>
<NumInput value={campaignQty} onChange={setCampaignQty} min={0} suffix="ชิ้น" /> <NumInput value={campaignQty} onChange={setCampaignQty} min={0} suffix={t('tiktokPriceSimulator.units')} />
</InputRow> </InputRow>
<InputRow label="กำไรต่อชิ้น (หลังลด)"> <InputRow label={t('tiktokPriceSimulator.campaignProfitPerUnit')}>
<NumInput value={campaignProfit} onChange={setCampaignProfit} min={-9999} step={5} prefix="฿" /> <NumInput value={campaignProfit} onChange={setCampaignProfit} min={-9999} step={5} prefix="฿" />
</InputRow> </InputRow>
<div className={`rounded-lg p-3 text-center ${campaignTotal > 0 ? 'bg-cyan-glow/10 border border-cyan-glow/30' : 'bg-rose-500/10 border border-rose-500/30'}`}> <div className={`rounded-lg p-3 text-center ${campaignTotal > 0 ? 'bg-cyan-glow/10 border border-cyan-glow/30' : 'bg-rose-500/10 border border-rose-500/30'}`}>
<p className="text-xs text-slate-500">กำไรรวม</p> <p className="text-xs text-slate-500">{t('tiktokPriceSimulator.totalProfit')}</p>
<p className={`text-2xl font-bold mt-1 ${campaignTotal > 0 ? 'text-cyan-glow' : 'text-rose-400'}`}>{fmt(campaignTotal)}</p> <p className={`text-2xl font-bold mt-1 ${campaignTotal > 0 ? 'text-cyan-glow' : 'text-rose-400'}`}>{fmt(campaignTotal)}</p>
</div> </div>
</div> </div>
@@ -481,18 +481,18 @@ function VolumeMarginSimulator() {
)} )}
<div className="flex-1"> <div className="flex-1">
<p className={`font-bold ${shouldJoin ? 'text-green-400' : 'text-rose-400'}`}> <p className={`font-bold ${shouldJoin ? 'text-green-400' : 'text-rose-400'}`}>
{shouldJoin ? '✅ ควรเข้าร่วม Campaign' : '❌ ไม่แนะนำให้เข้า Campaign'} {shouldJoin ? t('tiktokPriceSimulator.shouldJoinCampaign') : t('tiktokPriceSimulator.shouldNotJoinCampaign')}
</p> </p>
<p className="text-sm text-slate-400 mt-1"> <p className="text-sm text-slate-400 mt-1">
{shouldJoin {shouldJoin
? `กำไรรวมเพิ่มขึ้น ${fmt(diff)} (${multiplier.toFixed(1)}× เทียบวันปกติ) — คุ้มค่าที่จะยอมลดกำไรต่อชิ้น` ? t('tiktokPriceSimulator.joinReasonProfitUp', { diff: fmt(diff), mult: multiplier.toFixed(1) })
: campaignProfit <= 0 : campaignProfit <= 0
? 'กำไรต่อชิ้นติดลบ → ขายดีจนเข้าเนื้อ ห้ามเข้าเด็ดขาด!' ? t('tiktokPriceSimulator.joinReasonNegative')
: `กำไรรวม Campaign (${fmt(campaignTotal)}) ต่ำกว่าวันปกติ (${fmt(normalTotal)}) — ไม่คุ้มค่า`} : t('tiktokPriceSimulator.joinReasonLower', { camp: fmt(campaignTotal), norm: fmt(normalTotal) })}
</p> </p>
</div> </div>
<div className="text-right shrink-0"> <div className="text-right shrink-0">
<p className="text-xs text-slate-500">เพิ่ม/ลด</p> <p className="text-xs text-slate-500">{t('tiktokPriceSimulator.diffLabel')}</p>
<p className={`text-lg font-bold ${diff >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <p className={`text-lg font-bold ${diff >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{diff >= 0 ? '+' : ''}{fmt(diff)} {diff >= 0 ? '+' : ''}{fmt(diff)}
</p> </p>
@@ -512,55 +512,18 @@ function classifyProduct(cost: number, listPrice: number): ProductGroup {
return 'bundle' return 'bundle'
} }
const GROUP_CONFIG: Record<
ProductGroup,
{ label: string; desc: string; icon: React.ReactNode; color: string; bg: string; border: string }
> = {
hero: {
label: '🚀 Hero (Traffic Driver)',
desc: 'ดึงคนเข้าร้าน — margin บาง แต่ยอดขายมหาศาล',
icon: <Zap className="w-5 h-5" />,
color: 'text-amber-400',
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
},
profit: {
label: '💎 Profit Maker',
desc: 'แหล่งกำไรหลัก — margin หนา ลดน้อย',
icon: <Star className="w-5 h-5" />,
color: 'text-cyan-glow',
bg: 'bg-cyan-glow/10',
border: 'border-cyan-glow/30',
},
bundle: {
label: '🎁 Bundle / Add-on',
desc: 'เพิ่ม AOV — จัดชุดกับสินค้าอื่น',
icon: <Gift className="w-5 h-5" />,
color: 'text-violet-400',
bg: 'bg-violet-500/10',
border: 'border-violet-500/30',
},
unclassified: {
label: '— ยังไม่ระบุ',
desc: 'กรอกข้อมูลให้ครบ',
icon: <Info className="w-5 h-5" />,
color: 'text-slate-500',
bg: 'bg-night-800/40',
border: 'border-white/10',
},
}
let nextId = 1 let nextId = 1
function ProductStrategyClassifier() { function ProductStrategyClassifier() {
const { t } = useTranslation()
const [products, setProducts] = useState<ProductRow[]>([ const [products, setProducts] = useState<ProductRow[]>([
{ id: nextId++, name: 'สินค้า A', cost: 80, listPrice: 129 }, { id: nextId++, name: 'Aroma Diffuser 100ml', cost: 80, listPrice: 129 },
{ id: nextId++, name: 'สินค้า B', cost: 120, listPrice: 299 }, { id: nextId++, name: 'Essential Oil Set', cost: 120, listPrice: 299 },
{ id: nextId++, name: 'สินค้า C', cost: 50, listPrice: 99 }, { id: nextId++, name: 'Reed Sticks Refill', cost: 50, listPrice: 99 },
]) ])
const addRow = () => { const addRow = () => {
setProducts(p => [...p, { id: nextId++, name: `สินค้า ${String.fromCharCode(65 + p.length)}`, cost: 0, listPrice: 0 }]) setProducts(p => [...p, { id: nextId++, name: `Product ${String.fromCharCode(65 + p.length)}`, cost: 0, listPrice: 0 }])
} }
const removeRow = (id: number) => setProducts(p => p.filter(r => r.id !== id)) const removeRow = (id: number) => setProducts(p => p.filter(r => r.id !== id))
@@ -569,6 +532,44 @@ function ProductStrategyClassifier() {
setProducts(p => p.map(r => r.id === id ? { ...r, [field]: value } : r)) setProducts(p => p.map(r => r.id === id ? { ...r, [field]: value } : r))
} }
const groupConfig: Record<
ProductGroup,
{ label: string; desc: string; icon: React.ReactNode; color: string; bg: string; border: string }
> = {
hero: {
label: t('tiktokPriceSimulator.groupHeroLabel'),
desc: t('tiktokPriceSimulator.groupHeroDesc'),
icon: <Zap className="w-5 h-5" />,
color: 'text-amber-400',
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
},
profit: {
label: t('tiktokPriceSimulator.groupProfitLabel'),
desc: t('tiktokPriceSimulator.groupProfitDesc'),
icon: <Star className="w-5 h-5" />,
color: 'text-cyan-glow',
bg: 'bg-cyan-glow/10',
border: 'border-cyan-glow/30',
},
bundle: {
label: t('tiktokPriceSimulator.groupBundleLabel'),
desc: t('tiktokPriceSimulator.groupBundleDesc'),
icon: <Gift className="w-5 h-5" />,
color: 'text-violet-400',
bg: 'bg-violet-500/10',
border: 'border-violet-500/30',
},
unclassified: {
label: t('tiktokPriceSimulator.groupUnclassifiedLabel'),
desc: t('tiktokPriceSimulator.groupUnclassifiedDesc'),
icon: <Info className="w-5 h-5" />,
color: 'text-slate-500',
bg: 'bg-night-800/40',
border: 'border-white/10',
},
}
const grouped = useMemo(() => { const grouped = useMemo(() => {
const groups: Record<ProductGroup, ProductRow[]> = { const groups: Record<ProductGroup, ProductRow[]> = {
hero: [], hero: [],
@@ -590,11 +591,11 @@ function ProductStrategyClassifier() {
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="text-left text-xs text-slate-500 uppercase tracking-wider border-b border-white/10"> <tr className="text-left text-xs text-slate-500 uppercase tracking-wider border-b border-white/10">
<th className="pb-3 pr-4 font-medium">ชื่อสินค้า</th> <th className="pb-3 pr-4 font-medium">{t('tiktokPriceSimulator.thProductName')}</th>
<th className="pb-3 pr-4 font-medium">ต้นทุน (฿)</th> <th className="pb-3 pr-4 font-medium">{t('tiktokPriceSimulator.thCost')}</th>
<th className="pb-3 pr-4 font-medium">List Price (฿)</th> <th className="pb-3 pr-4 font-medium">{t('tiktokPriceSimulator.thListPrice')}</th>
<th className="pb-3 pr-4 font-medium">Margin %</th> <th className="pb-3 pr-4 font-medium">{t('tiktokPriceSimulator.thMargin')}</th>
<th className="pb-3 font-medium">กลุ่ม</th> <th className="pb-3 font-medium">{t('tiktokPriceSimulator.thGroup')}</th>
<th className="pb-3" /> <th className="pb-3" />
</tr> </tr>
</thead> </thead>
@@ -602,7 +603,7 @@ function ProductStrategyClassifier() {
{products.map(p => { {products.map(p => {
const margin = p.listPrice > 0 ? ((p.listPrice - p.cost) / p.listPrice) * 100 : 0 const margin = p.listPrice > 0 ? ((p.listPrice - p.cost) / p.listPrice) * 100 : 0
const group = classifyProduct(p.cost, p.listPrice) const group = classifyProduct(p.cost, p.listPrice)
const cfg = GROUP_CONFIG[group] const cfg = groupConfig[group]
return ( return (
<tr key={p.id} className="group"> <tr key={p.id} className="group">
<td className="py-3 pr-4"> <td className="py-3 pr-4">
@@ -644,7 +645,7 @@ function ProductStrategyClassifier() {
<td className="py-3"> <td className="py-3">
<button <button
onClick={() => removeRow(p.id)} onClick={() => removeRow(p.id)}
className="p-1.5 rounded-lg text-slate-600 hover:text-rose-400 hover:bg-rose-500/10 transition-all opacity-0 group-hover:opacity-100" className="p-1.5 rounded-lg text-slate-600 hover:text-rose-400 hover:bg-rose-500/10 transition-all opacity-0 group-hover:opacity-100 cursor-pointer"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
@@ -656,14 +657,14 @@ function ProductStrategyClassifier() {
</table> </table>
</div> </div>
<button onClick={addRow} className="btn-ghost flex items-center gap-2 text-sm"> <button onClick={addRow} className="btn-ghost flex items-center gap-2 text-sm cursor-pointer">
<Plus className="w-4 h-4" /> เพิ่มสินค้า <Plus className="w-4 h-4" /> {t('tiktokPriceSimulator.addProduct')}
</button> </button>
{/* Grouped summary */} {/* Grouped summary */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(['hero', 'profit', 'bundle'] as ProductGroup[]).map(g => { {(['hero', 'profit', 'bundle'] as ProductGroup[]).map(g => {
const cfg = GROUP_CONFIG[g] const cfg = groupConfig[g]
const items = grouped[g] const items = grouped[g]
return ( return (
<div key={g} className={`rounded-xl border p-4 ${cfg.bg} ${cfg.border}`}> <div key={g} className={`rounded-xl border p-4 ${cfg.bg} ${cfg.border}`}>
@@ -673,7 +674,7 @@ function ProductStrategyClassifier() {
</div> </div>
<p className="text-xs text-slate-500 mb-3">{cfg.desc}</p> <p className="text-xs text-slate-500 mb-3">{cfg.desc}</p>
{items.length === 0 ? ( {items.length === 0 ? (
<p className="text-xs text-slate-600 italic">ยังไม่มีสินค้า</p> <p className="text-xs text-slate-600 italic">{t('tiktokPriceSimulator.noProductsInGroup')}</p>
) : ( ) : (
<ul className="space-y-1"> <ul className="space-y-1">
{items.map(p => ( {items.map(p => (
@@ -692,10 +693,10 @@ function ProductStrategyClassifier() {
</div> </div>
<div className="rounded-xl bg-night-800/50 border border-white/5 p-4 text-xs text-slate-500 space-y-1"> <div className="rounded-xl bg-night-800/50 border border-white/5 p-4 text-xs text-slate-500 space-y-1">
<p className="text-slate-400 font-medium mb-2 flex items-center gap-1.5"><Info className="w-3.5 h-3.5" /> เกณฑ์การจัดกลุ่ม</p> <p className="text-slate-400 font-medium mb-2 flex items-center gap-1.5"><Info className="w-3.5 h-3.5" /> {t('tiktokPriceSimulator.groupCriteriaTitle')}</p>
<p>🚀 Hero: Margin &lt; 15% — ตัดกำไรต่ำมากเพื่อดึง traffic</p> <p>{t('tiktokPriceSimulator.groupCriteriaHero')}</p>
<p>💎 Profit Maker: Margin ≥ 30% — แหล่งรายได้หลัก</p> <p>{t('tiktokPriceSimulator.groupCriteriaProfit')}</p>
<p>🎁 Bundle: Margin 15–29% — จัดชุดเพิ่ม AOV</p> <p>{t('tiktokPriceSimulator.groupCriteriaBundle')}</p>
</div> </div>
</div> </div>
) )
@@ -704,6 +705,7 @@ function ProductStrategyClassifier() {
// ─── Section 4: Break-even Alert ─────────────────────────────────────────────── // ─── Section 4: Break-even Alert ───────────────────────────────────────────────
function BreakEvenAlert() { function BreakEvenAlert() {
const { t } = useTranslation()
const [cost, setCost] = useState(100) const [cost, setCost] = useState(100)
const [commissionPct, setCommissionPct] = useState(8) const [commissionPct, setCommissionPct] = useState(8)
const [shipping, setShipping] = useState(30) const [shipping, setShipping] = useState(30)
@@ -721,22 +723,22 @@ function BreakEvenAlert() {
const statusConfig = { const statusConfig = {
safe: { safe: {
icon: <CheckCircle2 className="w-8 h-8 text-green-400" />, icon: <CheckCircle2 className="w-8 h-8 text-green-400" />,
label: '✅ เข้าร่วม Campaign ได้', label: t('tiktokPriceSimulator.alertSafeLabel'),
desc: `ยังมี margin ${pct(margin)} — กำไรต่อชิ้น ${fmt(profitPerUnit)}`, desc: t('tiktokPriceSimulator.alertSafeDesc', { margin: pct(margin), profit: fmt(profitPerUnit) }),
bg: 'bg-green-500/10 border-green-500/30', bg: 'bg-green-500/10 border-green-500/30',
text: 'text-green-400', text: 'text-green-400',
}, },
warn: { warn: {
icon: <AlertTriangle className="w-8 h-8 text-amber-400" />, icon: <AlertTriangle className="w-8 h-8 text-amber-400" />,
label: '⚠️ ระวัง — Margin เหลือน้อยมาก', label: t('tiktokPriceSimulator.alertWarnLabel'),
desc: `Margin เหลือเพียง ${pct(margin)} — กำไรต่อชิ้น ${fmt(profitPerUnit)} อาจไม่คุ้มค่าดำเนินการ`, desc: t('tiktokPriceSimulator.alertWarnDesc', { margin: pct(margin), profit: fmt(profitPerUnit) }),
bg: 'bg-amber-500/10 border-amber-500/30', bg: 'bg-amber-500/10 border-amber-500/30',
text: 'text-amber-400', text: 'text-amber-400',
}, },
danger: { danger: {
icon: <XCircle className="w-8 h-8 text-rose-400" />, icon: <XCircle className="w-8 h-8 text-rose-400" />,
label: '🚫 ห้ามเข้า Campaign นี้เด็ดขาด!', label: t('tiktokPriceSimulator.alertDangerLabel'),
desc: `ราคา ${fmt(tiktokForcedPrice)} ต่ำกว่า Break-even ${fmt(breakEven)} — ขายทุกชิ้น ขาดทุนทุกออเดอร์!`, desc: t('tiktokPriceSimulator.alertDangerDesc', { price: fmt(tiktokForcedPrice), breakEven: fmt(breakEven) }),
bg: 'bg-rose-500/10 border-rose-500/30', bg: 'bg-rose-500/10 border-rose-500/30',
text: 'text-rose-400', text: 'text-rose-400',
}, },
@@ -749,13 +751,13 @@ function BreakEvenAlert() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
<InputRow label="ต้นทุนสินค้า" hint="ต้นทุนต่อชิ้น"> <InputRow label={t('tiktokPriceSimulator.costLabel')} hint={t('tiktokPriceSimulator.costHint')}>
<NumInput value={cost} onChange={setCost} min={0} step={10} prefix="฿" /> <NumInput value={cost} onChange={setCost} min={0} step={10} prefix="฿" />
</InputRow> </InputRow>
<InputRow label="ค่าคอมมิชชัน TikTok"> <InputRow label={t('tiktokPriceSimulator.commLabel')}>
<NumInput value={commissionPct} onChange={setCommissionPct} min={0} max={30} step={0.5} suffix="%" /> <NumInput value={commissionPct} onChange={setCommissionPct} min={0} max={30} step={0.5} suffix="%" />
</InputRow> </InputRow>
<InputRow label="ค่าจัดส่ง"> <InputRow label={t('tiktokPriceSimulator.shippingLabel')}>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<NumInput value={shipping} onChange={setShipping} min={0} prefix="฿" /> <NumInput value={shipping} onChange={setShipping} min={0} prefix="฿" />
<label className="flex items-center gap-2 cursor-pointer shrink-0"> <label className="flex items-center gap-2 cursor-pointer shrink-0">
@@ -763,20 +765,20 @@ function BreakEvenAlert() {
type="checkbox" type="checkbox"
checked={sellerPaysShipping} checked={sellerPaysShipping}
onChange={e => setSellerPaysShipping(e.target.checked)} onChange={e => setSellerPaysShipping(e.target.checked)}
className="accent-cyan-400 w-4 h-4" className="accent-cyan-400 w-4 h-4 cursor-pointer"
/> />
<span className="text-xs text-slate-400">ผู้ขายออก</span> <span className="text-xs text-slate-400">{t('tiktokPriceSimulator.sellerPays')}</span>
</label> </label>
</div> </div>
</InputRow> </InputRow>
<InputRow label="ราคาที่ TikTok กำหนด" hint="ราคาแคมเปญที่ระบบบังคับ"> <InputRow label={t('tiktokPriceSimulator.tiktokForcedPriceLabel')} hint={t('tiktokPriceSimulator.tiktokForcedPriceHint')}>
<NumInput value={tiktokForcedPrice} onChange={setTiktokForcedPrice} min={0} step={5} prefix="฿" /> <NumInput value={tiktokForcedPrice} onChange={setTiktokForcedPrice} min={0} step={5} prefix="฿" />
</InputRow> </InputRow>
</div> </div>
{/* Gauge */} {/* Gauge */}
<div> <div>
<p className="text-xs text-slate-500 mb-2">ตำแหน่งราคาแคมเปญ vs Break-even</p> <p className="text-xs text-slate-500 mb-2">{t('tiktokPriceSimulator.gaugeTitle')}</p>
<div className="relative h-6 bg-night-800 rounded-full overflow-hidden"> <div className="relative h-6 bg-night-800 rounded-full overflow-hidden">
{/* gradient bar */} {/* gradient bar */}
<div className="absolute inset-0 bg-gradient-to-r from-rose-500/60 via-amber-500/60 to-green-500/60" /> <div className="absolute inset-0 bg-gradient-to-r from-rose-500/60 via-amber-500/60 to-green-500/60" />
@@ -798,7 +800,7 @@ function BreakEvenAlert() {
</div> </div>
<div className="flex justify-between text-xs text-slate-600 mt-1"> <div className="flex justify-between text-xs text-slate-600 mt-1">
<span>฿0</span> <span>฿0</span>
<span className="text-white/50">Break-even: {fmt(breakEven)}</span> <span className="text-white/50">{t('tiktokPriceSimulator.gaugeBreakEven', { breakEven: fmt(breakEven) })}</span>
<span>{fmt(breakEven * 1.5)}</span> <span>{fmt(breakEven * 1.5)}</span>
</div> </div>
</div> </div>
@@ -811,7 +813,7 @@ function BreakEvenAlert() {
<p className="text-sm text-slate-400 mt-1">{statusConfig.desc}</p> <p className="text-sm text-slate-400 mt-1">{statusConfig.desc}</p>
{status === 'danger' && ( {status === 'danger' && (
<p className="text-xs text-rose-400/70 mt-2"> <p className="text-xs text-rose-400/70 mt-2">
💡 ทางเลือก: อย่าเข้า Campaign นี้ หรือส่งเฉพาะ SKU ที่มี Buffer สูงพอ {t('tiktokPriceSimulator.alertDangerAlt')}
</p> </p>
)} )}
</div> </div>
@@ -819,11 +821,11 @@ function BreakEvenAlert() {
{/* Key stats */} {/* Key stats */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<ResultBadge label="จุด Break-even" value={fmt(breakEven)} sub="ห้ามขายต่ำกว่านี้" danger /> <ResultBadge label={t('tiktokPriceSimulator.breakEvenLabel')} value={fmt(breakEven)} sub={t('tiktokPriceSimulator.alertBreakEvenSub')} danger />
<ResultBadge <ResultBadge
label="กำไรต่อชิ้น (ถ้าเข้า Campaign)" label={t('tiktokPriceSimulator.profitPerUnitIfJoin')}
value={fmt(profitPerUnit)} value={fmt(profitPerUnit)}
sub={margin > 0 ? `Margin ${pct(margin)}` : 'ขาดทุน!'} sub={margin > 0 ? `Margin ${pct(margin)}` : t('tiktokPriceSimulator.lossAlert')}
highlight={status === 'safe'} highlight={status === 'safe'}
warn={status === 'warn'} warn={status === 'warn'}
danger={status === 'danger'} danger={status === 'danger'}
@@ -836,6 +838,7 @@ function BreakEvenAlert() {
// ─── Main Page ───────────────────────────────────────────────────────────────── // ─── Main Page ─────────────────────────────────────────────────────────────────
export default function TiktokPriceSimulatorPage() { export default function TiktokPriceSimulatorPage() {
const { t } = useTranslation()
const [resetKey, setResetKey] = useState(0) const [resetKey, setResetKey] = useState(0)
return ( return (
@@ -847,32 +850,31 @@ export default function TiktokPriceSimulatorPage() {
<div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center">
<Calculator className="w-5 h-5 text-cyan-glow" /> <Calculator className="w-5 h-5 text-cyan-glow" />
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight">TikTok Price Simulator</h1> <h1 className="text-2xl font-bold text-white tracking-tight">{t('tiktokPriceSimulator.pageTitle')}</h1>
</div> </div>
<p className="text-sm text-slate-500 max-w-2xl"> <p className="text-sm text-slate-500 max-w-2xl">
คำนวณราคาตั้ง, จุด Break-even, และกลยุทธ์โปรโมชันก่อนเข้าร่วม Campaign — {t('tiktokPriceSimulator.pageSubtitle')}
ป้องกัน "ขายดีแต่เข้าเนื้อ" ทุกออเดอร์
</p> </p>
</div> </div>
<button <button
onClick={() => { onClick={() => {
setResetKey(k => k + 1) setResetKey(k => k + 1)
}} }}
className="btn-ghost flex items-center gap-2 text-xs shrink-0" className="btn-ghost flex items-center gap-2 text-xs shrink-0 cursor-pointer"
title="รีเซ็ตทุกค่า" title={t('tiktokPriceSimulator.resetTitle')}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
Reset {t('tiktokPriceSimulator.reset')}
</button> </button>
</div> </div>
{/* Quick guide */} {/* Quick guide */}
<div className="rounded-xl bg-night-800/40 border border-white/5 p-4 mb-8 grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="rounded-xl bg-night-800/40 border border-white/5 p-4 mb-8 grid grid-cols-2 md:grid-cols-4 gap-3">
{[ {[
{ num: '1', title: 'ตั้งราคา', desc: 'คำนวณ List / Selling / Campaign Price พร้อม Buffer' }, { num: '1', title: t('tiktokPriceSimulator.step1Title'), desc: t('tiktokPriceSimulator.step1Desc') },
{ num: '2', title: 'Volume vs Margin', desc: 'เปรียบกำไรรวมวันปกติ vs วัน Campaign' }, { num: '2', title: t('tiktokPriceSimulator.step2Title'), desc: t('tiktokPriceSimulator.step2Desc') },
{ num: '3', title: 'จัดกลุ่มสินค้า', desc: 'Hero / Profit Maker / Bundle ตาม Margin %' }, { num: '3', title: t('tiktokPriceSimulator.step3Title'), desc: t('tiktokPriceSimulator.step3Desc') },
{ num: '4', title: 'เช็ค Break-even', desc: 'ราคาแคมเปญที่ TikTok กำหนด เข้าได้หรือไม่' }, { num: '4', title: t('tiktokPriceSimulator.step4Title'), desc: t('tiktokPriceSimulator.step4Desc') },
].map(s => ( ].map(s => (
<div key={s.num} className="flex gap-3"> <div key={s.num} className="flex gap-3">
<div className="w-6 h-6 rounded-full bg-cyan-glow/20 border border-cyan-glow/30 flex items-center justify-center text-cyan-glow text-xs font-bold shrink-0"> <div className="w-6 h-6 rounded-full bg-cyan-glow/20 border border-cyan-glow/30 flex items-center justify-center text-cyan-glow text-xs font-bold shrink-0">
@@ -889,8 +891,8 @@ export default function TiktokPriceSimulatorPage() {
{/* Sections */} {/* Sections */}
<SectionCard <SectionCard
icon={<Calculator className="w-5 h-5" />} icon={<Calculator className="w-5 h-5" />}
title="ส่วนที่ 1 — Price Buffer Calculator" title={t('tiktokPriceSimulator.sec1Title')}
subtitle="คำนวณราคาตั้งที่เผื่อ Buffer สำหรับ Campaign ไว้แล้วตั้งแต่ต้น" subtitle={t('tiktokPriceSimulator.sec1Subtitle')}
accentColor="cyan" accentColor="cyan"
> >
<PriceBufferCalculator /> <PriceBufferCalculator />
@@ -898,8 +900,8 @@ export default function TiktokPriceSimulatorPage() {
<SectionCard <SectionCard
icon={<TrendingUp className="w-5 h-5" />} icon={<TrendingUp className="w-5 h-5" />}
title="ส่วนที่ 2 — Volume vs Margin Simulator" title={t('tiktokPriceSimulator.sec2Title')}
subtitle="เปรียบเทียบกำไรรวมวันปกติ vs วัน Campaign เพื่อตัดสินใจ" subtitle={t('tiktokPriceSimulator.sec2Subtitle')}
accentColor="violet" accentColor="violet"
defaultOpen={false} defaultOpen={false}
> >
@@ -908,8 +910,8 @@ export default function TiktokPriceSimulatorPage() {
<SectionCard <SectionCard
icon={<BarChart3 className="w-5 h-5" />} icon={<BarChart3 className="w-5 h-5" />}
title="ส่วนที่ 3 — Product Strategy Classifier" title={t('tiktokPriceSimulator.sec3Title')}
subtitle="จัดกลุ่มสินค้าเป็น 3 กลุ่มตาม Margin % เพื่อวางกลยุทธ์ต่างกัน" subtitle={t('tiktokPriceSimulator.sec3Subtitle')}
accentColor="amber" accentColor="amber"
defaultOpen={false} defaultOpen={false}
> >
@@ -918,8 +920,8 @@ export default function TiktokPriceSimulatorPage() {
<SectionCard <SectionCard
icon={<AlertTriangle className="w-5 h-5" />} icon={<AlertTriangle className="w-5 h-5" />}
title="ส่วนที่ 4 — Break-even Alert" title={t('tiktokPriceSimulator.sec4Title')}
subtitle="เช็คทันทีว่าราคาที่ TikTok บังคับ ต่ำกว่า Break-even หรือไม่" subtitle={t('tiktokPriceSimulator.sec4Subtitle')}
accentColor="rose" accentColor="rose"
defaultOpen={false} defaultOpen={false}
> >
@@ -928,11 +930,11 @@ export default function TiktokPriceSimulatorPage() {
{/* Footer tip */} {/* Footer tip */}
<div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/10 p-5 mt-2"> <div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/10 p-5 mt-2">
<p className="text-xs font-bold text-cyan-glow mb-2">💡 กฎเหล็ก: อย่าหลงกับตัวเลข GMV</p> <p className="text-xs font-bold text-cyan-glow mb-2">{t('tiktokPriceSimulator.footerTipTitle')}</p>
<p className="text-xs text-slate-500 leading-relaxed"> <p className="text-xs text-slate-500 leading-relaxed">
ยอดขาย (GMV) ตัวเลขสวย ≠ เงินในบัญชี ให้โฟกัสที่{' '} {t('tiktokPriceSimulator.footerTipBody')}{' '}
<span className="text-white">กำไรสุทธิหลังหักทุกอย่างแล้ว (Net Profit)</span> เสมอ <span className="text-white">{t('tiktokPriceSimulator.footerTipBodyHighlight')}</span>{' '}
ถ้าแคมเปญไหนบังคับให้ขาดทุน → อย่าเข้า หรือส่งเฉพาะ SKU ที่มี Buffer สูงพอเท่านั้น {t('tiktokPriceSimulator.footerTipBodyEnd')}
</p> </p>
</div> </div>
</div> </div>
+74 -62
View File
@@ -4,16 +4,18 @@ import {
Filter, Calendar, Table2, Search, Filter, Calendar, Table2, Search,
MapPin, MapPin,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg'; import ThailandMapSvg from '../components/tiktok-map/ThailandMapSvg';
import DistrictMapModal from '../components/tiktok-map/DistrictMapModal'; import DistrictMapModal from '../components/tiktok-map/DistrictMapModal';
import { fmtBaht, fmtNum } from '../utils/thaiGeoUtils'; import { fmtBaht, fmtNum, getLocalizedProvinceName, getLocalizedRegionName } from '../utils/thaiGeoUtils';
import { TIKTOK_SHOPS } from './TiktokOrderPage'; import { TIKTOK_SHOPS } from './TiktokOrderPage';
type ViewMode = 'map' | 'list'; type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
export default function TiktokSalesMapPage() { export default function TiktokSalesMapPage() {
const { t, i18n } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('map'); const [viewMode, setViewMode] = useState<ViewMode>('map');
const [shop, setShop] = useState('all'); const [shop, setShop] = useState('all');
const [period, setPeriod] = useState<TimePeriod>('all'); const [period, setPeriod] = useState<TimePeriod>('all');
@@ -93,11 +95,11 @@ export default function TiktokSalesMapPage() {
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white"> <h1 className="text-2xl md:text-3xl font-bold tracking-tight text-white">
แผนที่ยอดขายประเทศไทย (Thailand Sales Map) {t('tiktokSalesMap.title')}
</h1> </h1>
</div> </div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
วิเคราะห์การกระจายตัวของยอดขาย TikTok และศักยภาพลูกค้าแยกตาม 77 จังหวัด {t('tiktokSalesMap.subtitle')}
</p> </p>
</div> </div>
</div> </div>
@@ -114,7 +116,7 @@ export default function TiktokSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
<Map className="w-3.5 h-3.5" /> มุมมองแผนที่ <Map className="w-3.5 h-3.5" /> {t('tiktokSalesMap.mapView')}
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
@@ -124,7 +126,7 @@ export default function TiktokSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
<Table2 className="w-3.5 h-3.5" /> มุมมองรายการ <Table2 className="w-3.5 h-3.5" /> {t('tiktokSalesMap.listView')}
</button> </button>
</div> </div>
@@ -134,7 +136,7 @@ export default function TiktokSalesMapPage() {
onChange={(e) => setShop(e.target.value)} 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" 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> <option value="all">{t('tiktokSalesMap.allShops')}</option>
{TIKTOK_SHOPS.map((s) => ( {TIKTOK_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -148,10 +150,10 @@ export default function TiktokSalesMapPage() {
onChange={(e) => handlePeriodChange(e.target.value as TimePeriod)} 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" 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="all">{t('tiktokSalesMap.periodAll')}</option>
<option value="30days">30 วันล่าสุด</option> <option value="30days">{t('tiktokSalesMap.period30Days')}</option>
<option value="this_year">ปีนี้</option> <option value="this_year">{t('tiktokSalesMap.periodThisYear')}</option>
<option value="custom">กำหนดเอง</option> <option value="custom">{t('tiktokSalesMap.periodCustom')}</option>
</select> </select>
{/* Custom Date Pickers */} {/* Custom Date Pickers */}
@@ -184,7 +186,7 @@ export default function TiktokSalesMapPage() {
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" 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 ? <span className="animate-spin">⏳</span> : <Filter className="w-3.5 h-3.5" />}
{loading ? 'กำลังโหลด...' : 'กรอง'} {loading ? t('common.loading') : t('common.filter')}
</button> </button>
</div> </div>
</div> </div>
@@ -194,7 +196,7 @@ export default function TiktokSalesMapPage() {
{/* KPI 1 */} {/* KPI 1 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดขายรวม</span> <span>{t('tiktokSalesMap.kpiTotalRevenue')}</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"> <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 ↗ YoY
</span> </span>
@@ -210,21 +212,21 @@ export default function TiktokSalesMapPage() {
{/* KPI 2 */} {/* KPI 2 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>คำสั่งซื้อสะสม</span> <span>{t('tiktokSalesMap.kpiTotalOrders')}</span>
<ShoppingBag className="w-3.5 h-3.5 text-rose-400" /> <ShoppingBag className="w-3.5 h-3.5 text-rose-400" />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white font-mono"> <p className="text-xl md:text-2xl font-bold text-white font-mono">
{fmtNum(mapData?.kpi?.totalOrders || 0)} {fmtNum(mapData?.kpi?.totalOrders || 0)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">รวมทุกสถานะคำสั่งซื้อ</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('tiktokSalesMap.allStatusesSub')}</p>
</div> </div>
</div> </div>
{/* KPI 3 */} {/* KPI 3 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>การเข้าถึงจังหวัด</span> <span>{t('tiktokSalesMap.kpiCoverage')}</span>
<span className="text-emerald-400 font-bold text-[11px] font-mono"> <span className="text-emerald-400 font-bold text-[11px] font-mono">
{(mapData?.kpi?.coveragePercent || 0).toFixed(1)}% {(mapData?.kpi?.coveragePercent || 0).toFixed(1)}%
</span> </span>
@@ -232,41 +234,43 @@ export default function TiktokSalesMapPage() {
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono"> <p className="text-xl md:text-2xl font-bold text-emerald-400 font-mono">
{mapData?.kpi?.coverageCount || 0}{' '} {mapData?.kpi?.coverageCount || 0}{' '}
<span className="text-xs text-slate-500 font-normal">/ 77 จังหวัด</span> <span className="text-xs text-slate-500 font-normal">{t('tiktokSalesMap.outOf77Provinces')}</span>
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">ครอบคลุมทั่วประเทศ</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('tiktokSalesMap.nationwideCoverage')}</p>
</div> </div>
</div> </div>
{/* KPI 4 */} {/* KPI 4 */}
<div className="bg-slate-900/60 border border-white/10 rounded-2xl p-4 flex flex-col justify-between"> <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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ยอดเฉลี่ย/ออเดอร์</span> <span>{t('tiktokSalesMap.kpiAov')}</span>
<DollarSign className="w-3.5 h-3.5 text-amber-400" /> <DollarSign className="w-3.5 h-3.5 text-amber-400" />
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-amber-400 font-mono"> <p className="text-xl md:text-2xl font-bold text-amber-400 font-mono">
{fmtBaht(mapData?.kpi?.aov || 0)} {fmtBaht(mapData?.kpi?.aov || 0)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5">AOV คำนวณจากทุกคำสั่งซื้อ</p> <p className="text-[10px] text-slate-500 mt-0.5">{t('tiktokSalesMap.aovSub')}</p>
</div> </div>
</div> </div>
{/* KPI 5 */} {/* 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="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"> <div className="flex items-center justify-between text-xs text-slate-400">
<span>ภาคที่มีสัดส่วนสูงสุด</span> <span>{t('tiktokSalesMap.kpiTopRegion')}</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"> <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 Top Region
</span> </span>
</div> </div>
<div className="mt-2"> <div className="mt-2">
<p className="text-xl md:text-2xl font-bold text-white tracking-tight"> <p className="text-xl md:text-2xl font-bold text-white tracking-tight">
{mapData?.kpi?.topRegion?.name || 'ภาคกลาง'} {getLocalizedRegionName(mapData?.kpi?.topRegion?.name, i18n.language) || getLocalizedRegionName('Central', i18n.language)}
</p> </p>
<p className="text-[10px] text-slate-500 mt-0.5"> <p className="text-[10px] text-slate-500 mt-0.5">
{fmtNum(mapData?.kpi?.topRegion?.orders || 0)} ออเดอร์ ( {t('tiktokSalesMap.topRegionSub', {
{fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)}) count: fmtNum(mapData?.kpi?.topRegion?.orders || 0),
amount: fmtBaht(mapData?.kpi?.topRegion?.revenue || 0)
})}
</p> </p>
</div> </div>
</div> </div>
@@ -279,9 +283,9 @@ export default function TiktokSalesMapPage() {
<div className="lg:col-span-8 flex flex-col gap-4"> <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="bg-slate-900/60 border border-white/10 rounded-3xl p-5 md:p-6 flex flex-col">
<div className="mb-4"> <div className="mb-4">
<h2 className="text-lg font-bold text-white">แผนที่ยอดขายประเทศไทย</h2> <h2 className="text-lg font-bold text-white">{t('tiktokSalesMap.mapCardTitle')}</h2>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
สีเข้มหมายถึงยอดขายสูง คลิกจังหวัดเพื่อดูข้อมูลทั้งหมดของจังหวัดนั้น {t('tiktokSalesMap.mapCardSub')}
</p> </p>
</div> </div>
@@ -306,21 +310,23 @@ export default function TiktokSalesMapPage() {
<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="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"> <div className="flex items-center justify-between text-xs">
<span className="text-slate-500 uppercase tracking-wider font-bold"> <span className="text-slate-500 uppercase tracking-wider font-bold">
จังหวัด {t('tiktokSalesMap.provinceLabel')}
</span> </span>
{selectedProvince && ( {selectedProvince && (
<button <button
onClick={() => setSelectedProvince(null)} onClick={() => setSelectedProvince(null)}
className="text-xs text-emerald-400 hover:underline font-bold" className="text-xs text-emerald-400 hover:underline font-bold cursor-pointer"
> >
ดูทั่วประเทศ {t('tiktokSalesMap.viewNationwide')}
</button> </button>
)} )}
</div> </div>
<div> <div>
<h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight"> <h3 className="text-2xl md:text-3xl font-bold text-white tracking-tight">
{currentProvinceInfo ? currentProvinceInfo.nameTh : 'ทั่วประเทศ'} {currentProvinceInfo
? getLocalizedProvinceName(currentProvinceInfo.nameTh, i18n.language)
: t('tiktokSalesMap.nationwide')}
</h3> </h3>
<p className="text-2xl font-bold text-emerald-400 font-mono mt-1"> <p className="text-2xl font-bold text-emerald-400 font-mono mt-1">
{fmtBaht( {fmtBaht(
@@ -330,23 +336,20 @@ export default function TiktokSalesMapPage() {
)} )}
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
{fmtNum( {t('tiktokSalesMap.provinceCardSub', {
currentProvinceInfo orders: fmtNum(currentProvinceInfo ? currentProvinceInfo.orders : mapData?.kpi?.totalOrders || 0),
? currentProvinceInfo.orders region: currentProvinceInfo
: mapData?.kpi?.totalOrders || 0 ? getLocalizedRegionName(currentProvinceInfo.regionTh, i18n.language)
)}{' '} : t('tiktokSalesMap.coverageCountText', { count: mapData?.kpi?.coverageCount || 0 })
คำสั่งซื้อ •{' '} })}
{currentProvinceInfo
? currentProvinceInfo.regionTh
: `${mapData?.kpi?.coverageCount || 0} จังหวัดที่มียอดขาย`}
</p> </p>
</div> </div>
{/* Color Scale Legend */} {/* Color Scale Legend */}
<div className="space-y-1.5 pt-2"> <div className="space-y-1.5 pt-2">
<div className="flex items-center justify-between text-[11px] text-slate-500"> <div className="flex items-center justify-between text-[11px] text-slate-500">
<span>ไม่มียอด</span> <span>{t('tiktokSalesMap.noSales')}</span>
<span>ยอดสูง</span> <span>{t('tiktokSalesMap.highSales')}</span>
</div> </div>
<div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" /> <div className="h-2 rounded-full bg-gradient-to-r from-slate-800 via-emerald-600 to-emerald-300 w-full" />
</div> </div>
@@ -355,13 +358,13 @@ export default function TiktokSalesMapPage() {
{selectedProvince ? ( {selectedProvince ? (
<button <button
onClick={() => setIsModalOpen(true)} 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" 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 cursor-pointer"
> >
<MapPin className="w-4 h-4" /> ดูรายละเอียดและแผนที่อำเภอ (Map Focus) <MapPin className="w-4 h-4" /> {t('tiktokSalesMap.btnDistrictFocus')}
</button> </button>
) : ( ) : (
<div className="text-[11px] text-slate-500 leading-relaxed pt-1"> <div className="text-[11px] text-slate-500 leading-relaxed pt-1">
เลือกจังหวัดแล้วทุกส่วนด้านล่างจะเปลี่ยนพร้อมกัน ทั้งแนวโน้ม กลุ่มสินค้า และร้านยอดสูง {t('tiktokSalesMap.selectProvinceHint')}
</div> </div>
)} )}
</div> </div>
@@ -374,7 +377,7 @@ export default function TiktokSalesMapPage() {
<div className="flex items-center justify-between pb-4 border-b border-white/10"> <div className="flex items-center justify-between pb-4 border-b border-white/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Award className="w-5 h-5 text-amber-400" /> <Award className="w-5 h-5 text-amber-400" />
<h2 className="text-base font-bold text-white">จังหวัดยอดขายสูง</h2> <h2 className="text-base font-bold text-white">{t('tiktokSalesMap.topProvincesTitle')}</h2>
</div> </div>
<span className="text-xs text-slate-400 font-mono">Top 20</span> <span className="text-xs text-slate-400 font-mono">Top 20</span>
</div> </div>
@@ -405,10 +408,13 @@ export default function TiktokSalesMapPage() {
</span> </span>
<div> <div>
<p className="text-xs font-bold text-white flex items-center gap-1.5"> <p className="text-xs font-bold text-white flex items-center gap-1.5">
{p.nameTh} {getLocalizedProvinceName(p.nameTh, i18n.language)}
</p> </p>
<p className="text-[10px] text-slate-500"> <p className="text-[10px] text-slate-500">
{p.orders} ออเดอร์ • {p.regionTh} {t('tiktokSalesMap.rankingItemSub', {
orders: p.orders,
region: getLocalizedRegionName(p.regionTh, i18n.language)
})}
</p> </p>
</div> </div>
</div> </div>
@@ -418,7 +424,7 @@ export default function TiktokSalesMapPage() {
{fmtBaht(p.revenue)} {fmtBaht(p.revenue)}
</p> </p>
<p className="text-[9px] text-slate-500"> <p className="text-[9px] text-slate-500">
AOV {fmtBaht(p.aov)} {t('tiktokSalesMap.rankingAov', { amount: fmtBaht(p.aov) })}
</p> </p>
</div> </div>
</div> </div>
@@ -432,7 +438,7 @@ export default function TiktokSalesMapPage() {
<div className="bg-slate-900/60 border border-white/10 rounded-3xl p-6 space-y-4"> <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"> <div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-base font-bold text-white"> <h2 className="text-base font-bold text-white">
ตารางสรุปยอดขายแยกราย 77 จังหวัด {t('tiktokSalesMap.tableTitle')}
</h2> </h2>
<div className="relative w-64"> <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" /> <Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
@@ -440,7 +446,7 @@ export default function TiktokSalesMapPage() {
type="text" type="text"
value={listSearch} value={listSearch}
onChange={(e) => setListSearch(e.target.value)} onChange={(e) => setListSearch(e.target.value)}
placeholder="ค้นหาชื่อจังหวัด หรือ ภาค..." placeholder={t('tiktokSalesMap.searchPlaceholder')}
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" 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>
@@ -450,13 +456,13 @@ export default function TiktokSalesMapPage() {
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10"> <thead className="text-[11px] text-slate-400 uppercase bg-white/5 border-b border-white/10">
<tr> <tr>
<th className="px-4 py-3">รหัส</th> <th className="px-4 py-3">{t('tiktokSalesMap.thCode')}</th>
<th className="px-4 py-3">จังหวัด</th> <th className="px-4 py-3">{t('tiktokSalesMap.thProvince')}</th>
<th className="px-4 py-3">ภูมิภาค</th> <th className="px-4 py-3">{t('tiktokSalesMap.thRegion')}</th>
<th className="px-4 py-3 text-right">ยอดขายรวม (฿)</th> <th className="px-4 py-3 text-right">{t('tiktokSalesMap.thRevenue')}</th>
<th className="px-4 py-3 text-right">จำนวนออเดอร์</th> <th className="px-4 py-3 text-right">{t('tiktokSalesMap.thOrders')}</th>
<th className="px-4 py-3 text-right">AOV (฿/ออเดอร์)</th> <th className="px-4 py-3 text-right">{t('tiktokSalesMap.thAov')}</th>
<th className="px-4 py-3 text-center">จัดการ</th> <th className="px-4 py-3 text-center">{t('tiktokSalesMap.thActions')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -466,7 +472,9 @@ export default function TiktokSalesMapPage() {
!listSearch || !listSearch ||
p.nameTh.includes(listSearch) || p.nameTh.includes(listSearch) ||
p.nameEn.toLowerCase().includes(listSearch.toLowerCase()) || p.nameEn.toLowerCase().includes(listSearch.toLowerCase()) ||
p.regionTh.includes(listSearch) p.regionTh.includes(listSearch) ||
getLocalizedProvinceName(p.nameTh, i18n.language).toLowerCase().includes(listSearch.toLowerCase()) ||
getLocalizedRegionName(p.regionTh, i18n.language).toLowerCase().includes(listSearch.toLowerCase())
) )
.sort((a: any, b: any) => b.revenue - a.revenue) .sort((a: any, b: any) => b.revenue - a.revenue)
.map((p: any) => ( .map((p: any) => (
@@ -476,10 +484,14 @@ export default function TiktokSalesMapPage() {
> >
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td> <td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white"> <td className="px-4 py-3 font-bold text-white">
{p.nameTh}{' '} {getLocalizedProvinceName(p.nameTh, i18n.language)}{' '}
<span className="text-slate-500 text-[10px] font-mono">({p.nameEn})</span> <span className="text-slate-500 text-[10px] font-mono">
({i18n.language === 'th' ? p.nameEn : p.nameTh})
</span>
</td>
<td className="px-4 py-3 text-slate-400">
{getLocalizedRegionName(p.regionTh, i18n.language)}
</td> </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"> <td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
{fmtBaht(p.revenue)} {fmtBaht(p.revenue)}
</td> </td>
@@ -495,9 +507,9 @@ export default function TiktokSalesMapPage() {
setSelectedProvince(p.nameTh); setSelectedProvince(p.nameTh);
setIsModalOpen(true); 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" 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 cursor-pointer"
> >
<MapPin className="w-3 h-3" /> แผนที่อำเภอ <MapPin className="w-3 h-3" /> {t('tiktokSalesMap.btnDistrictMap')}
</button> </button>
</td> </td>
</tr> </tr>
+73 -72
View File
@@ -4,12 +4,14 @@ import {
Info, Info,
RefreshCw RefreshCw
} from 'lucide-react' } from 'lucide-react'
import { useTranslation } from 'react-i18next'
function fmt(val: number) { function fmt(val: number) {
return '฿' + val.toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) return '฿' + val.toLocaleString('th-TH', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} }
export default function TiktokShopCasePage() { export default function TiktokShopCasePage() {
const { t } = useTranslation()
const [resetKey, setResetKey] = useState(0) const [resetKey, setResetKey] = useState(0)
const [selectedCase, setSelectedCase] = useState<'case1' | 'case2' | 'case3'>('case1') const [selectedCase, setSelectedCase] = useState<'case1' | 'case2' | 'case3'>('case1')
@@ -38,7 +40,7 @@ export default function TiktokShopCasePage() {
origTotalFees: -13.08, origTotalFees: -13.08,
originalPayout: 48.02, originalPayout: 48.02,
defaultCost: 30.00, defaultCost: 30.00,
label: 'เคสที่ 1: บิลสินค้า ฿79 (สุทธิ ฿48.02)', name: t('tiktokShopCase.case1Name'),
}, },
case2: { case2: {
beforeDiscount: 190.00, beforeDiscount: 190.00,
@@ -51,7 +53,7 @@ export default function TiktokShopCasePage() {
origTotalFees: -36.19, origTotalFees: -36.19,
originalPayout: 132.91, originalPayout: 132.91,
defaultCost: 80.00, defaultCost: 80.00,
label: 'เคสที่ 2: บิลสินค้า ฿190 (สุทธิ ฿132.91)', name: t('tiktokShopCase.case2Name'),
}, },
case3: { case3: {
beforeDiscount: 190.00, beforeDiscount: 190.00,
@@ -64,7 +66,7 @@ export default function TiktokShopCasePage() {
origTotalFees: -40.18, origTotalFees: -40.18,
originalPayout: 112.01, originalPayout: 112.01,
defaultCost: 70.00, defaultCost: 70.00,
label: 'เคสที่ 3: บิลสินค้า ฿190 + Affiliate (สุทธิ ฿112.01)', name: t('tiktokShopCase.case3Name'),
} }
} }
@@ -85,7 +87,6 @@ export default function TiktokShopCasePage() {
}, [selectedCase]) }, [selectedCase])
// Simulated calculations // Simulated calculations
// Deduct discounts. Let's make it cumulative based on the original list price:
const simDiscountAmount = discounts.reduce((sum, d) => { const simDiscountAmount = discounts.reduce((sum, d) => {
const amt = d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value const amt = d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value
return sum + amt return sum + amt
@@ -122,10 +123,10 @@ export default function TiktokShopCasePage() {
<div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 flex items-center justify-center">
<Calculator className="w-5 h-5 text-cyan-glow" /> <Calculator className="w-5 h-5 text-cyan-glow" />
</div> </div>
<h1 className="text-2xl font-bold text-white tracking-tight">TikTok Shop Case</h1> <h1 className="text-2xl font-bold text-white tracking-tight">{t('tiktokShopCase.pageTitle')}</h1>
</div> </div>
<p className="text-sm text-slate-500 max-w-2xl"> <p className="text-sm text-slate-500 max-w-2xl">
ศึกษาโครงสร้างบิลรับเงินจริงจากระบบ TikTok Shop พร้อมทั้งจำลองคำนวณราคาขาย, คูปองส่วนลดร้านค้า, ต้นทุน และประเมินสถิติกำไร & ROI ก่อนขายจริง {t('tiktokShopCase.pageSubtitle')}
</p> </p>
</div> </div>
<button <button
@@ -136,11 +137,11 @@ export default function TiktokShopCasePage() {
setSimGrowthPct(7.50) setSimGrowthPct(7.50)
setSimAffiliatePct(selectedCase === 'case3' ? 5.00 : 0) setSimAffiliatePct(selectedCase === 'case3' ? 5.00 : 0)
}} }}
className="btn-ghost flex items-center gap-2 text-xs shrink-0" className="btn-ghost flex items-center gap-2 text-xs shrink-0 cursor-pointer"
title="รีเซ็ตทุกค่า" title={t('tiktokShopCase.resetTitle')}
> >
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
Reset {t('tiktokShopCase.reset')}
</button> </button>
</div> </div>
@@ -149,30 +150,30 @@ export default function TiktokShopCasePage() {
<div className="flex items-center justify-between mb-4 flex-wrap gap-4 border-b border-white/5 pb-4"> <div className="flex items-center justify-between mb-4 flex-wrap gap-4 border-b border-white/5 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Info className="w-5 h-5 text-cyan-glow" /> <Info className="w-5 h-5 text-cyan-glow" />
<h2 className="text-lg font-bold text-white">กรณีศึกษา & เครื่องมือจำลองค่าธรรมเนียมจริง</h2> <h2 className="text-lg font-bold text-white">{t('tiktokShopCase.sectionTitle')}</h2>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
<div className="flex p-1 bg-night-900 border border-white/10 rounded-lg"> <div className="flex p-1 bg-night-900 border border-white/10 rounded-lg">
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case1' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer ${selectedCase === 'case1' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case1')} onClick={() => setSelectedCase('case1')}
> >
ตัวอย่างที่ 1 (ยอด ฿79) {t('tiktokShopCase.case1Btn')}
</button> </button>
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case2' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer ${selectedCase === 'case2' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case2')} onClick={() => setSelectedCase('case2')}
> >
ตัวอย่างที่ 2 (ยอด ฿190) {t('tiktokShopCase.case2Btn')}
</button> </button>
<button <button
type="button" type="button"
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all ${selectedCase === 'case3' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-3 py-1.5 text-xs font-medium rounded-md transition-all cursor-pointer ${selectedCase === 'case3' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => setSelectedCase('case3')} onClick={() => setSelectedCase('case3')}
> >
ตัวอย่างที่ 3 (ยอด ฿190 + Affiliate) {t('tiktokShopCase.case3Btn')}
</button> </button>
</div> </div>
<button <button
@@ -182,39 +183,39 @@ export default function TiktokShopCasePage() {
setSimGrowthPct(7.50) setSimGrowthPct(7.50)
setSimAffiliatePct(selectedCase === 'case3' ? 5.00 : 0) setSimAffiliatePct(selectedCase === 'case3' ? 5.00 : 0)
}} }}
className="btn-ghost text-xs py-1.5 px-3 rounded border border-white/10" className="btn-ghost text-xs py-1.5 px-3 rounded border border-white/10 cursor-pointer"
> >
ใช้ค่าตั้งต้นตัวอย่าง {t('tiktokShopCase.useDefaultBtn')}
</button> </button>
</div> </div>
</div> </div>
<p className="text-xs text-slate-400 mb-2 font-medium"> <p className="text-xs text-slate-400 mb-2 font-medium">
รายละเอียดบิลจริงดั้งเดิมของ {selectedCase === 'case1' ? 'ตัวอย่างที่ 1' : selectedCase === 'case2' ? 'ตัวอย่างที่ 2' : 'ตัวอย่างที่ 3'}: {t('tiktokShopCase.billDetailSubtitle', { caseName: activeCase.name })}
</p> </p>
<div className="grid grid-cols-3 gap-4 p-3 rounded-lg bg-night-800/40 border border-white/5 text-xs mb-6 opacity-80"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 p-3 rounded-lg bg-night-800/40 border border-white/5 text-xs mb-6 opacity-80">
<div> <div>
<span className="text-slate-500 block">ยอดรวมสินค้าก่อนลด</span> <span className="text-slate-500 block">{t('tiktokShopCase.grossBeforeDiscount')}</span>
<span className="font-bold text-white font-mono">{fmt(activeCase.beforeDiscount)}</span> <span className="font-bold text-white font-mono">{fmt(activeCase.beforeDiscount)}</span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">ส่วนลดจากร้านค้า (ลดไป %)</span> <span className="text-slate-500 block">{t('tiktokShopCase.shopDiscountWithPct')}</span>
<span className="font-bold text-rose-400 font-mono"> <span className="font-bold text-rose-400 font-mono">
{fmt(activeCase.shopDiscount)} (-{((Math.abs(activeCase.shopDiscount) / activeCase.beforeDiscount) * 100).toFixed(1)}%) {fmt(activeCase.shopDiscount)} (-{((Math.abs(activeCase.shopDiscount) / activeCase.beforeDiscount) * 100).toFixed(1)}%)
</span> </span>
</div> </div>
<div> <div>
<span className="text-slate-500 block">ยอดรวมหลังหักส่วนลด (ฐานคิดค่าคอม)</span> <span className="text-slate-500 block">{t('tiktokShopCase.netBaseForCommission')}</span>
<span className="font-bold text-cyan-glow font-mono">{fmt(activeCase.baseAmt)}</span> <span className="font-bold text-cyan-glow font-mono">{fmt(activeCase.baseAmt)}</span>
</div> </div>
</div> </div>
{/* Simulator controls - Price & Discounts */} {/* Simulator controls - Price & Discounts */}
<p className="text-xs text-slate-400 mb-2 font-medium">1. จำลองการตั้งราคา ส่วนลด และต้นทุนสินค้า (ปรับแต่งเองได้):</p> <p className="text-xs text-slate-400 mb-2 font-medium">{t('tiktokShopCase.step1ControlTitle')}</p>
<div className="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5"> <div className="mb-4 grid grid-cols-1 sm:grid-cols-3 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5">
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ราคาขายก่อนลด (List Price)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.listPriceLabel')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -228,7 +229,7 @@ export default function TiktokShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ต้นทุนสินค้า (Cost)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.costLabel')}</label>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<input <input
type="number" type="number"
@@ -246,7 +247,7 @@ export default function TiktokShopCasePage() {
key={val} key={val}
type="button" type="button"
onClick={() => setSimCost(val)} onClick={() => setSimCost(val)}
className={`px-2.5 py-1 text-[10px] font-medium rounded border transition-all ${simCost === val ? 'bg-cyan-glow/20 text-cyan-glow border-cyan-glow/30' : 'bg-night-900 text-slate-400 border-white/10 hover:text-white hover:border-white/20'}`} className={`px-2.5 py-1 text-[10px] font-medium rounded border transition-all cursor-pointer ${simCost === val ? 'bg-cyan-glow/20 text-cyan-glow border-cyan-glow/30' : 'bg-night-900 text-slate-400 border-white/10 hover:text-white hover:border-white/20'}`}
> >
฿{val} ฿{val}
</button> </button>
@@ -256,16 +257,16 @@ export default function TiktokShopCasePage() {
</div> </div>
<div className="space-y-3 sm:col-span-2"> <div className="space-y-3 sm:col-span-2">
<label className="block text-xs text-slate-400 font-medium">ส่วนลดจากร้านค้า (คูปองร้านค้า 1-3)</label> <label className="block text-xs text-slate-400 font-medium">{t('tiktokShopCase.shopDiscountsLabel')}</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{discounts.map((discount, index) => ( {discounts.map((discount, index) => (
<div key={index} className="flex flex-col gap-2 p-2.5 rounded-lg bg-night-900/60 border border-white/5"> <div key={index} className="flex flex-col gap-2 p-2.5 rounded-lg bg-night-900/60 border border-white/5">
<span className="text-[10px] text-slate-500 font-bold">คูปองที่ #{index + 1}</span> <span className="text-[10px] text-slate-500 font-bold">{t('tiktokShopCase.couponIndex', { index: index + 1 })}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex p-0.5 bg-night-950 border border-white/10 rounded-md shrink-0"> <div className="flex p-0.5 bg-night-950 border border-white/10 rounded-md shrink-0">
<button <button
type="button" type="button"
className={`px-1.5 py-0.5 text-[9px] font-medium rounded transition-all ${discount.type === 'pct' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-1.5 py-0.5 text-[9px] font-medium rounded transition-all cursor-pointer ${discount.type === 'pct' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => { onClick={() => {
const updated = [...discounts] const updated = [...discounts]
updated[index] = { ...updated[index], type: 'pct' } updated[index] = { ...updated[index], type: 'pct' }
@@ -276,7 +277,7 @@ export default function TiktokShopCasePage() {
</button> </button>
<button <button
type="button" type="button"
className={`px-1.5 py-0.5 text-[9px] font-medium rounded transition-all ${discount.type === 'amount' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`} className={`px-1.5 py-0.5 text-[9px] font-medium rounded transition-all cursor-pointer ${discount.type === 'amount' ? 'bg-cyan-glow/20 text-cyan-glow' : 'text-slate-400 hover:text-white'}`}
onClick={() => { onClick={() => {
const updated = [...discounts] const updated = [...discounts]
updated[index] = { ...updated[index], type: 'amount' } updated[index] = { ...updated[index], type: 'amount' }
@@ -307,10 +308,10 @@ export default function TiktokShopCasePage() {
</div> </div>
{/* Simulator controls - Fees */} {/* Simulator controls - Fees */}
<p className="text-xs text-slate-400 mb-2 font-medium">2. จำลองการปรับเปลี่ยนอัตราค่าธรรมเนียม (%):</p> <p className="text-xs text-slate-400 mb-2 font-medium">{t('tiktokShopCase.step2ControlTitle')}</p>
<div className="mb-6 grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5"> <div className="mb-6 grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 rounded-xl bg-night-800/40 border border-white/5">
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">Transaction (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.feeTransaction')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -325,7 +326,7 @@ export default function TiktokShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าคอม TikTok (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.feeCommission')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -340,7 +341,7 @@ export default function TiktokShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">สนับสนุนเติบโต (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.feeGrowth')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -355,7 +356,7 @@ export default function TiktokShopCasePage() {
</div> </div>
</div> </div>
<div> <div>
<label className="block text-xs text-slate-400 font-medium mb-1.5">ค่าคอมแอฟฟิลิเอต (%)</label> <label className="block text-xs text-slate-400 font-medium mb-1.5">{t('tiktokShopCase.feeAffiliate')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
@@ -376,66 +377,66 @@ export default function TiktokShopCasePage() {
{/* ก่อนปรับ (ค่าธรรมเนียมจากบิลตัวอย่างจริง) */} {/* ก่อนปรับ (ค่าธรรมเนียมจากบิลตัวอย่างจริง) */}
<div className="rounded-xl bg-night-800/30 border border-white/5 p-4 space-y-4"> <div className="rounded-xl bg-night-800/30 border border-white/5 p-4 space-y-4">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider border-b border-white/5 pb-2 flex justify-between"> <h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider border-b border-white/5 pb-2 flex justify-between">
<span>ก่อนปรับ (บิลดั้งเดิม)</span> <span>{t('tiktokShopCase.tableBeforeTitle')}</span>
<span className="text-slate-500">รวม {((Math.abs(activeCase.origTotalFees) / activeCase.baseAmt) * 100).toFixed(2)}%</span> <span className="text-slate-500">{t('tiktokShopCase.totalPctHeader', { pct: ((Math.abs(activeCase.origTotalFees) / activeCase.baseAmt) * 100).toFixed(2) })}</span>
</h3> </h3>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ยอดรวมสินค้าก่อนลด</span> <span>{t('tiktokShopCase.rowGross')}</span>
<span className="font-mono">{fmt(activeCase.beforeDiscount)}</span> <span className="font-mono">{fmt(activeCase.beforeDiscount)}</span>
</div> </div>
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ส่วนลดจากร้านค้า</span> <span>{t('tiktokShopCase.rowShopDiscount')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.shopDiscount)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.shopDiscount)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300">
<span>ยอดสินค้าสุทธิ (ฐานคิดคอม)</span> <span>{t('tiktokShopCase.rowNetBase')}</span>
<span>{fmt(activeCase.baseAmt)}</span> <span>{fmt(activeCase.baseAmt)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคำสั่งซื้อ (Transaction 3.21%)</span> <span>{t('tiktokShopCase.rowFeeTrans', { pct: '3.21' })}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origTrans)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origTrans)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอม TikTok Shop (10.70%)</span> <span>{t('tiktokShopCase.rowFeeComm', { pct: '10.70' })}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origComm)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origComm)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าสนับสนุนการเติบโต (7.50%)</span> <span>{t('tiktokShopCase.rowFeeGrowth', { pct: '7.50' })}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origGrowth)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origGrowth)}</span>
</div> </div>
{activeCase.origAffiliate !== 0 && ( {activeCase.origAffiliate !== 0 && (
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมแอฟฟิลิเอต (5.00%)</span> <span>{t('tiktokShopCase.rowFeeAffiliate', { pct: '5.00' })}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origAffiliate)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origAffiliate)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-white/5 pt-1.5 font-medium text-slate-300">
<span>รวมค่าธรรมเนียมโดนหัก</span> <span>{t('tiktokShopCase.rowTotalFees')}</span>
<span className="font-mono text-rose-400">{fmt(activeCase.origTotalFees)}</span> <span className="font-mono text-rose-400">{fmt(activeCase.origTotalFees)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm">
<span>ยอดเงินโอนเข้าบัญชีสุทธิ</span> <span>{t('tiktokShopCase.rowNetPayout')}</span>
<span className="font-mono text-cyan-glow">{fmt(activeCase.originalPayout)}</span> <span className="font-mono text-cyan-glow">{fmt(activeCase.originalPayout)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-1.5 text-slate-400"> <div className="flex justify-between border-t border-white/5 pt-1.5 text-slate-400">
<span>- ต้นทุนสินค้า (Cost)</span> <span>{t('tiktokShopCase.rowCost')}</span>
<span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-white/5 pt-2 font-bold text-white text-sm">
<span>กำไรสุทธิ (Net Profit)</span> <span>{t('tiktokShopCase.rowNetProfit')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origProfit >= 0 ? '' : '-'}฿{Math.abs(origProfit).toFixed(2)} {origProfit >= 0 ? '' : '-'}฿{Math.abs(origProfit).toFixed(2)}
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อยอดขาย (Margin %)</span> <span>{t('tiktokShopCase.rowMargin')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origProfitMargin.toFixed(1)}% {origProfitMargin.toFixed(1)}%
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อทุน (ROI %)</span> <span>{t('tiktokShopCase.rowRoi')}</span>
<span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${origProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{origROI.toFixed(1)}% {origROI.toFixed(1)}%
</span> </span>
@@ -446,76 +447,76 @@ export default function TiktokShopCasePage() {
{/* หลังปรับ (จำลองการปรับค่าธรรมเนียม) */} {/* หลังปรับ (จำลองการปรับค่าธรรมเนียม) */}
<div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/20 p-4 space-y-4"> <div className="rounded-xl bg-cyan-glow/5 border border-cyan-glow/20 p-4 space-y-4">
<h3 className="text-xs font-bold text-cyan-glow uppercase tracking-wider border-b border-cyan-glow/10 pb-2 flex justify-between"> <h3 className="text-xs font-bold text-cyan-glow uppercase tracking-wider border-b border-cyan-glow/10 pb-2 flex justify-between">
<span>หลังปรับ (ผลการจำลองใหม่)</span> <span>{t('tiktokShopCase.tableAfterTitle')}</span>
<span>รวม {simTotalPct.toFixed(2)}%</span> <span>{t('tiktokShopCase.totalPctHeader', { pct: simTotalPct.toFixed(2) })}</span>
</h3> </h3>
<div className="space-y-2 text-xs"> <div className="space-y-2 text-xs">
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ยอดรวมสินค้าก่อนลด</span> <span>{t('tiktokShopCase.rowGross')}</span>
<span className="font-mono">{fmt(simBeforeDiscount)}</span> <span className="font-mono">{fmt(simBeforeDiscount)}</span>
</div> </div>
<div className="flex justify-between text-slate-400"> <div className="flex justify-between text-slate-400">
<span>ส่วนลดจากร้านค้า (รวม)</span> <span>{t('tiktokShopCase.rowShopDiscountTotal')}</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-{fmt(simDiscountAmount)} -{fmt(simDiscountAmount)}
</span> </span>
</div> </div>
{discounts.map((d, i) => d.value > 0 ? ( {discounts.map((d, i) => d.value > 0 ? (
<div key={i} className="flex justify-between text-[10px] text-slate-500 pl-3"> <div key={i} className="flex justify-between text-[10px] text-slate-500 pl-3">
<span>- คูปอง #{i + 1} ({d.type === 'pct' ? `${d.value}%` : `฿${d.value}`})</span> <span>{t('tiktokShopCase.rowCouponItem', { index: i + 1, val: d.type === 'pct' ? `${d.value}%` : `฿${d.value}` })}</span>
<span className="font-mono text-rose-400"> <span className="font-mono text-rose-400">
-{fmt(d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value)} -{fmt(d.type === 'pct' ? simBeforeDiscount * (d.value / 100) : d.value)}
</span> </span>
</div> </div>
) : null)} ) : null)}
<div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 font-medium text-slate-300">
<span>ยอดสินค้าสุทธิ (ฐานคิดคอม)</span> <span>{t('tiktokShopCase.rowNetBase')}</span>
<span className="font-mono text-cyan-glow">{fmt(baseAmt)}</span> <span className="font-mono text-cyan-glow">{fmt(baseAmt)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคำสั่งซื้อ ({simTransPct.toFixed(2)}%)</span> <span>{t('tiktokShopCase.rowFeeTrans', { pct: simTransPct.toFixed(2) })}</span>
<span className="font-mono text-rose-400">฿-{simTransAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simTransAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอม TikTok Shop ({simCommPct.toFixed(2)}%)</span> <span>{t('tiktokShopCase.rowFeeComm', { pct: simCommPct.toFixed(2) })}</span>
<span className="font-mono text-rose-400">฿-{simCommAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCommAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าสนับสนุนการเติบโต ({simGrowthPct.toFixed(2)}%)</span> <span>{t('tiktokShopCase.rowFeeGrowth', { pct: simGrowthPct.toFixed(2) })}</span>
<span className="font-mono text-rose-400">฿-{simGrowthAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simGrowthAmt.toFixed(2)}</span>
</div> </div>
{simAffiliatePct > 0 && ( {simAffiliatePct > 0 && (
<div className="flex justify-between pl-3 text-slate-500"> <div className="flex justify-between pl-3 text-slate-500">
<span>- ค่าคอมคอมแอฟฟิลิเอต ({simAffiliatePct.toFixed(2)}%)</span> <span>{t('tiktokShopCase.rowFeeAffiliate', { pct: simAffiliatePct.toFixed(2) })}</span>
<span className="font-mono text-rose-400">฿-{simAffiliateAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simAffiliateAmt.toFixed(2)}</span>
</div> </div>
)} )}
<div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 font-medium text-slate-300"> <div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 font-medium text-slate-300">
<span>รวมค่าธรรมเนียมโดนหัก</span> <span>{t('tiktokShopCase.rowTotalFees')}</span>
<span className="font-mono text-rose-400">฿-{simTotalAmt.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simTotalAmt.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-cyan-glow/10 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-cyan-glow/10 pt-2 font-bold text-white text-sm">
<span>ยอดเงินโอนเข้าบัญชีสุทธิ</span> <span>{t('tiktokShopCase.rowNetPayout')}</span>
<span className="font-mono text-cyan-glow">฿{simPayout.toFixed(2)}</span> <span className="font-mono text-cyan-glow">฿{simPayout.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 text-slate-400"> <div className="flex justify-between border-t border-cyan-glow/10 pt-1.5 text-slate-400">
<span>- ต้นทุนสินค้า (Cost)</span> <span>{t('tiktokShopCase.rowCost')}</span>
<span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span> <span className="font-mono text-rose-400">฿-{simCost.toFixed(2)}</span>
</div> </div>
<div className="flex justify-between border-t border-cyan-glow/10 pt-2 font-bold text-white text-sm"> <div className="flex justify-between border-t border-cyan-glow/10 pt-2 font-bold text-white text-sm">
<span>กำไรสุทธิ (Net Profit)</span> <span>{t('tiktokShopCase.rowNetProfit')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simProfit >= 0 ? '' : '-'}฿{Math.abs(simProfit).toFixed(2)} {simProfit >= 0 ? '' : '-'}฿{Math.abs(simProfit).toFixed(2)}
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อยอดขาย (Margin %)</span> <span>{t('tiktokShopCase.rowMargin')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simProfitMargin.toFixed(1)}% {simProfitMargin.toFixed(1)}%
</span> </span>
</div> </div>
<div className="flex justify-between text-slate-400 pl-3"> <div className="flex justify-between text-slate-400 pl-3">
<span>กำไรต่อทุน (ROI %)</span> <span>{t('tiktokShopCase.rowRoi')}</span>
<span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}> <span className={`font-mono ${simProfit >= 0 ? 'text-green-400' : 'text-rose-400'}`}>
{simROI.toFixed(1)}% {simROI.toFixed(1)}%
</span> </span>
@@ -527,14 +528,14 @@ export default function TiktokShopCasePage() {
{/* Difference comparison status card */} {/* Difference comparison status card */}
<div className={`mt-6 rounded-xl p-4 flex items-center justify-between text-xs border ${diffVal >= 0 ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-rose-500/10 border-rose-500/30 text-rose-400'}`}> <div className={`mt-6 rounded-xl p-4 flex items-center justify-between text-xs border ${diffVal >= 0 ? 'bg-green-500/10 border-green-500/30 text-green-400' : 'bg-rose-500/10 border-rose-500/30 text-rose-400'}`}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Info className="w-4 h-4" /> <Info className="w-4 h-4 shrink-0" />
<span className="font-medium"> <span className="font-medium">
{diffVal >= 0 {diffVal >= 0
? `ยอดรับเงินเพิ่มขึ้น ฿${diffVal.toFixed(2)} จากโครงสร้างธรรมเนียมใหม่` ? t('tiktokShopCase.diffProfitUp', { diff: diffVal.toFixed(2) })
: `ยอดเงินโอนเข้าบัญชีสุทธิจะต่างไป ฿${diffVal.toFixed(2)} (${diffVal >= 0 ? '+' : ''}${diffPct.toFixed(1)}%) เมื่อเทียบกับบิลจริงดั้งเดิม`} : t('tiktokShopCase.diffPayoutChanged', { diff: Math.abs(diffVal).toFixed(2), sign: diffVal >= 0 ? '+' : '', pct: diffPct.toFixed(1) })}
</span> </span>
</div> </div>
<span className="font-bold font-mono text-sm">{diffVal >= 0 ? '+' : ''}฿{diffVal.toFixed(2)}</span> <span className="font-bold font-mono text-sm shrink-0 ml-2">{diffVal >= 0 ? '+' : ''}฿{diffVal.toFixed(2)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -4,10 +4,11 @@ import {
Calendar, Table2, Search, CheckCircle2, XCircle, Filter, Calendar, Table2, Search, CheckCircle2, XCircle, Filter,
MapPin, Globe2, TrendingUp, Layers, ArrowUpRight MapPin, Globe2, TrendingUp, Layers, ArrowUpRight
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg'; import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
import OmnichannelDistrictModal from '../../components/analytics/OmnichannelDistrictModal'; import OmnichannelDistrictModal from '../../components/analytics/OmnichannelDistrictModal';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; import { fmtBaht, fmtNum, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils';
import { TIKTOK_SHOPS } from '../TiktokOrderPage'; import { TIKTOK_SHOPS } from '../TiktokOrderPage';
import { SHOPEE_SHOPS } from '../ShopeePage'; import { SHOPEE_SHOPS } from '../ShopeePage';
import { LAZADA_SHOPS } from '../LazadaOrderPage'; import { LAZADA_SHOPS } from '../LazadaOrderPage';
@@ -18,6 +19,7 @@ type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
type StatusFilter = 'completed' | 'all' | 'cancelled'; type StatusFilter = 'completed' | 'all' | 'cancelled';
export default function OmnichannelSalesMapPage() { export default function OmnichannelSalesMapPage() {
const { t, i18n } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('map'); const [viewMode, setViewMode] = useState<ViewMode>('map');
const [channel, setChannel] = useState<ChannelType>('all'); const [channel, setChannel] = useState<ChannelType>('all');
const [tiktokShop, setTiktokShop] = useState('all'); const [tiktokShop, setTiktokShop] = useState('all');
@@ -108,13 +110,20 @@ export default function OmnichannelSalesMapPage() {
if (!mapData?.provinces) return []; if (!mapData?.provinces) return [];
if (!searchTable.trim()) return mapData.provinces; if (!searchTable.trim()) return mapData.provinces;
const q = searchTable.toLowerCase(); const q = searchTable.toLowerCase();
return mapData.provinces.filter( return mapData.provinces.filter((p: any) => {
(p: any) => const zh = getLocalizedProvinceName(p.nameTh, 'zh');
const ja = getLocalizedProvinceName(p.nameTh, 'ja');
const reg = getLocalizedRegionName(p.region || p.regionTh, i18n.language);
return (
p.nameTh.toLowerCase().includes(q) || p.nameTh.toLowerCase().includes(q) ||
p.nameEn.toLowerCase().includes(q) || p.nameEn.toLowerCase().includes(q) ||
p.regionTh.toLowerCase().includes(q) zh.toLowerCase().includes(q) ||
ja.toLowerCase().includes(q) ||
p.regionTh?.toLowerCase().includes(q) ||
reg.toLowerCase().includes(q)
); );
}, [mapData, searchTable]); });
}, [mapData, searchTable, i18n.language]);
const kpi = mapData?.kpi; const kpi = mapData?.kpi;
@@ -128,13 +137,13 @@ export default function OmnichannelSalesMapPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white tracking-tight">Omnichannel Sales Map</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('salesMap.title', 'Omnichannel Sales Map')}</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-gradient-to-r from-rose-500/20 via-orange-500/20 to-blue-500/20 text-orange-300 border border-orange-500/30">
TikTok + Shopee + Lazada TikTok + Shopee + Lazada
</span> </span>
</div> </div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ {t('salesMap.subtitle', 'Business Analytics • แผนที่ยอดขายรวม 77 จังหวัด และระบบเจาะลึกรายอำเภอ 928 อำเภอ')}
</p> </p>
</div> </div>
</div> </div>
@@ -149,7 +158,7 @@ export default function OmnichannelSalesMapPage() {
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`} }`}
> >
<Map className="w-4 h-4" /> มุมมองแผนที่ <Map className="w-4 h-4" /> {t('salesMap.mapView', 'มุมมองแผนที่')}
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
@@ -159,7 +168,7 @@ export default function OmnichannelSalesMapPage() {
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`} }`}
> >
<Table2 className="w-4 h-4" /> ตาราง 77 จังหวัด <Table2 className="w-4 h-4" /> {t('salesMap.listView', 'ตาราง 77 จังหวัด')}
</button> </button>
</div> </div>
</div> </div>
@@ -178,7 +187,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<Globe2 className="w-3.5 h-3.5 text-cyan-400" /> <Globe2 className="w-3.5 h-3.5 text-cyan-400" />
ทุกช่องทาง (Omnichannel) {t('salesMap.allChannels', 'ทุกช่องทาง')}
</button> </button>
<button <button
onClick={() => setChannel('tiktok')} onClick={() => setChannel('tiktok')}
@@ -189,7 +198,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-rose-500" /> <span className="w-2 h-2 rounded-full bg-rose-500" />
เฉพาะ TikTok TikTok
</button> </button>
<button <button
onClick={() => setChannel('shopee')} onClick={() => setChannel('shopee')}
@@ -200,7 +209,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-orange-500" /> <span className="w-2 h-2 rounded-full bg-orange-500" />
เฉพาะ Shopee Shopee
</button> </button>
<button <button
onClick={() => setChannel('lazada')} onClick={() => setChannel('lazada')}
@@ -211,7 +220,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-blue-500" /> <span className="w-2 h-2 rounded-full bg-blue-500" />
เฉพาะ Lazada Lazada
</button> </button>
</div> </div>
@@ -226,7 +235,7 @@ export default function OmnichannelSalesMapPage() {
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ทั้งหมด (All Time) {t('analytics.presetAll', 'ทั้งหมด')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('30days')} onClick={() => handlePeriodChange('30days')}
@@ -236,7 +245,7 @@ export default function OmnichannelSalesMapPage() {
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
30 วันล่าสุด {t('analytics.preset30d', '30 วันล่าสุด')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('this_year')} onClick={() => handlePeriodChange('this_year')}
@@ -246,7 +255,7 @@ export default function OmnichannelSalesMapPage() {
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ปีนี้ {t('analytics.presetThisYear', 'ปีนี้')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('custom')} onClick={() => handlePeriodChange('custom')}
@@ -256,7 +265,7 @@ export default function OmnichannelSalesMapPage() {
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
กำหนดเอง {t('analytics.customRange', 'กำหนดเอง')}
</button> </button>
</div> </div>
</div> </div>
@@ -267,13 +276,13 @@ export default function OmnichannelSalesMapPage() {
{/* TikTok Shop Dropdown */} {/* TikTok Shop Dropdown */}
{(channel === 'all' || channel === 'tiktok') && ( {(channel === 'all' || channel === 'tiktok') && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน TikTok:</span> <span className="text-slate-400">{t('salesMap.tiktokShopLabel', 'ร้าน TikTok:')}</span>
<select <select
value={tiktokShop} value={tiktokShop}
onChange={(e) => setTiktokShop(e.target.value)} onChange={(e) => setTiktokShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-rose-300 font-bold focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-rose-300 font-bold focus:outline-none"
> >
<option value="all">TikTok ทุกร้าน</option> <option value="all">{t('salesMap.allTiktokShops', 'TikTok ทุกร้าน')}</option>
{TIKTOK_SHOPS.map((s) => ( {TIKTOK_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -286,13 +295,13 @@ export default function OmnichannelSalesMapPage() {
{/* Shopee Shop Dropdown */} {/* Shopee Shop Dropdown */}
{(channel === 'all' || channel === 'shopee') && ( {(channel === 'all' || channel === 'shopee') && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน Shopee:</span> <span className="text-slate-400">{t('salesMap.shopeeShopLabel', 'ร้าน Shopee:')}</span>
<select <select
value={shopeeShop} value={shopeeShop}
onChange={(e) => setShopeeShop(e.target.value)} onChange={(e) => setShopeeShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-orange-300 font-bold focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-orange-300 font-bold focus:outline-none"
> >
<option value="all">Shopee ทุกร้าน</option> <option value="all">{t('salesMap.allShopeeShops', 'Shopee ทุกร้าน')}</option>
{SHOPEE_SHOPS.map((s) => ( {SHOPEE_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -305,13 +314,13 @@ export default function OmnichannelSalesMapPage() {
{/* Lazada Shop Dropdown */} {/* Lazada Shop Dropdown */}
{(channel === 'all' || channel === 'lazada') && ( {(channel === 'all' || channel === 'lazada') && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">ร้าน Lazada:</span> <span className="text-slate-400">{t('salesMap.lazadaShopLabel', 'ร้าน Lazada:')}</span>
<select <select
value={lazadaShop} value={lazadaShop}
onChange={(e) => setLazadaShop(e.target.value)} onChange={(e) => setLazadaShop(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-blue-300 font-bold focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-blue-300 font-bold focus:outline-none"
> >
<option value="all">Lazada ทุกร้าน</option> <option value="all">{t('salesMap.allLazadaShops', 'Lazada ทุกร้าน')}</option>
{LAZADA_SHOPS.map((s) => ( {LAZADA_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
{s.label} {s.label}
@@ -324,7 +333,7 @@ export default function OmnichannelSalesMapPage() {
{/* Status Filter */} {/* Status Filter */}
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10"> <div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1"> <span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
<Filter className="w-3 h-3 text-slate-400" /> สถานะ: <Filter className="w-3 h-3 text-slate-400" /> {t('orders.status', 'สถานะ')}:
</span> </span>
<button <button
type="button" type="button"
@@ -336,7 +345,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> <CheckCircle2 className="w-3 h-3 text-emerald-400" />
สำเร็จ (ไม่รวมยกเลิก) {t('salesMap.completedOnly', 'สำเร็จ (ไม่รวมยกเลิก)')}
</button> </button>
<button <button
type="button" type="button"
@@ -347,7 +356,7 @@ export default function OmnichannelSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
ทุกสถานะ (รวมยกเลิก) {t('salesMap.allStatuses', 'ทุกสถานะ (รวมยกเลิก)')}
</button> </button>
<button <button
type="button" type="button"
@@ -359,7 +368,7 @@ export default function OmnichannelSalesMapPage() {
}`} }`}
> >
<XCircle className="w-3 h-3 text-rose-400" /> <XCircle className="w-3 h-3 text-rose-400" />
เฉพาะที่ยกเลิก {t('salesMap.cancelledOnly', 'เฉพาะที่ยกเลิก')}
</button> </button>
</div> </div>
</div> </div>
@@ -367,14 +376,14 @@ export default function OmnichannelSalesMapPage() {
{/* Custom Date Range */} {/* Custom Date Range */}
{period === 'custom' && ( {period === 'custom' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">ตั้งแต่วันที่:</span> <span className="text-slate-400">{t('analytics.customRange', 'ตั้งแต่วันที่')}:</span>
<input <input
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/> />
<span className="text-slate-400">ถึง</span> <span className="text-slate-400">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" type="date"
value={dateTo} value={dateTo}
@@ -391,7 +400,7 @@ export default function OmnichannelSalesMapPage() {
{/* Total Revenue & Channel Split */} {/* Total Revenue & Channel Split */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวมทุกช่องทาง</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.totalRevenue', 'ยอดขายรวม')}</p>
<DollarSign className="w-4 h-4 text-emerald-400" /> <DollarSign className="w-4 h-4 text-emerald-400" />
</div> </div>
<div> <div>
@@ -429,12 +438,12 @@ export default function OmnichannelSalesMapPage() {
{/* Total Orders & Channel Count */} {/* Total Orders & Channel Count */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">คำสั่งซื้อทั้งหมด</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.totalOrders', 'คำสั่งซื้อทั้งหมด')}</p>
<ShoppingBag className="w-4 h-4 text-orange-400" /> <ShoppingBag className="w-4 h-4 text-orange-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white font-mono"> <p className="text-2xl font-bold text-white font-mono">
{fmtNum(kpi?.totalOrders || 0)} <span className="text-xs font-normal text-slate-400">ออเดอร์</span> {fmtNum(kpi?.totalOrders || 0)} <span className="text-xs font-normal text-slate-400">{t('salesMap.orders', 'ออเดอร์')}</span>
</p> </p>
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1"> <div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokOrders || 0)}</span> <span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokOrders || 0)}</span>
@@ -447,15 +456,15 @@ export default function OmnichannelSalesMapPage() {
{/* Province Coverage */} {/* Province Coverage */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การเข้าถึงพื้นที่</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.penetration', 'การเข้าถึงพื้นที่')}</p>
<Map className="w-4 h-4 text-cyan-400" /> <Map className="w-4 h-4 text-cyan-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-cyan-400 font-mono"> <p className="text-2xl font-bold text-cyan-400 font-mono">
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span> {kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 {t('salesMap.province', 'จังหวัด')}</span>
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span>
</p> </p>
</div> </div>
</div> </div>
@@ -463,7 +472,7 @@ export default function OmnichannelSalesMapPage() {
{/* AOV (Average Order Value) */} {/* AOV (Average Order Value) */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดซื้อเฉลี่ย (AOV)</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.aov', 'ยอดซื้อเฉลี่ย (AOV)')}</p>
<TrendingUp className="w-4 h-4 text-amber-400" /> <TrendingUp className="w-4 h-4 text-amber-400" />
</div> </div>
<div> <div>
@@ -481,12 +490,12 @@ export default function OmnichannelSalesMapPage() {
{/* Top Region */} {/* Top Region */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.topProvince', 'ภูมิภาคยอดขายสูงสุด')}</p>
<Award className="w-4 h-4 text-rose-400" /> <Award className="w-4 h-4 text-rose-400" />
</div> </div>
<div> <div>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{kpi?.topRegion?.name || '-'} {getLocalizedRegionName(kpi?.topRegion?.name, i18n.language) || '-'}
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
{fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์ {fmtBaht(kpi?.topRegion?.revenue || 0)} • {fmtNum(kpi?.topRegion?.orders || 0)} ออเดอร์
@@ -522,7 +531,7 @@ export default function OmnichannelSalesMapPage() {
<div className="flex-1 flex items-center justify-center"> <div className="flex-1 flex items-center justify-center">
{loading ? ( {loading ? (
<div className="py-24 text-center text-slate-400 text-sm"> <div className="py-24 text-center text-slate-400 text-sm">
<span className="animate-spin inline-block mr-2">⏳</span> กำลังประมวลผลข้อมูลแผนที่... {t('salesMap.loadingMap', 'กำลังประมวลผลข้อมูลแผนที่...')}
</div> </div>
) : ( ) : (
<ThailandMapSvg <ThailandMapSvg
@@ -541,21 +550,25 @@ export default function OmnichannelSalesMapPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-orange-400" /> <MapPin className="w-5 h-5 text-orange-400" />
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4> <h4 className="text-xl font-bold text-white">
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span> {i18n.language === 'th' ? `จังหวัด${selectedProvinceDetail.nameTh}` : getLocalizedProvinceName(selectedProvinceDetail.nameTh, i18n.language)}
</h4>
<span className="text-xs font-mono text-slate-400">
({i18n.language === 'en' ? selectedProvinceDetail.nameTh : selectedProvinceDetail.nameEn})
</span>
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300"> <span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
{selectedProvinceDetail.regionTh} {getLocalizedRegionName(selectedProvinceDetail.region || selectedProvinceDetail.regionTh, i18n.language)}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap"> <div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
<span>ยอดรวม: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span> <span>{t('salesMap.totalRevenue', 'ยอดรวม')}: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.orders}</strong> รายการ</span> <span>{t('salesMap.orders', 'คำสั่งซื้อ')}: <strong className="text-white text-sm">{selectedProvinceDetail.orders}</strong></span>
<span>AOV: <strong className="text-amber-400">{fmtBaht(selectedProvinceDetail.aov)}</strong></span> <span>AOV: <strong className="text-amber-400">{fmtBaht(selectedProvinceDetail.aov)}</strong></span>
</div> </div>
{/* Platform Breakdown Mini Bar */} {/* Platform Breakdown Mini Bar */}
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap"> <div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
<span className="text-[11px] text-slate-400">สัดส่วนยอดขาย:</span> <span className="text-[11px] text-slate-400">{t('salesMap.share', 'สัดส่วนยอดขาย')}:</span>
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex"> <div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
<div <div
className="h-full bg-rose-500" className="h-full bg-rose-500"
@@ -583,7 +596,7 @@ export default function OmnichannelSalesMapPage() {
onClick={() => setShowDistrictModal(true)} onClick={() => setShowDistrictModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-rose-600 hover:from-orange-600 hover:to-rose-700 text-white font-bold text-sm shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2 transition-all shrink-0" className="px-6 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-rose-600 hover:from-orange-600 hover:to-rose-700 text-white font-bold text-sm shadow-lg shadow-orange-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
> >
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span> <span>{t('salesMap.viewDistricts', 'ดูรายละเอียดและแผนที่อำเภอ')}</span>
<ArrowUpRight className="w-4 h-4" /> <ArrowUpRight className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -598,7 +611,7 @@ export default function OmnichannelSalesMapPage() {
<div className="flex items-center justify-between pb-3 border-b border-white/10"> <div className="flex items-center justify-between pb-3 border-b border-white/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Award className="w-4 h-4 text-orange-400" /> <Award className="w-4 h-4 text-orange-400" />
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดยอดขายสูงสุด</h3> <h3 className="font-bold text-white text-sm">{t('salesMap.topProvince', 'Top 20 จังหวัดยอดขายสูงสุด')}</h3>
</div> </div>
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">Omnichannel</span> <span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">Omnichannel</span>
</div> </div>
@@ -628,8 +641,10 @@ export default function OmnichannelSalesMapPage() {
{item.rank} {item.rank}
</span> </span>
<div> <div>
<p className="text-xs font-bold text-white">{item.nameTh}</p> <p className="text-xs font-bold text-white">
<p className="text-[10px] text-slate-400 font-mono">{item.orders} ออเดอร์</p> {getLocalizedProvinceName(item.nameTh, i18n.language)}
</p>
<p className="text-[10px] text-slate-400 font-mono">{item.orders} {t('salesMap.orders', 'ออเดอร์')}</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -660,14 +675,16 @@ export default function OmnichannelSalesMapPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center gap-2 pb-3 border-b border-white/10"> <div className="flex items-center gap-2 pb-3 border-b border-white/10">
<Layers className="w-4 h-4 text-cyan-400" /> <Layers className="w-4 h-4 text-cyan-400" />
<h3 className="font-bold text-white text-sm">สัดส่วนยอดขายตามภูมิภาค</h3> <h3 className="font-bold text-white text-sm">{t('salesMap.regionalBreakdown', 'สัดส่วนยอดขายตามภูมิภาค')}</h3>
</div> </div>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{mapData?.regionBreakdown?.map((reg: any) => ( {mapData?.regionBreakdown?.map((reg: any) => (
<div key={reg.region} className="space-y-1"> <div key={reg.region} className="space-y-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-white font-medium">{reg.regionTh}</span> <span className="text-white font-medium">
{getLocalizedRegionName(reg.region || reg.regionTh, i18n.language)}
</span>
<span className="font-mono text-emerald-400 font-bold"> <span className="font-mono text-emerald-400 font-bold">
{fmtBaht(reg.revenue)}{' '} {fmtBaht(reg.revenue)}{' '}
<span className="text-slate-400 font-normal">({reg.percentage?.toFixed(1)}%)</span> <span className="text-slate-400 font-normal">({reg.percentage?.toFixed(1)}%)</span>
@@ -688,7 +705,7 @@ export default function OmnichannelSalesMapPage() {
/> />
</div> </div>
<div className="flex justify-between text-[10px] text-slate-500 font-mono"> <div className="flex justify-between text-[10px] text-slate-500 font-mono">
<span>{reg.orders} ออเดอร์</span> <span>{reg.orders} {t('salesMap.orders', 'ออเดอร์')}</span>
<span className="text-slate-400"> <span className="text-slate-400">
TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)} TT ฿{fmtNum(reg.tiktokRevenue)} | SP ฿{fmtNum(reg.shopeeRevenue)} | LZ ฿{fmtNum(reg.lazadaRevenue)}
</span> </span>
@@ -707,14 +724,14 @@ export default function OmnichannelSalesMapPage() {
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาชื่อจังหวัด หรือภาค..." placeholder={t('salesMap.searchProvince', 'ค้นหาชื่อจังหวัด หรือภาค...')}
value={searchTable} value={searchTable}
onChange={(e) => setSearchTable(e.target.value)} onChange={(e) => setSearchTable(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40" className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-orange-500/40"
/> />
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด <span className="text-white font-bold">{filteredProvinces.length}</span> / 77 {t('salesMap.province', 'จังหวัด')}
</p> </p>
</div> </div>
@@ -722,16 +739,16 @@ export default function OmnichannelSalesMapPage() {
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase"> <thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
<tr> <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">{t('salesMap.province', 'จังหวัด')}</th>
<th className="px-4 py-3">ภูมิภาค</th> <th className="px-4 py-3">{t('salesMap.region', 'ภูมิภาค')}</th>
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th> <th className="px-4 py-3 text-right">{t('salesMap.totalRevenue', 'ยอดรวม')} (฿)</th>
<th className="px-4 py-3 text-right">ออเดอร์รวม</th> <th className="px-4 py-3 text-right">{t('salesMap.totalOrders', 'ออเดอร์รวม')}</th>
<th className="px-4 py-3 text-right">TikTok (฿)</th> <th className="px-4 py-3 text-right">TikTok (฿)</th>
<th className="px-4 py-3 text-right">Shopee (฿)</th> <th className="px-4 py-3 text-right">Shopee (฿)</th>
<th className="px-4 py-3 text-right">Lazada (฿)</th> <th className="px-4 py-3 text-right">Lazada (฿)</th>
<th className="px-4 py-3 text-right">AOV (฿)</th> <th className="px-4 py-3 text-right">AOV (฿)</th>
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th> <th className="px-4 py-3 text-center">{t('salesMap.share', 'สัดส่วน')}</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
@@ -739,7 +756,7 @@ export default function OmnichannelSalesMapPage() {
{filteredProvinces.length === 0 ? ( {filteredProvinces.length === 0 ? (
<tr> <tr>
<td colSpan={11} className="px-4 py-12 text-center text-slate-500"> <td colSpan={11} className="px-4 py-12 text-center text-slate-500">
ไม่พบข้อมูลจังหวัดตามคำค้นหา {t('salesMap.noData', 'ไม่พบข้อมูลจังหวัดตามคำค้นหา')}
</td> </td>
</tr> </tr>
) : ( ) : (
@@ -747,18 +764,21 @@ export default function OmnichannelSalesMapPage() {
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors"> <tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td> <td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white"> <td className="px-4 py-3 font-bold text-white">
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span> {getLocalizedProvinceName(p.nameTh, i18n.language)}{' '}
<span className="text-slate-500 text-[10px] font-normal font-mono">
({i18n.language === 'en' ? p.nameTh : p.nameEn})
</span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10"> <span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
{p.regionTh} {getLocalizedRegionName(p.region || p.regionTh, i18n.language)}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400"> <td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'} {p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
</td> </td>
<td className="px-4 py-3 text-right font-mono text-white"> <td className="px-4 py-3 text-right font-mono text-white">
{p.orders > 0 ? `${p.orders} ออเดอร์` : '-'} {p.orders > 0 ? `${p.orders} ${t('salesMap.orders', 'ออเดอร์')}` : '-'}
</td> </td>
<td className="px-4 py-3 text-right font-mono text-rose-400"> <td className="px-4 py-3 text-right font-mono text-rose-400">
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
@@ -798,7 +818,7 @@ export default function OmnichannelSalesMapPage() {
}} }}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-orange-500/20 hover:text-orange-300 text-slate-400 border border-white/10 text-xs transition-colors" className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-orange-500/20 hover:text-orange-300 text-slate-400 border border-white/10 text-xs transition-colors"
> >
ดูรายอำเภอ {t('salesMap.viewDistricts', 'ดูรายอำเภอ')}
</button> </button>
</td> </td>
</tr> </tr>
@@ -5,10 +5,11 @@ import {
MapPin, Globe2, Layers, ArrowUpRight, MapPin, Globe2, Layers, ArrowUpRight,
Package, ChevronDown, Loader2 Package, ChevronDown, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg'; import ThailandMapSvg from '../../components/tiktok-map/ThailandMapSvg';
import ProductDistrictModal from '../../components/analytics/ProductDistrictModal'; import ProductDistrictModal from '../../components/analytics/ProductDistrictModal';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; import { fmtBaht, fmtNum, getLocalizedProvinceName, getLocalizedRegionName } from '../../utils/thaiGeoUtils';
type ViewMode = 'map' | 'list'; type ViewMode = 'map' | 'list';
type TimePeriod = 'all' | '30days' | 'this_year' | 'custom'; type TimePeriod = 'all' | '30days' | 'this_year' | 'custom';
@@ -39,6 +40,7 @@ interface ProductItem {
} }
export default function ProductSalesMapPage() { export default function ProductSalesMapPage() {
const { t, i18n } = useTranslation();
const [viewMode, setViewMode] = useState<ViewMode>('map'); const [viewMode, setViewMode] = useState<ViewMode>('map');
const [metric, setMetric] = useState<MetricType>('revenue'); const [metric, setMetric] = useState<MetricType>('revenue');
const [channel, setChannel] = useState<ChannelType>('all'); const [channel, setChannel] = useState<ChannelType>('all');
@@ -185,13 +187,20 @@ export default function ProductSalesMapPage() {
if (!mapData?.provinces) return []; if (!mapData?.provinces) return [];
if (!searchTable.trim()) return mapData.provinces; if (!searchTable.trim()) return mapData.provinces;
const q = searchTable.toLowerCase(); const q = searchTable.toLowerCase();
return mapData.provinces.filter( return mapData.provinces.filter((p: any) => {
(p: any) => const zh = getLocalizedProvinceName(p.nameTh, 'zh');
const ja = getLocalizedProvinceName(p.nameTh, 'ja');
const reg = getLocalizedRegionName(p.region || p.regionTh, i18n.language);
return (
p.nameTh.toLowerCase().includes(q) || p.nameTh.toLowerCase().includes(q) ||
p.nameEn.toLowerCase().includes(q) || p.nameEn.toLowerCase().includes(q) ||
p.regionTh.toLowerCase().includes(q) zh.toLowerCase().includes(q) ||
ja.toLowerCase().includes(q) ||
p.regionTh?.toLowerCase().includes(q) ||
reg.toLowerCase().includes(q)
); );
}, [mapData, searchTable]); });
}, [mapData, searchTable, i18n.language]);
const kpi = mapData?.kpi; const kpi = mapData?.kpi;
@@ -205,13 +214,13 @@ export default function ProductSalesMapPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h1 className="text-3xl font-bold text-white tracking-tight">Product Sales Map</h1> <h1 className="text-3xl font-bold text-white tracking-tight">{t('salesMap.productTitle', 'Product Sales Map')}</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
Geo-Intelligence by Product {t('salesMap.productBadge', 'Geo-Intelligence by Product')}
</span> </span>
</div> </div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ {t('salesMap.productSubtitle', 'Business Analytics • วิเคราะห์ความหนาแน่นและพื้นที่ยอดขายของสินค้าแต่ละรายการใน 77 จังหวัด และ 928 อำเภอ')}
</p> </p>
</div> </div>
</div> </div>
@@ -228,7 +237,7 @@ export default function ProductSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
฿ ดูตามยอดขาย {t('salesMap.viewByRevenue', '฿ ดูตามยอดขาย')}
</button> </button>
<button <button
onClick={() => setMetric('quantity')} onClick={() => setMetric('quantity')}
@@ -238,7 +247,7 @@ export default function ProductSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
📦 ดูตามจำนวนชิ้น {t('salesMap.viewByQuantity', '📦 ดูตามจำนวนชิ้น')}
</button> </button>
</div> </div>
@@ -252,7 +261,7 @@ export default function ProductSalesMapPage() {
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`} }`}
> >
<Map className="w-4 h-4" /> แผนที่ <Map className="w-4 h-4" /> {t('salesMap.mapView', 'แผนที่')}
</button> </button>
<button <button
onClick={() => setViewMode('list')} onClick={() => setViewMode('list')}
@@ -262,7 +271,7 @@ export default function ProductSalesMapPage() {
: 'text-slate-400 hover:text-white bg-white/5 border border-white/10' : 'text-slate-400 hover:text-white bg-white/5 border border-white/10'
}`} }`}
> >
<Table2 className="w-4 h-4" /> ตาราง <Table2 className="w-4 h-4" /> {t('salesMap.listView', 'ตาราง 77 จังหวัด')}
</button> </button>
</div> </div>
</div> </div>
@@ -272,7 +281,7 @@ export default function ProductSalesMapPage() {
<div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-4 backdrop-blur-md relative z-30"> <div className="bg-gradient-to-r from-indigo-500/10 via-purple-500/10 to-slate-900 border border-indigo-500/30 rounded-2xl p-4 backdrop-blur-md relative z-30">
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4"> <div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 relative"> <div className="flex items-center gap-3 flex-1 relative">
<span className="text-xs font-bold text-slate-300 shrink-0">เลือกสินค้า Master:</span> <span className="text-xs font-bold text-slate-300 shrink-0">{t('salesMap.selectMasterProduct', 'เลือกสินค้า Master:')}</span>
{/* Product Selector Dropdown Button */} {/* Product Selector Dropdown Button */}
<div className="relative flex-1 max-w-xl"> <div className="relative flex-1 max-w-xl">
@@ -283,7 +292,7 @@ export default function ProductSalesMapPage() {
<div className="flex items-center gap-2 truncate"> <div className="flex items-center gap-2 truncate">
<Package className="w-4 h-4 text-indigo-400 shrink-0" /> <Package className="w-4 h-4 text-indigo-400 shrink-0" />
<span className="truncate"> <span className="truncate">
{loadingProducts ? 'กำลังโหลดสินค้า...' : currentProduct ? currentProduct.name : 'เลือกสินค้า'} {loadingProducts ? t('products.loadingProducts', 'กำลังโหลดสินค้า...') : currentProduct ? currentProduct.name : t('salesMap.selectProduct', 'เลือกสินค้า')}
</span> </span>
{currentProduct?.category && ( {currentProduct?.category && (
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-normal"> <span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300 font-normal">
@@ -301,7 +310,7 @@ export default function ProductSalesMapPage() {
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาชื่อสินค้าหลัก, SKU, หมวดหมู่..." placeholder={t('salesMap.searchProduct', 'ค้นหาชื่อสินค้าหลัก, SKU, หมวดหมู่...')}
value={productSearch} value={productSearch}
onChange={(e) => setProductSearch(e.target.value)} onChange={(e) => setProductSearch(e.target.value)}
className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-black/50 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" className="w-full pl-9 pr-3 py-1.5 rounded-xl bg-black/50 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
@@ -330,12 +339,12 @@ export default function ProductSalesMapPage() {
<p className="truncate font-semibold">{prod.name}</p> <p className="truncate font-semibold">{prod.name}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-400"> <div className="flex items-center gap-2 text-[10px] text-slate-400">
<span>{prod.category}</span> <span>{prod.category}</span>
<span>• {prod.variants.length} ตัวเลือก</span> <span>• {prod.variants.length} {t('products.variants', 'ตัวเลือก')}</span>
</div> </div>
</div> </div>
<div className="text-right shrink-0 font-mono text-[11px]"> <div className="text-right shrink-0 font-mono text-[11px]">
<p className="text-emerald-400 font-bold">{fmtBaht(prod.totalRevenue)}</p> <p className="text-emerald-400 font-bold">{fmtBaht(prod.totalRevenue)}</p>
<p className="text-[10px] text-slate-400">{fmtNum(prod.totalSoldUnits)} ชิ้น</p> <p className="text-[10px] text-slate-400">{fmtNum(prod.totalSoldUnits)} {t('salesMap.unitsSold', 'ชิ้น')}</p>
</div> </div>
</div> </div>
); );
@@ -349,7 +358,7 @@ export default function ProductSalesMapPage() {
{/* Variant Selector Tabs */} {/* Variant Selector Tabs */}
{currentProduct && currentProduct.variants.length > 0 && ( {currentProduct && currentProduct.variants.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap text-xs"> <div className="flex items-center gap-1.5 flex-wrap text-xs">
<span className="text-slate-400 mr-1">ขนาด/ตัวเลือก:</span> <span className="text-slate-400 mr-1">{t('salesMap.variantOptions', 'ขนาด/ตัวเลือก:')}</span>
<button <button
onClick={() => setSelectedVariantId(null)} onClick={() => setSelectedVariantId(null)}
className={`px-3 py-1 rounded-lg font-bold border transition-all ${ className={`px-3 py-1 rounded-lg font-bold border transition-all ${
@@ -358,7 +367,7 @@ export default function ProductSalesMapPage() {
: 'bg-black/30 text-slate-400 border-white/10 hover:text-white' : 'bg-black/30 text-slate-400 border-white/10 hover:text-white'
}`} }`}
> >
ทุกตัวเลือกรวม ({currentProduct.variants.length}) {t('salesMap.allVariantsTotal', 'ทุกตัวเลือกรวม')} ({currentProduct.variants.length})
</button> </button>
{currentProduct.variants.map((v) => { {currentProduct.variants.map((v) => {
@@ -394,7 +403,7 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<Globe2 className="w-3.5 h-3.5 text-cyan-400" /> <Globe2 className="w-3.5 h-3.5 text-cyan-400" />
ทุกช่องทาง (Omnichannel) {t('salesMap.allChannels', 'ทุกช่องทาง (Omnichannel)')}
</button> </button>
<button <button
onClick={() => setChannel('tiktok')} onClick={() => setChannel('tiktok')}
@@ -405,7 +414,7 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-rose-500" /> <span className="w-2 h-2 rounded-full bg-rose-500" />
เฉพาะ TikTok {t('salesMap.tiktokOnly', 'เฉพาะ TikTok')}
</button> </button>
<button <button
onClick={() => setChannel('shopee')} onClick={() => setChannel('shopee')}
@@ -416,7 +425,7 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-orange-500" /> <span className="w-2 h-2 rounded-full bg-orange-500" />
เฉพาะ Shopee {t('salesMap.shopeeOnly', 'เฉพาะ Shopee')}
</button> </button>
<button <button
onClick={() => setChannel('lazada')} onClick={() => setChannel('lazada')}
@@ -427,7 +436,7 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<span className="w-2 h-2 rounded-full bg-blue-500" /> <span className="w-2 h-2 rounded-full bg-blue-500" />
เฉพาะ Lazada {t('salesMap.lazadaOnly', 'เฉพาะ Lazada')}
</button> </button>
</div> </div>
@@ -440,7 +449,7 @@ export default function ProductSalesMapPage() {
period === 'all' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' period === 'all' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ทั้งหมด (All Time) {t('analytics.presetAll', 'ทั้งหมด')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('30days')} onClick={() => handlePeriodChange('30days')}
@@ -448,7 +457,7 @@ export default function ProductSalesMapPage() {
period === '30days' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' period === '30days' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
30 วันล่าสุด {t('analytics.preset30d', '30 วันล่าสุด')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('this_year')} onClick={() => handlePeriodChange('this_year')}
@@ -456,7 +465,7 @@ export default function ProductSalesMapPage() {
period === 'this_year' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' period === 'this_year' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ปีนี้ {t('analytics.presetThisYear', 'ปีนี้')}
</button> </button>
<button <button
onClick={() => handlePeriodChange('custom')} onClick={() => handlePeriodChange('custom')}
@@ -464,7 +473,7 @@ export default function ProductSalesMapPage() {
period === 'custom' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white' period === 'custom' ? 'bg-white/15 text-white border-white/20' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
กำหนดเอง {t('analytics.customRange', 'กำหนดเอง')}
</button> </button>
</div> </div>
</div> </div>
@@ -474,7 +483,7 @@ export default function ProductSalesMapPage() {
{/* Status Filter */} {/* Status Filter */}
<div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10"> <div className="flex items-center gap-1.5 bg-black/40 p-1 rounded-xl border border-white/10">
<span className="text-slate-400 text-xs px-1.5 flex items-center gap-1"> <span className="text-slate-400 text-xs px-1.5 flex items-center gap-1">
<Filter className="w-3 h-3 text-slate-400" /> สถานะ: <Filter className="w-3 h-3 text-slate-400" /> {t('orders.status', 'สถานะ')}:
</span> </span>
<button <button
type="button" type="button"
@@ -486,7 +495,7 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<CheckCircle2 className="w-3 h-3 text-emerald-400" /> <CheckCircle2 className="w-3 h-3 text-emerald-400" />
สำเร็จ (ไม่รวมยกเลิก) {t('salesMap.completedOnly', 'สำเร็จ (ไม่รวมยกเลิก)')}
</button> </button>
<button <button
type="button" type="button"
@@ -497,7 +506,7 @@ export default function ProductSalesMapPage() {
: 'text-slate-400 hover:text-white' : 'text-slate-400 hover:text-white'
}`} }`}
> >
ทุกสถานะ (รวมยกเลิก) {t('salesMap.allStatuses', 'ทุกสถานะ (รวมยกเลิก)')}
</button> </button>
<button <button
type="button" type="button"
@@ -509,21 +518,21 @@ export default function ProductSalesMapPage() {
}`} }`}
> >
<XCircle className="w-3 h-3 text-rose-400" /> <XCircle className="w-3 h-3 text-rose-400" />
เฉพาะที่ยกเลิก {t('salesMap.cancelledOnly', 'เฉพาะที่ยกเลิก')}
</button> </button>
</div> </div>
{/* Custom Date Range */} {/* Custom Date Range */}
{period === 'custom' && ( {period === 'custom' && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">ตั้งแต่วันที่:</span> <span className="text-slate-400">{t('analytics.customRange', 'ตั้งแต่วันที่')}:</span>
<input <input
type="date" type="date"
value={dateFrom} value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)} onChange={(e) => setDateFrom(e.target.value)}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-mono focus:outline-none"
/> />
<span className="text-slate-400">ถึง</span> <span className="text-slate-400">{t('analytics.to', 'ถึง')}</span>
<input <input
type="date" type="date"
value={dateTo} value={dateTo}
@@ -540,7 +549,7 @@ export default function ProductSalesMapPage() {
{/* Product Total Revenue */} {/* Product Total Revenue */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายสินค้านี้รวม</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.productTotalRevenue', 'ยอดขายสินค้านี้รวม')}</p>
<DollarSign className="w-4 h-4 text-emerald-400" /> <DollarSign className="w-4 h-4 text-emerald-400" />
</div> </div>
<div> <div>
@@ -578,12 +587,12 @@ export default function ProductSalesMapPage() {
{/* Total Units Sold */} {/* Total Units Sold */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.totalUnitsSold', 'จำนวนชิ้นที่ขายได้')}</p>
<ShoppingBag className="w-4 h-4 text-amber-400" /> <ShoppingBag className="w-4 h-4 text-amber-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-amber-400 font-mono"> <p className="text-2xl font-bold text-amber-400 font-mono">
{fmtNum(kpi?.totalUnitsSold || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span> {fmtNum(kpi?.totalUnitsSold || 0)} <span className="text-xs font-normal text-slate-400">{t('salesMap.unitsSold', 'ชิ้น')}</span>
</p> </p>
<div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1"> <div className="flex items-center justify-between text-[11px] text-slate-400 mt-2 pt-2 border-t border-white/5 font-mono flex-wrap gap-1">
<span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokUnits || 0)}</span> <span className="text-rose-400">TT: {fmtNum(kpi?.totalTiktokUnits || 0)}</span>
@@ -596,15 +605,15 @@ export default function ProductSalesMapPage() {
{/* Province Coverage */} {/* Province Coverage */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">การกระจายตัวในจังหวัด</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.provinceCoverage', 'การกระจายตัวในจังหวัด')}</p>
<Map className="w-4 h-4 text-cyan-400" /> <Map className="w-4 h-4 text-cyan-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-cyan-400 font-mono"> <p className="text-2xl font-bold text-cyan-400 font-mono">
{kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 จังหวัด</span> {kpi?.coverageCount || 0} <span className="text-sm font-normal text-slate-400">/ 77 {t('salesMap.province', 'จังหวัด')}</span>
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
ครอบคลุม <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span> ของประเทศไทย {t('salesMap.share', 'ครอบคลุม')} <span className="text-white font-bold">{kpi?.coveragePercent?.toFixed(1)}%</span>
</p> </p>
</div> </div>
</div> </div>
@@ -612,15 +621,15 @@ export default function ProductSalesMapPage() {
{/* Top Province */} {/* Top Province */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จังหวัดที่ขายดีที่สุด</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.topSellingProvince', 'จังหวัดที่ขายดีที่สุด')}</p>
<Award className="w-4 h-4 text-rose-400" /> <Award className="w-4 h-4 text-rose-400" />
</div> </div>
<div> <div>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{kpi?.topProvince?.name ? `จ.${kpi.topProvince.name}` : '-'} {kpi?.topProvince?.name ? getLocalizedProvinceName(kpi.topProvince.name, i18n.language) : '-'}
</p> </p>
<p className="text-xs text-slate-400 mt-1 font-mono"> <p className="text-xs text-slate-400 mt-1 font-mono">
{fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} ชิ้น {fmtBaht(kpi?.topProvince?.revenue || 0)} • {fmtNum(kpi?.topProvince?.unitsSold || 0)} {t('salesMap.unitsSold', 'ชิ้น')}
</p> </p>
</div> </div>
</div> </div>
@@ -628,12 +637,12 @@ export default function ProductSalesMapPage() {
{/* Top Region */} {/* Top Region */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ภูมิภาคยอดขายสูงสุด</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">{t('salesMap.topSellingRegion', 'ภูมิภาคยอดขายสูงสุด')}</p>
<Layers className="w-4 h-4 text-indigo-400" /> <Layers className="w-4 h-4 text-indigo-400" />
</div> </div>
<div> <div>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{kpi?.topRegion?.name || '-'} {kpi?.topRegion?.name ? getLocalizedRegionName(kpi.topRegion.name, i18n.language) : '-'}
</p> </p>
<p className="text-xs text-slate-400 mt-1 font-mono"> <p className="text-xs text-slate-400 mt-1 font-mono">
{fmtBaht(kpi?.topRegion?.revenue || 0)} {fmtBaht(kpi?.topRegion?.revenue || 0)}
@@ -652,17 +661,17 @@ export default function ProductSalesMapPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Map className="w-4 h-4 text-indigo-400" /> <Map className="w-4 h-4 text-indigo-400" />
<h3 className="font-bold text-white text-sm"> <h3 className="font-bold text-white text-sm">
แผนที่ความหนาแน่น {metric === 'quantity' ? 'จำนวนชิ้นที่ขาย (Units)' : 'ยอดขาย (Revenue)'} {t('salesMap.densityMap', 'แผนที่ความหนาแน่น')} {metric === 'quantity' ? `(${t('salesMap.unitsSold', 'จำนวนชิ้น')})` : `(${t('salesMap.revenue', 'ยอดขาย')})`}
</h3> </h3>
</div> </div>
<div className="text-xs text-slate-400 flex items-center gap-4"> <div className="text-xs text-slate-400 flex items-center gap-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" /> <span className="w-2.5 h-2.5 rounded bg-emerald-950 border border-emerald-800" />
<span>น้อย</span> <span>{t('salesMap.less', 'น้อย')}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded bg-emerald-500" /> <span className="w-2.5 h-2.5 rounded bg-emerald-500" />
<span>มาก</span> <span>{t('salesMap.more', 'มาก')}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -672,7 +681,7 @@ export default function ProductSalesMapPage() {
{loading ? ( {loading ? (
<div className="py-24 text-center text-slate-400 text-sm"> <div className="py-24 text-center text-slate-400 text-sm">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-indigo-400" /> <Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-indigo-400" />
กำลังประมวลผลข้อมูลแผนที่สินค้า... {t('salesMap.loadingMap', 'กำลังประมวลผลข้อมูลแผนที่สินค้า...')}
</div> </div>
) : ( ) : (
<ThailandMapSvg <ThailandMapSvg
@@ -691,21 +700,25 @@ export default function ProductSalesMapPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="w-5 h-5 text-indigo-400" /> <MapPin className="w-5 h-5 text-indigo-400" />
<h4 className="text-xl font-bold text-white">จังหวัด{selectedProvinceDetail.nameTh}</h4> <h4 className="text-xl font-bold text-white">
<span className="text-xs font-mono text-slate-400">({selectedProvinceDetail.nameEn})</span> {i18n.language === 'th' ? `จังหวัด${selectedProvinceDetail.nameTh}` : getLocalizedProvinceName(selectedProvinceDetail.nameTh, i18n.language)}
</h4>
<span className="text-xs font-mono text-slate-400">
({i18n.language === 'en' ? selectedProvinceDetail.nameTh : selectedProvinceDetail.nameEn})
</span>
<span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300"> <span className="px-2 py-0.5 rounded text-[10px] bg-white/10 text-slate-300">
{selectedProvinceDetail.regionTh} {getLocalizedRegionName(selectedProvinceDetail.region || selectedProvinceDetail.regionTh, i18n.language)}
</span> </span>
</div> </div>
<div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap"> <div className="flex items-center gap-4 text-xs text-slate-300 font-mono pt-1 flex-wrap">
<span>ยอดขาย: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span> <span>{t('salesMap.revenue', 'ยอดขาย')}: <strong className="text-emerald-400 text-sm">{fmtBaht(selectedProvinceDetail.revenue)}</strong></span>
<span>จำนวนที่ขาย: <strong className="text-amber-400 text-sm">{selectedProvinceDetail.unitsSold}</strong> ชิ้น</span> <span>{t('salesMap.unitsSold', 'จำนวนที่ขาย')}: <strong className="text-amber-400 text-sm">{selectedProvinceDetail.unitsSold}</strong> {t('salesMap.unitsSold', 'ชิ้น')}</span>
<span>คำสั่งซื้อ: <strong className="text-white text-sm">{selectedProvinceDetail.ordersCount}</strong> ออเดอร์</span> <span>{t('salesMap.orders', 'คำสั่งซื้อ')}: <strong className="text-white text-sm">{selectedProvinceDetail.ordersCount}</strong> {t('salesMap.orders', 'ออเดอร์')}</span>
</div> </div>
{/* Platform Breakdown Mini Bar */} {/* Platform Breakdown Mini Bar */}
<div className="pt-2 flex items-center gap-3 text-xs flex-wrap"> <div className="pt-2 flex items-center gap-3 text-xs flex-wrap">
<span className="text-[11px] text-slate-400">สัดส่วนช่องทาง:</span> <span className="text-[11px] text-slate-400">{t('salesMap.share', 'สัดส่วนช่องทาง')}:</span>
<div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex"> <div className="w-48 h-2 bg-slate-800 rounded-full overflow-hidden flex">
<div <div
className="h-full bg-rose-500" className="h-full bg-rose-500"
@@ -733,7 +746,7 @@ export default function ProductSalesMapPage() {
onClick={() => setShowDistrictModal(true)} onClick={() => setShowDistrictModal(true)}
className="px-6 py-3 rounded-xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:to-pink-600 text-white font-bold text-sm shadow-lg shadow-indigo-500/20 flex items-center justify-center gap-2 transition-all shrink-0" className="px-6 py-3 rounded-xl bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:to-pink-600 text-white font-bold text-sm shadow-lg shadow-indigo-500/20 flex items-center justify-center gap-2 transition-all shrink-0"
> >
<span>ดูรายละเอียดและแผนที่อำเภอ (District Focus)</span> <span>{t('salesMap.viewDistricts', 'ดูรายละเอียดและแผนที่อำเภอ')}</span>
<ArrowUpRight className="w-4 h-4" /> <ArrowUpRight className="w-4 h-4" />
</button> </button>
</div> </div>
@@ -748,7 +761,7 @@ export default function ProductSalesMapPage() {
<div className="flex items-center justify-between pb-3 border-b border-white/10"> <div className="flex items-center justify-between pb-3 border-b border-white/10">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Award className="w-4 h-4 text-indigo-400" /> <Award className="w-4 h-4 text-indigo-400" />
<h3 className="font-bold text-white text-sm">Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด</h3> <h3 className="font-bold text-white text-sm">{t('salesMap.topProvince', 'Top 20 จังหวัดที่สินค้านี้ขายดีที่สุด')}</h3>
</div> </div>
<span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">By {metric}</span> <span className="text-[10px] text-slate-400 uppercase tracking-widest font-mono">By {metric}</span>
</div> </div>
@@ -778,8 +791,10 @@ export default function ProductSalesMapPage() {
{item.rank} {item.rank}
</span> </span>
<div> <div>
<p className="text-xs font-bold text-white">{item.nameTh}</p> <p className="text-xs font-bold text-white">
<p className="text-[10px] text-amber-400 font-mono font-bold">{item.unitsSold} ชิ้น</p> {getLocalizedProvinceName(item.nameTh, i18n.language)}
</p>
<p className="text-[10px] text-amber-400 font-mono font-bold">{item.unitsSold} {t('salesMap.unitsSold', 'ชิ้น')}</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
@@ -810,17 +825,19 @@ export default function ProductSalesMapPage() {
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 backdrop-blur-sm">
<div className="flex items-center gap-2 pb-3 border-b border-white/10"> <div className="flex items-center gap-2 pb-3 border-b border-white/10">
<Layers className="w-4 h-4 text-cyan-400" /> <Layers className="w-4 h-4 text-cyan-400" />
<h3 className="font-bold text-white text-sm">สัดส่วนตามภูมิภาค</h3> <h3 className="font-bold text-white text-sm">{t('salesMap.regionalBreakdown', 'สัดส่วนตามภูมิภาค')}</h3>
</div> </div>
<div className="mt-3 space-y-3"> <div className="mt-3 space-y-3">
{mapData?.regionBreakdown?.map((reg: any) => ( {mapData?.regionBreakdown?.map((reg: any) => (
<div key={reg.region} className="space-y-1"> <div key={reg.region} className="space-y-1">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-white font-medium">{reg.regionTh}</span> <span className="text-white font-medium">
{getLocalizedRegionName(reg.region || reg.regionTh, i18n.language)}
</span>
<span className="font-mono text-emerald-400 font-bold"> <span className="font-mono text-emerald-400 font-bold">
{fmtBaht(reg.revenue)}{' '} {fmtBaht(reg.revenue)}{' '}
<span className="text-amber-400 font-normal">({reg.unitsSold} ชิ้น)</span> <span className="text-amber-400 font-normal">({reg.unitsSold} {t('salesMap.unitsSold', 'ชิ้น')})</span>
</span> </span>
</div> </div>
<div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex"> <div className="h-1.5 w-full bg-slate-800 rounded-full overflow-hidden flex">
@@ -851,14 +868,14 @@ export default function ProductSalesMapPage() {
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาชื่อจังหวัด หรือภาค..." placeholder={t('salesMap.searchProvince', 'ค้นหาชื่อจังหวัด หรือภาค...')}
value={searchTable} value={searchTable}
onChange={(e) => setSearchTable(e.target.value)} onChange={(e) => setSearchTable(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40" className="w-full pl-9 pr-4 py-2 rounded-xl bg-white/5 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500/40"
/> />
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
แสดง <span className="text-white font-bold">{filteredProvinces.length}</span> จาก 77 จังหวัด <span className="text-white font-bold">{filteredProvinces.length}</span> / 77 {t('salesMap.province', 'จังหวัด')}
</p> </p>
</div> </div>
@@ -866,15 +883,15 @@ export default function ProductSalesMapPage() {
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase"> <thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase">
<tr> <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">{t('salesMap.province', 'จังหวัด')}</th>
<th className="px-4 py-3">ภูมิภาค</th> <th className="px-4 py-3">{t('salesMap.region', 'ภูมิภาค')}</th>
<th className="px-4 py-3 text-right">ยอดรวม (฿)</th> <th className="px-4 py-3 text-right">{t('salesMap.totalRevenue', 'ยอดรวม')} (฿)</th>
<th className="px-4 py-3 text-right">จำนวนที่ขาย (ชิ้น)</th> <th className="px-4 py-3 text-right">{t('salesMap.unitsSold', 'จำนวนที่ขาย')}</th>
<th className="px-4 py-3 text-right">TikTok (฿)</th> <th className="px-4 py-3 text-right">TikTok (฿)</th>
<th className="px-4 py-3 text-right">Shopee (฿)</th> <th className="px-4 py-3 text-right">Shopee (฿)</th>
<th className="px-4 py-3 text-right">Lazada (฿)</th> <th className="px-4 py-3 text-right">Lazada (฿)</th>
<th className="px-4 py-3 text-center">สัดส่วนช่องทาง</th> <th className="px-4 py-3 text-center">{t('salesMap.share', 'สัดส่วนช่องทาง')}</th>
<th className="px-4 py-3" /> <th className="px-4 py-3" />
</tr> </tr>
</thead> </thead>
@@ -882,7 +899,7 @@ export default function ProductSalesMapPage() {
{filteredProvinces.length === 0 ? ( {filteredProvinces.length === 0 ? (
<tr> <tr>
<td colSpan={10} className="px-4 py-12 text-center text-slate-500"> <td colSpan={10} className="px-4 py-12 text-center text-slate-500">
ไม่พบข้อมูลจังหวัดตามคำค้นหา {t('salesMap.noData', 'ไม่พบข้อมูลจังหวัดตามคำค้นหา')}
</td> </td>
</tr> </tr>
) : ( ) : (
@@ -890,18 +907,21 @@ export default function ProductSalesMapPage() {
<tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors"> <tr key={p.code} className="border-b border-white/5 hover:bg-white/[0.04] transition-colors">
<td className="px-4 py-3 font-mono text-slate-500">{p.code}</td> <td className="px-4 py-3 font-mono text-slate-500">{p.code}</td>
<td className="px-4 py-3 font-bold text-white"> <td className="px-4 py-3 font-bold text-white">
{p.nameTh} <span className="text-slate-500 text-[10px] font-normal font-mono">({p.nameEn})</span> {getLocalizedProvinceName(p.nameTh, i18n.language)}{' '}
<span className="text-slate-500 text-[10px] font-normal font-mono">
({i18n.language === 'en' ? p.nameTh : p.nameEn})
</span>
</td> </td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10"> <span className="px-2 py-0.5 rounded text-[10px] bg-white/5 text-slate-300 border border-white/10">
{p.regionTh} {getLocalizedRegionName(p.region || p.regionTh, i18n.language)}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-right font-mono font-bold text-emerald-400"> <td className="px-4 py-3 text-right font-mono font-bold text-emerald-400">
{p.revenue > 0 ? fmtBaht(p.revenue) : '-'} {p.revenue > 0 ? fmtBaht(p.revenue) : '-'}
</td> </td>
<td className="px-4 py-3 text-right font-mono text-amber-400 font-bold"> <td className="px-4 py-3 text-right font-mono text-amber-400 font-bold">
{p.unitsSold > 0 ? `${p.unitsSold} ชิ้น` : '-'} {p.unitsSold > 0 ? `${p.unitsSold} ${t('salesMap.unitsSold', 'ชิ้น')}` : '-'}
</td> </td>
<td className="px-4 py-3 text-right font-mono text-rose-400"> <td className="px-4 py-3 text-right font-mono text-rose-400">
{p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'} {p.tiktokRevenue > 0 ? fmtBaht(p.tiktokRevenue) : '-'}
@@ -938,7 +958,7 @@ export default function ProductSalesMapPage() {
}} }}
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-indigo-500/20 hover:text-indigo-300 text-slate-400 border border-white/10 text-xs transition-colors" className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-indigo-500/20 hover:text-indigo-300 text-slate-400 border border-white/10 text-xs transition-colors"
> >
ดูรายอำเภอ {t('salesMap.viewDistricts', 'ดูรายอำเภอ')}
</button> </button>
</td> </td>
</tr> </tr>
@@ -17,6 +17,7 @@ import {
ChevronRight, ChevronRight,
Loader2, Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { API_BASE } from '@homaroma/types'; import { API_BASE } from '@homaroma/types';
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils'; import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
import { TIKTOK_SHOPS } from '../TiktokOrderPage'; import { TIKTOK_SHOPS } from '../TiktokOrderPage';
@@ -38,6 +39,7 @@ interface MasterVariant {
} }
export default function MarketplaceProductsPage() { export default function MarketplaceProductsPage() {
const { t } = useTranslation();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [autoMatching, setAutoMatching] = useState(false); const [autoMatching, setAutoMatching] = useState(false);
@@ -89,7 +91,7 @@ export default function MarketplaceProductsPage() {
.then((res) => setData(res)) .then((res) => setData(res))
.catch((err) => { .catch((err) => {
console.error('Failed to load marketplace products:', err); console.error('Failed to load marketplace products:', err);
showToast('เกิดข้อผิดพลาดในการโหลดข้อมูลสินค้า', 'error'); showToast(t('marketplaceProducts.toastLoadError', 'เกิดข้อผิดพลาดในการโหลดข้อมูลสินค้า'), 'error');
}) })
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}; };
@@ -131,16 +133,20 @@ export default function MarketplaceProductsPage() {
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
showToast( showToast(
`Sync ข้อมูลสินค้าเรียบร้อย! ทั้งหมด ${fmtNum(result.totalMarketplaceProducts)} รายการ (จับคู่ใหม่อัตโนมัติ ${result.matchedCount} รายการ)`, t('marketplaceProducts.toastSyncSuccess', {
total: fmtNum(result.totalMarketplaceProducts),
matched: result.matchedCount,
defaultValue: `Sync ข้อมูลสินค้าเรียบร้อย! ทั้งหมด ${fmtNum(result.totalMarketplaceProducts)} รายการ (จับคู่ใหม่อัตโนมัติ ${result.matchedCount} รายการ)`,
}),
'success' 'success'
); );
fetchData(); fetchData();
} else { } else {
showToast('ไม่สามารถ Sync ข้อมูลได้', 'error'); showToast(t('marketplaceProducts.toastSyncError', 'ไม่สามารถ Sync ข้อมูลได้'), 'error');
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast('เกิดข้อผิดพลาดในการเชื่อมต่อ Sync', 'error'); showToast(t('marketplaceProducts.toastSyncConnError', 'เกิดข้อผิดพลาดในการเชื่อมต่อ Sync'), 'error');
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
@@ -160,14 +166,18 @@ export default function MarketplaceProductsPage() {
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
showToast( showToast(
`Auto-Match สำเร็จ! จับคู่ SKU ตรงกันได้เพิ่ม ${fmtNum(result.matched)} รายการ (คงเหลือยังไม่ผูก ${fmtNum(result.remainingUnmapped)} รายการ)`, t('marketplaceProducts.toastAutoMatchSuccess', {
matched: fmtNum(result.matched),
remaining: fmtNum(result.remainingUnmapped),
defaultValue: `Auto-Match สำเร็จ! จับคู่ SKU ตรงกันได้เพิ่ม ${fmtNum(result.matched)} รายการ (คงเหลือยังไม่ผูก ${fmtNum(result.remainingUnmapped)} รายการ)`,
}),
'success' 'success'
); );
fetchData(); fetchData();
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast('เกิดข้อผิดพลาดในการ Auto-Match', 'error'); showToast(t('marketplaceProducts.toastAutoMatchError', 'เกิดข้อผิดพลาดในการ Auto-Match'), 'error');
} finally { } finally {
setAutoMatching(false); setAutoMatching(false);
} }
@@ -187,13 +197,18 @@ export default function MarketplaceProductsPage() {
}); });
const result = await res.json(); const result = await res.json();
if (result.success) { if (result.success) {
showToast(masterVariantId ? 'ผูกสินค้ากับ Master Catalog เรียบร้อย' : 'ยกเลิกการผูกสินค้าเรียบร้อย', 'success'); showToast(
masterVariantId
? t('marketplaceProducts.toastMapSuccess', 'ผูกสินค้ากับ Master Catalog เรียบร้อย')
: t('marketplaceProducts.toastUnlinkSuccess', 'ยกเลิกการผูกสินค้าเรียบร้อย'),
'success'
);
setMappingModalItem(null); setMappingModalItem(null);
fetchData(); fetchData();
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
showToast('ไม่สามารถบันทึกการผูกสินค้าได้', 'error'); showToast(t('marketplaceProducts.toastMapError', 'ไม่สามารถบันทึกการผูกสินค้าได้'), 'error');
} finally { } finally {
setSavingMapping(false); setSavingMapping(false);
} }
@@ -247,13 +262,18 @@ export default function MarketplaceProductsPage() {
</div> </div>
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-3xl font-bold text-white tracking-tight">Marketplace Products & SKU Manager</h1> <h1 className="text-3xl font-bold text-white tracking-tight">
{t('marketplaceProducts.title', 'Marketplace Products & SKU Manager')}
</h1>
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30"> <span className="px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-500/15 text-amber-400 border border-amber-500/30">
Tool • TikTok + Shopee + Lazada {t('marketplaceProducts.badge', 'Tool • TikTok + Shopee + Lazada')}
</span> </span>
</div> </div>
<p className="text-xs text-slate-400 mt-0.5"> <p className="text-xs text-slate-400 mt-0.5">
รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร {t(
'marketplaceProducts.subtitle',
'รวมรายการสินค้าที่ขายได้จากทุก Marketplace และเชื่อมโยง (SKU Mapping) เข้ากับ Master Catalog เพื่อวิเคราะห์ต้นทุนและกำไร'
)}
</p> </p>
</div> </div>
</div> </div>
@@ -266,7 +286,7 @@ export default function MarketplaceProductsPage() {
className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 hover:text-white border border-white/10 text-xs font-bold transition-all disabled:opacity-50" className="flex items-center gap-2 px-4 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-200 hover:text-white border border-white/10 text-xs font-bold transition-all disabled:opacity-50"
> >
{autoMatching ? <Loader2 className="w-4 h-4 animate-spin text-amber-400" /> : <Sparkles className="w-4 h-4 text-amber-400" />} {autoMatching ? <Loader2 className="w-4 h-4 animate-spin text-amber-400" /> : <Sparkles className="w-4 h-4 text-amber-400" />}
<span>⚡ Auto-Match SKUs</span> <span>{t('marketplaceProducts.autoMatch', '⚡ Auto-Match SKUs')}</span>
</button> </button>
<button <button
@@ -275,7 +295,11 @@ export default function MarketplaceProductsPage() {
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 hover:from-amber-600 hover:to-rose-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50" className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-gradient-to-r from-amber-500 via-orange-500 to-rose-500 hover:from-amber-600 hover:to-rose-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50"
> >
<RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-4 h-4 ${syncing ? 'animate-spin' : ''}`} />
<span>{syncing ? 'กำลังประมวลผลดึงข้อมูล...' : '🔄 Sync จาก Order ทั้งหมด'}</span> <span>
{syncing
? t('marketplaceProducts.syncing', 'กำลังประมวลผลดึงข้อมูล...')
: t('marketplaceProducts.syncFromOrders', '🔄 Sync จาก Order ทั้งหมด')}
</span>
</button> </button>
</div> </div>
</div> </div>
@@ -285,15 +309,20 @@ export default function MarketplaceProductsPage() {
{/* Total SKUs */} {/* Total SKUs */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สินค้าบน Marketplace</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
{t('marketplaceProducts.kpiTotalSkus', 'สินค้าบน Marketplace')}
</p>
<Package className="w-4 h-4 text-amber-400" /> <Package className="w-4 h-4 text-amber-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-white font-mono"> <p className="text-2xl font-bold text-white font-mono">
{fmtNum(kpi?.totalSkus || 0)} <span className="text-xs font-normal text-slate-400">รายการ/SKU</span> {fmtNum(kpi?.totalSkus || 0)}{' '}
<span className="text-xs font-normal text-slate-400">
{t('marketplaceProducts.skuUnit', 'รายการ/SKU')}
</span>
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
รวมทุกรูปแบบสินค้าที่เคยขายได้ {t('marketplaceProducts.kpiTotalSkusSub', 'รวมทุกรูปแบบสินค้าที่เคยขายได้')}
</p> </p>
</div> </div>
</div> </div>
@@ -301,7 +330,9 @@ export default function MarketplaceProductsPage() {
{/* Mapping Progress */} {/* Mapping Progress */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">สถานะการผูก Master SKU</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
{t('marketplaceProducts.kpiMappingProgress', 'สถานะการผูก Master SKU')}
</p>
<Link2 className="w-4 h-4 text-cyan-400" /> <Link2 className="w-4 h-4 text-cyan-400" />
</div> </div>
<div> <div>
@@ -321,7 +352,9 @@ export default function MarketplaceProductsPage() {
/> />
</div> </div>
<p className="text-[11px] text-slate-400 mt-1 font-mono"> <p className="text-[11px] text-slate-400 mt-1 font-mono">
ยังไม่ผูก: <span className="text-rose-400 font-bold">{kpi?.unmappedSkus || 0}</span> รายการ {t('marketplaceProducts.unmappedLabel', 'ยังไม่ผูก:')}{' '}
<span className="text-rose-400 font-bold">{kpi?.unmappedSkus || 0}</span>{' '}
{t('marketplaceProducts.itemUnit', 'รายการ')}
</p> </p>
</div> </div>
</div> </div>
@@ -329,15 +362,20 @@ export default function MarketplaceProductsPage() {
{/* Total Sold Units */} {/* Total Sold Units */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">จำนวนชิ้นที่ขายได้รวม</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
{t('marketplaceProducts.kpiTotalSold', 'จำนวนชิ้นที่ขายได้รวม')}
</p>
<ShoppingBag className="w-4 h-4 text-orange-400" /> <ShoppingBag className="w-4 h-4 text-orange-400" />
</div> </div>
<div> <div>
<p className="text-2xl font-bold text-orange-400 font-mono"> <p className="text-2xl font-bold text-orange-400 font-mono">
{fmtNum(kpi?.totalSoldQty || 0)} <span className="text-xs font-normal text-slate-400">ชิ้น</span> {fmtNum(kpi?.totalSoldQty || 0)}{' '}
<span className="text-xs font-normal text-slate-400">
{t('marketplaceProducts.unitsSold', 'ชิ้น')}
</span>
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
ยอดขายรวมสะสมจากทุกออเดอร์ {t('marketplaceProducts.kpiTotalSoldSub', 'ยอดขายรวมสะสมจากทุกออเดอร์')}
</p> </p>
</div> </div>
</div> </div>
@@ -345,7 +383,9 @@ export default function MarketplaceProductsPage() {
{/* Total Revenue */} {/* Total Revenue */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">ยอดขายรวม (Gross)</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
{t('marketplaceProducts.kpiTotalRevenue', 'ยอดขายรวม (Gross)')}
</p>
<DollarSign className="w-4 h-4 text-emerald-400" /> <DollarSign className="w-4 h-4 text-emerald-400" />
</div> </div>
<div> <div>
@@ -353,7 +393,7 @@ export default function MarketplaceProductsPage() {
{fmtBaht(kpi?.totalRevenue || 0)} {fmtBaht(kpi?.totalRevenue || 0)}
</p> </p>
<p className="text-xs text-slate-400 mt-1"> <p className="text-xs text-slate-400 mt-1">
ยอดเงินที่ได้รับจากลูกค้า {t('marketplaceProducts.kpiTotalRevenueSub', 'ยอดเงินที่ได้รับจากลูกค้า')}
</p> </p>
</div> </div>
</div> </div>
@@ -361,7 +401,9 @@ export default function MarketplaceProductsPage() {
{/* Est. Gross Profit (From Linked BOM) */} {/* Est. Gross Profit (From Linked BOM) */}
<div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3"> <div className="bg-white/5 border border-white/10 rounded-2xl p-5 flex flex-col justify-between gap-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">กำไรขั้นต้นประเมิน (BOM)</p> <p className="text-xs text-slate-400 uppercase tracking-widest font-semibold">
{t('marketplaceProducts.kpiEstProfit', 'กำไรขั้นต้นประเมิน (BOM)')}
</p>
<TrendingUp className="w-4 h-4 text-amber-400" /> <TrendingUp className="w-4 h-4 text-amber-400" />
</div> </div>
<div> <div>
@@ -369,7 +411,8 @@ export default function MarketplaceProductsPage() {
{fmtBaht(kpi?.totalEstProfit || 0)} {fmtBaht(kpi?.totalEstProfit || 0)}
</p> </p>
<p className="text-xs text-slate-400 mt-1 font-mono"> <p className="text-xs text-slate-400 mt-1 font-mono">
Margin รวม: <span className="text-emerald-400 font-bold">{kpi?.grossProfitMargin?.toFixed(1)}%</span> {t('marketplaceProducts.marginLabel', 'Margin รวม:')}{' '}
<span className="text-emerald-400 font-bold">{kpi?.grossProfitMargin?.toFixed(1)}%</span>
</p> </p>
</div> </div>
</div> </div>
@@ -389,7 +432,7 @@ export default function MarketplaceProductsPage() {
channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white' channel === 'all' ? 'bg-white/15 text-white shadow' : 'text-slate-400 hover:text-white'
}`} }`}
> >
🌐 ทุกช่องทาง {t('marketplaceProducts.allChannels', '🌐 ทุกช่องทาง')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -402,7 +445,7 @@ export default function MarketplaceProductsPage() {
: 'text-slate-400 hover:text-rose-400' : 'text-slate-400 hover:text-rose-400'
}`} }`}
> >
🔴 TikTok {t('marketplaceProducts.tiktok', '🔴 TikTok')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -415,7 +458,7 @@ export default function MarketplaceProductsPage() {
: 'text-slate-400 hover:text-orange-400' : 'text-slate-400 hover:text-orange-400'
}`} }`}
> >
🟠 Shopee {t('marketplaceProducts.shopee', '🟠 Shopee')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -428,13 +471,13 @@ export default function MarketplaceProductsPage() {
: 'text-slate-400 hover:text-blue-400' : 'text-slate-400 hover:text-blue-400'
}`} }`}
> >
🔵 Lazada {t('marketplaceProducts.lazada', '🔵 Lazada')}
</button> </button>
</div> </div>
{/* Shop Selector */} {/* Shop Selector */}
<div className="flex items-center gap-2 text-xs"> <div className="flex items-center gap-2 text-xs">
<span className="text-slate-400">ร้านค้า:</span> <span className="text-slate-400">{t('marketplaceProducts.shopLabel', 'ร้านค้า:')}</span>
<select <select
value={shop} value={shop}
onChange={(e) => { onChange={(e) => {
@@ -443,7 +486,7 @@ export default function MarketplaceProductsPage() {
}} }}
className="px-2.5 py-1.5 rounded-xl bg-black/40 border border-white/10 text-white font-medium focus:outline-none" className="px-2.5 py-1.5 rounded-xl bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
> >
<option value="all">ทุกร้านค้า (All Shops)</option> <option value="all">{t('marketplaceProducts.allShops', 'ทุกร้านค้า (All Shops)')}</option>
{channel === 'tiktok' && {channel === 'tiktok' &&
TIKTOK_SHOPS.map((s) => ( TIKTOK_SHOPS.map((s) => (
<option key={s.id} value={s.id}> <option key={s.id} value={s.id}>
@@ -503,7 +546,7 @@ export default function MarketplaceProductsPage() {
: 'bg-white/5 text-slate-400 border-white/5 hover:text-white' : 'bg-white/5 text-slate-400 border-white/5 hover:text-white'
}`} }`}
> >
ทั้งหมด {t('marketplaceProducts.statusAll', 'ทั้งหมด')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -517,7 +560,7 @@ export default function MarketplaceProductsPage() {
}`} }`}
> >
<CheckCircle2 className="w-3.5 h-3.5 inline mr-1 text-cyan-400" /> <CheckCircle2 className="w-3.5 h-3.5 inline mr-1 text-cyan-400" />
ผูกแล้ว (Mapped) {t('marketplaceProducts.statusMapped', 'ผูกแล้ว (Mapped)')}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -531,7 +574,7 @@ export default function MarketplaceProductsPage() {
}`} }`}
> >
<AlertCircle className="w-3.5 h-3.5 inline mr-1 text-rose-400" /> <AlertCircle className="w-3.5 h-3.5 inline mr-1 text-rose-400" />
ยังไม่ผูก (Unmapped) {t('marketplaceProducts.statusUnmapped', 'ยังไม่ผูก (Unmapped)')}
</button> </button>
</div> </div>
</div> </div>
@@ -542,7 +585,10 @@ export default function MarketplaceProductsPage() {
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="ค้นหาชื่อสินค้า, SKU, ตัวเลือก, หรือ Master Variant..." placeholder={t(
'marketplaceProducts.searchPlaceholder',
'ค้นหาชื่อสินค้า, SKU, ตัวเลือก, หรือ Master Variant...'
)}
value={search} value={search}
onChange={(e) => { onChange={(e) => {
setSearch(e.target.value); setSearch(e.target.value);
@@ -554,7 +600,7 @@ export default function MarketplaceProductsPage() {
<div className="flex items-center gap-3 text-xs"> <div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">เรียงตาม:</span> <span className="text-slate-400">{t('marketplaceProducts.sortBy', 'เรียงตาม:')}</span>
<select <select
value={sortBy} value={sortBy}
onChange={(e) => { onChange={(e) => {
@@ -563,10 +609,10 @@ export default function MarketplaceProductsPage() {
}} }}
className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-medium focus:outline-none" className="px-2.5 py-1 rounded-lg bg-black/40 border border-white/10 text-white font-medium focus:outline-none"
> >
<option value="revenue">ยอดขายรวมสูงสุด (Revenue)</option> <option value="revenue">{t('marketplaceProducts.sortRevenue', 'ยอดขายรวมสูงสุด (Revenue)')}</option>
<option value="sold">จำนวนชิ้นขายสูงสุด (Sold Qty)</option> <option value="sold">{t('marketplaceProducts.sortSold', 'จำนวนชิ้นขายสูงสุด (Sold Qty)')}</option>
<option value="name">ชื่อสินค้า (A-Z)</option> <option value="name">{t('marketplaceProducts.sortName', 'ชื่อสินค้า (A-Z)')}</option>
<option value="recent">ขายล่าสุด (Recent)</option> <option value="recent">{t('marketplaceProducts.sortRecent', 'ขายล่าสุด (Recent)')}</option>
</select> </select>
</div> </div>
</div> </div>
@@ -579,16 +625,20 @@ export default function MarketplaceProductsPage() {
<table className="w-full text-xs text-left"> <table className="w-full text-xs text-left">
<thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase tracking-wider"> <thead className="bg-white/5 text-slate-400 border-b border-white/10 uppercase tracking-wider">
<tr> <tr>
<th className="px-4 py-3.5">ช่องทาง / ร้านค้า</th> <th className="px-4 py-3.5">{t('marketplaceProducts.thChannelShop', 'ช่องทาง / ร้านค้า')}</th>
<th className="px-4 py-3.5">ชื่อสินค้า & ตัวเลือก (Marketplace)</th> <th className="px-4 py-3.5">
<th className="px-4 py-3.5">Seller SKU</th> {t('marketplaceProducts.thProductName', 'ชื่อสินค้า & ตัวเลือก (Marketplace)')}
<th className="px-4 py-3.5 text-right">จำนวนที่ขาย</th> </th>
<th className="px-4 py-3.5 text-right">ยอดขายรวม (฿)</th> <th className="px-4 py-3.5">{t('marketplaceProducts.thSku', 'Seller SKU')}</th>
<th className="px-4 py-3.5 text-right">ราคาเฉลี่ย</th> <th className="px-4 py-3.5 text-right">{t('marketplaceProducts.thSoldQty', 'จำนวนที่ขาย')}</th>
<th className="px-4 py-3.5">สินค้า Master (Core Catalog)</th> <th className="px-4 py-3.5 text-right">{t('marketplaceProducts.thTotalRevenue', 'ยอดขายรวม (฿)')}</th>
<th className="px-4 py-3.5 text-right">ต้นทุน BOM (฿)</th> <th className="px-4 py-3.5 text-right">{t('marketplaceProducts.thAvgPrice', 'ราคาเฉลี่ย')}</th>
<th className="px-4 py-3.5 text-right">กำไรประเมิน</th> <th className="px-4 py-3.5">
<th className="px-4 py-3.5 text-center">จัดการ</th> {t('marketplaceProducts.thMasterProduct', 'สินค้า Master (Core Catalog)')}
</th>
<th className="px-4 py-3.5 text-right">{t('marketplaceProducts.thBomCost', 'ต้นทุน BOM (฿)')}</th>
<th className="px-4 py-3.5 text-right">{t('marketplaceProducts.thEstProfit', 'กำไรประเมิน')}</th>
<th className="px-4 py-3.5 text-center">{t('marketplaceProducts.thActions', 'จัดการ')}</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
@@ -596,18 +646,18 @@ export default function MarketplaceProductsPage() {
<tr> <tr>
<td colSpan={10} className="px-4 py-16 text-center text-slate-400"> <td colSpan={10} className="px-4 py-16 text-center text-slate-400">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-amber-400" /> <Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-amber-400" />
กำลังโหลดข้อมูลสินค้า... {t('marketplaceProducts.loading', 'กำลังโหลดข้อมูลสินค้า...')}
</td> </td>
</tr> </tr>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<tr> <tr>
<td colSpan={10} className="px-4 py-16 text-center text-slate-400 space-y-2"> <td colSpan={10} className="px-4 py-16 text-center text-slate-400 space-y-2">
<p>ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก</p> <p>{t('marketplaceProducts.noData', 'ไม่พบรายการสินค้าตามเงื่อนไขที่เลือก')}</p>
<button <button
onClick={handleSyncAll} onClick={handleSyncAll}
className="px-4 py-2 rounded-xl bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-bold hover:bg-amber-500/30 transition-all" className="px-4 py-2 rounded-xl bg-amber-500/20 text-amber-300 border border-amber-500/30 text-xs font-bold hover:bg-amber-500/30 transition-all"
> >
กดเพื่อ Sync ข้อมูลจากออเดอร์ {t('marketplaceProducts.clickToSync', 'กดเพื่อ Sync ข้อมูลจากออเดอร์')}
</button> </button>
</td> </td>
</tr> </tr>
@@ -645,7 +695,7 @@ export default function MarketplaceProductsPage() {
</p> </p>
{item.variation && ( {item.variation && (
<span className="inline-block mt-1 px-2 py-0.5 rounded text-[10px] bg-white/5 text-amber-300 border border-white/10"> <span className="inline-block mt-1 px-2 py-0.5 rounded text-[10px] bg-white/5 text-amber-300 border border-white/10">
ตัวเลือก: {item.variation} {t('marketplaceProducts.variationPrefix', 'ตัวเลือก:')} {item.variation}
</span> </span>
)} )}
</td> </td>
@@ -663,7 +713,10 @@ export default function MarketplaceProductsPage() {
{/* Sold Qty */} {/* Sold Qty */}
<td className="px-4 py-3.5 text-right font-mono font-bold text-white"> <td className="px-4 py-3.5 text-right font-mono font-bold text-white">
{fmtNum(item.totalSoldQty)} <span className="text-[10px] font-normal text-slate-500">ชิ้น</span> {fmtNum(item.totalSoldQty)}{' '}
<span className="text-[10px] font-normal text-slate-500">
{t('marketplaceProducts.unitsSold', 'ชิ้น')}
</span>
</td> </td>
{/* Total Revenue */} {/* Total Revenue */}
@@ -693,7 +746,8 @@ export default function MarketplaceProductsPage() {
</div> </div>
) : ( ) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-rose-500/10 text-rose-300 border border-rose-500/20"> <span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-lg text-[10px] font-semibold bg-rose-500/10 text-rose-300 border border-rose-500/20">
<AlertCircle className="w-3 h-3 text-rose-400" /> ยังไม่ผูก SKU <AlertCircle className="w-3 h-3 text-rose-400" />{' '}
{t('marketplaceProducts.unmappedBadge', 'ยังไม่ผูก SKU')}
</span> </span>
)} )}
</td> </td>
@@ -702,11 +756,18 @@ export default function MarketplaceProductsPage() {
<td className="px-4 py-3.5 text-right font-mono"> <td className="px-4 py-3.5 text-right font-mono">
{hasMapping && profit?.unitBomCost > 0 ? ( {hasMapping && profit?.unitBomCost > 0 ? (
<div> <div>
<span className="text-slate-300">{fmtBaht(profit.unitBomCost)}/ชิ้น</span> <span className="text-slate-300">
<p className="text-[10px] text-slate-500">รวม {fmtBaht(profit.totalCost)}</p> {fmtBaht(profit.unitBomCost)}
{t('marketplaceProducts.perUnit', '/ชิ้น')}
</span>
<p className="text-[10px] text-slate-500">
{t('marketplaceProducts.totalPrefix', 'รวม')} {fmtBaht(profit.totalCost)}
</p>
</div> </div>
) : hasMapping ? ( ) : hasMapping ? (
<span className="text-slate-500 text-[10px]">ยังไม่ตั้ง BOM</span> <span className="text-slate-500 text-[10px]">
{t('marketplaceProducts.noBom', 'ยังไม่ตั้ง BOM')}
</span>
) : ( ) : (
<span className="text-slate-600">-</span> <span className="text-slate-600">-</span>
)} )}
@@ -717,7 +778,9 @@ export default function MarketplaceProductsPage() {
{hasMapping && profit ? ( {hasMapping && profit ? (
<div> <div>
<span className="font-bold text-amber-400">{fmtBaht(profit.profit)}</span> <span className="font-bold text-amber-400">{fmtBaht(profit.profit)}</span>
<p className="text-[10px] text-emerald-400 font-bold">{profit.marginPercent.toFixed(1)}% margin</p> <p className="text-[10px] text-emerald-400 font-bold">
{profit.marginPercent.toFixed(1)}% margin
</p>
</div> </div>
) : ( ) : (
<span className="text-slate-600">-</span> <span className="text-slate-600">-</span>
@@ -735,7 +798,11 @@ export default function MarketplaceProductsPage() {
className="px-2.5 py-1.5 rounded-xl bg-white/5 hover:bg-amber-500/20 hover:text-amber-300 text-slate-300 border border-white/10 text-xs font-semibold transition-all inline-flex items-center gap-1.5" className="px-2.5 py-1.5 rounded-xl bg-white/5 hover:bg-amber-500/20 hover:text-amber-300 text-slate-300 border border-white/10 text-xs font-semibold transition-all inline-flex items-center gap-1.5"
> >
<Link2 className="w-3.5 h-3.5" /> <Link2 className="w-3.5 h-3.5" />
<span>{hasMapping ? 'เปลี่ยนผูก' : 'ผูกสินค้า'}</span> <span>
{hasMapping
? t('marketplaceProducts.changeMapping', 'เปลี่ยนผูก')
: t('marketplaceProducts.mapProduct', 'ผูกสินค้า')}
</span>
</button> </button>
</td> </td>
</tr> </tr>
@@ -750,8 +817,11 @@ export default function MarketplaceProductsPage() {
{pagination && pagination.totalPages > 1 && ( {pagination && pagination.totalPages > 1 && (
<div className="p-4 border-t border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.01]"> <div className="p-4 border-t border-white/10 flex flex-col sm:flex-row sm:items-center justify-between gap-3 bg-white/[0.01]">
<p className="text-xs text-slate-400"> <p className="text-xs text-slate-400">
แสดงหน้าที่ <strong className="text-white">{pagination.page}</strong> จาก{' '} {t('common.showingPage', 'แสดงหน้าที่')}{' '}
<strong className="text-white">{pagination.totalPages}</strong> (ทั้งหมด {fmtNum(pagination.total)} รายการ) <strong className="text-white">{pagination.page}</strong> {t('common.of', 'จาก')}{' '}
<strong className="text-white">{pagination.totalPages}</strong> (
{t('marketplaceProducts.totalPrefix', 'ทั้งหมด')} {fmtNum(pagination.total)}{' '}
{t('marketplaceProducts.itemUnit', 'รายการ')})
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
@@ -759,14 +829,14 @@ export default function MarketplaceProductsPage() {
disabled={pagination.page <= 1} disabled={pagination.page <= 1}
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1" className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
> >
<ChevronLeft className="w-4 h-4" /> ก่อนหน้า <ChevronLeft className="w-4 h-4" /> {t('common.prev', 'ก่อนหน้า')}
</button> </button>
<button <button
onClick={() => setPage((p) => Math.min(p + 1, pagination.totalPages))} onClick={() => setPage((p) => Math.min(p + 1, pagination.totalPages))}
disabled={pagination.page >= pagination.totalPages} disabled={pagination.page >= pagination.totalPages}
className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1" className="px-3 py-1.5 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 disabled:opacity-30 text-xs border border-white/10 transition-all flex items-center gap-1"
> >
ถัดไป <ChevronRight className="w-4 h-4" /> {t('common.next', 'ถัดไป')} <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -784,8 +854,12 @@ export default function MarketplaceProductsPage() {
<Link2 className="w-5 h-5 text-amber-400" /> <Link2 className="w-5 h-5 text-amber-400" />
</div> </div>
<div> <div>
<h3 className="font-bold text-white text-base">ผูกสินค้า Marketplace เข้า Master Catalog</h3> <h3 className="font-bold text-white text-base">
<p className="text-xs text-slate-400">เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้</p> {t('marketplaceProducts.modalTitle', 'ผูกสินค้า Marketplace เข้า Master Catalog')}
</h3>
<p className="text-xs text-slate-400">
{t('marketplaceProducts.modalSubtitle', 'เลือกสินค้า/ขนาดจากระบบหลักที่ตรงกับรายการนี้')}
</p>
</div> </div>
</div> </div>
<button <button
@@ -799,7 +873,9 @@ export default function MarketplaceProductsPage() {
{/* Target Marketplace Item Summary */} {/* Target Marketplace Item Summary */}
<div className="p-4 mx-6 mt-4 bg-white/5 border border-white/10 rounded-xl space-y-1.5 text-xs"> <div className="p-4 mx-6 mt-4 bg-white/5 border border-white/10 rounded-xl space-y-1.5 text-xs">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-slate-400">สินค้าบน {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}):</span> <span className="text-slate-400">
{t('marketplaceProducts.productOn', 'สินค้าบน')} {mappingModalItem.channel.toUpperCase()} ({mappingModalItem.shop}):
</span>
{mappingModalItem.marketplaceSku && ( {mappingModalItem.marketplaceSku && (
<span className="px-2 py-0.2 rounded bg-black/40 text-amber-300 font-mono"> <span className="px-2 py-0.2 rounded bg-black/40 text-amber-300 font-mono">
SKU: {mappingModalItem.marketplaceSku} SKU: {mappingModalItem.marketplaceSku}
@@ -808,12 +884,24 @@ export default function MarketplaceProductsPage() {
</div> </div>
<p className="font-bold text-white text-sm">{mappingModalItem.productName}</p> <p className="font-bold text-white text-sm">{mappingModalItem.productName}</p>
{mappingModalItem.variation && ( {mappingModalItem.variation && (
<p className="text-amber-400">ตัวเลือก: {mappingModalItem.variation}</p> <p className="text-amber-400">
{t('marketplaceProducts.variationPrefix', 'ตัวเลือก:')} {mappingModalItem.variation}
</p>
)} )}
<div className="flex items-center gap-4 text-slate-400 font-mono pt-1"> <div className="flex items-center gap-4 text-slate-400 font-mono pt-1">
<span>ขายแล้ว: <strong className="text-white">{fmtNum(mappingModalItem.totalSoldQty)}</strong> ชิ้น</span> <span>
<span>ยอดขาย: <strong className="text-emerald-400">{fmtBaht(mappingModalItem.totalRevenue)}</strong></span> {t('marketplaceProducts.soldLabel', 'ขายแล้ว:')}{' '}
<span>ราคาเฉลี่ย: <strong className="text-amber-400">{fmtBaht(mappingModalItem.avgPrice)}</strong></span> <strong className="text-white">{fmtNum(mappingModalItem.totalSoldQty)}</strong>{' '}
{t('marketplaceProducts.unitsSold', 'ชิ้น')}
</span>
<span>
{t('marketplaceProducts.revenueLabel', 'ยอดขาย:')}{' '}
<strong className="text-emerald-400">{fmtBaht(mappingModalItem.totalRevenue)}</strong>
</span>
<span>
{t('marketplaceProducts.avgPriceLabel', 'ราคาเฉลี่ย:')}{' '}
<strong className="text-amber-400">{fmtBaht(mappingModalItem.avgPrice)}</strong>
</span>
</div> </div>
</div> </div>
@@ -823,7 +911,10 @@ export default function MarketplaceProductsPage() {
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="พิมพ์ค้นหาชื่อสินค้าหลัก, รหัส SKU, หรือขนาด (เช่น 50ml, 120ml)..." placeholder={t(
'marketplaceProducts.modalSearchPlaceholder',
'พิมพ์ค้นหาชื่อสินค้าหลัก, รหัส SKU, หรือขนาด (เช่น 50ml, 120ml)...'
)}
value={variantSearch} value={variantSearch}
onChange={(e) => setVariantSearch(e.target.value)} onChange={(e) => setVariantSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40" className="w-full pl-9 pr-4 py-2 rounded-xl bg-black/40 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-amber-500/40"
@@ -834,7 +925,7 @@ export default function MarketplaceProductsPage() {
<div className="flex-1 overflow-y-auto space-y-2 pr-1 max-h-[300px]"> <div className="flex-1 overflow-y-auto space-y-2 pr-1 max-h-[300px]">
{filteredMasterVariants.length === 0 ? ( {filteredMasterVariants.length === 0 ? (
<div className="py-8 text-center text-slate-500 text-xs"> <div className="py-8 text-center text-slate-500 text-xs">
ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา {t('marketplaceProducts.noMasterFound', 'ไม่พบรายการสินค้า Master ในระบบที่ตรงกับคำค้นหา')}
</div> </div>
) : ( ) : (
filteredMasterVariants.map((variant) => { filteredMasterVariants.map((variant) => {
@@ -861,7 +952,8 @@ export default function MarketplaceProductsPage() {
<div className="text-right shrink-0 font-mono text-xs"> <div className="text-right shrink-0 font-mono text-xs">
<p className="text-emerald-400 font-bold">{fmtBaht(variant.price)}</p> <p className="text-emerald-400 font-bold">{fmtBaht(variant.price)}</p>
<p className="text-[10px] text-slate-400"> <p className="text-[10px] text-slate-400">
ต้นทุน BOM: <span className="text-amber-300">{fmtBaht(variant.bomCost)}</span> {t('marketplaceProducts.bomCostLabel', 'ต้นทุน BOM:')}{' '}
<span className="text-amber-300">{fmtBaht(variant.bomCost)}</span>
</p> </p>
</div> </div>
</div> </div>
@@ -879,7 +971,7 @@ export default function MarketplaceProductsPage() {
disabled={savingMapping} disabled={savingMapping}
className="px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-bold transition-all flex items-center gap-1.5" className="px-4 py-2 rounded-xl bg-rose-500/10 hover:bg-rose-500/20 text-rose-300 border border-rose-500/30 text-xs font-bold transition-all flex items-center gap-1.5"
> >
<Unlink className="w-4 h-4" /> ยกเลิกการผูก <Unlink className="w-4 h-4" /> {t('marketplaceProducts.unlink', 'ยกเลิกการผูก')}
</button> </button>
) : <div />} ) : <div />}
@@ -888,7 +980,7 @@ export default function MarketplaceProductsPage() {
onClick={() => setMappingModalItem(null)} onClick={() => setMappingModalItem(null)}
className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-bold transition-all" className="px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 text-xs font-bold transition-all"
> >
ปิด {t('common.close', 'ปิด')}
</button> </button>
<button <button
onClick={() => handleSaveMapping(mappingModalItem.id, selectedVariantId)} onClick={() => handleSaveMapping(mappingModalItem.id, selectedVariantId)}
@@ -896,7 +988,7 @@ export default function MarketplaceProductsPage() {
className="px-5 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50 flex items-center gap-1.5" className="px-5 py-2 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-slate-950 font-black text-xs shadow-lg shadow-orange-500/20 transition-all disabled:opacity-50 flex items-center gap-1.5"
> >
{savingMapping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />} {savingMapping ? <Loader2 className="w-4 h-4 animate-spin" /> : <Link2 className="w-4 h-4" />}
<span>บันทึกการผูกสินค้า</span> <span>{t('marketplaceProducts.saveMapping', 'บันทึกการผูกสินค้า')}</span>
</button> </button>
</div> </div>
</div> </div>
+222
View File
@@ -1,4 +1,5 @@
// Utility for formatting, region mapping and color scale for Thailand Map // Utility for formatting, region mapping and color scale for Thailand Map
// Supports multilingual names (TH, EN, ZH, JA) for 77 provinces, regions, and districts
export function fmtBaht(val: number | null | undefined): string { export function fmtBaht(val: number | null | undefined): string {
if (val == null || isNaN(val)) return '฿0.00'; if (val == null || isNaN(val)) return '฿0.00';
@@ -46,3 +47,224 @@ export function getDistrictColor(value: number, max: number, isSelected: boolean
if (ratio < 0.85) return '#10b981'; if (ratio < 0.85) return '#10b981';
return '#34d399'; return '#34d399';
} }
// ─────────────────────────────────────────────────────────────────────────────
// Multilingual Geo Dictionary (77 Provinces of Thailand)
// ─────────────────────────────────────────────────────────────────────────────
export interface ProvinceGeoInfo {
code: string;
th: string;
en: string;
zh: string;
ja: string;
regionKey: 'Central' | 'North' | 'Northeast' | 'East' | 'West' | 'South';
}
export const THAILAND_PROVINCES_GEO: ProvinceGeoInfo[] = [
{ code: '10', th: 'กรุงเทพมหานคร', en: 'Bangkok', zh: '曼谷', ja: 'バンコク', regionKey: 'Central' },
{ code: '11', th: 'สมุทรปราการ', en: 'Samut Prakan', zh: '北榄', ja: 'サムットプラーカーン', regionKey: 'Central' },
{ code: '12', th: 'นนทบุรี', en: 'Nonthaburi', zh: '暖武里', ja: 'ノンタブリー', regionKey: 'Central' },
{ code: '13', th: 'ปทุมธานี', en: 'Pathum Thani', zh: '巴吞他尼', ja: 'パトゥムターニー', regionKey: 'Central' },
{ code: '14', th: 'พระนครศรีอยุธยา', en: 'Phra Nakhon Si Ayutthaya', zh: '大城 (阿瑜陀耶)', ja: 'アユタヤ', regionKey: 'Central' },
{ code: '15', th: 'อ่างทอง', en: 'Ang Thong', zh: '红统', ja: 'アーントーン', regionKey: 'Central' },
{ code: '16', th: 'ลพบุรี', en: 'Lop Buri', zh: '华富里', ja: 'ロッブリー', regionKey: 'Central' },
{ code: '17', th: 'สิงห์บุรี', en: 'Sing Buri', zh: '信武里', ja: 'シンブリー', regionKey: 'Central' },
{ code: '18', th: 'ชัยนาท', en: 'Chai Nat', zh: '猜纳', ja: 'チャイナート', regionKey: 'Central' },
{ code: '19', th: 'สระบุรี', en: 'Saraburi', zh: '北标', ja: 'サラブリー', regionKey: 'Central' },
{ code: '20', th: 'ชลบุรี', en: 'Chon Buri', zh: '春武里', ja: 'チョンブリー', regionKey: 'East' },
{ code: '21', th: 'ระยอง', en: 'Rayong', zh: '罗勇', ja: 'ラヨーン', regionKey: 'East' },
{ code: '22', th: 'จันทบุรี', en: 'Chanthaburi', zh: '尖竹汶', ja: 'チャンタブリー', regionKey: 'East' },
{ code: '23', th: 'ตราด', en: 'Trat', zh: '达叻', ja: 'トラート', regionKey: 'East' },
{ code: '24', th: 'ฉะเชิงเทรา', en: 'Chachoengsao', zh: '北柳', ja: 'チャチューンサオ', regionKey: 'East' },
{ code: '25', th: 'ปราจีนบุรี', en: 'Prachin Buri', zh: '巴真', ja: 'プラーチーンブリー', regionKey: 'East' },
{ code: '26', th: 'นครนายก', en: 'Nakhon Nayok', zh: '那空那育', ja: 'ナコーンナーヨック', regionKey: 'Central' },
{ code: '27', th: 'สระแก้ว', en: 'Sa Kaeo', zh: '沙缴', ja: 'サケーオ', regionKey: 'East' },
{ code: '30', th: 'นครราชสีมา', en: 'Nakhon Ratchasima', zh: '呵叻', ja: 'ナコーンラーチャシーマー', regionKey: 'Northeast' },
{ code: '31', th: 'บุรีรัมย์', en: 'Buri Ram', zh: '武里南', ja: 'ブリーラム', regionKey: 'Northeast' },
{ code: '32', th: 'สุรินทร์', en: 'Surin', zh: '素辇', ja: 'スリン', regionKey: 'Northeast' },
{ code: '33', th: 'ศรีสะเกษ', en: 'Si Sa Ket', zh: '四色菊', ja: 'シーサケート', regionKey: 'Northeast' },
{ code: '34', th: 'อุบลราชธานี', en: 'Ubon Ratchathani', zh: '乌汶', ja: 'ウボンラーチャターニー', regionKey: 'Northeast' },
{ code: '35', th: 'ยโสธร', en: 'Yasothon', zh: '益梭通', ja: 'ヤソートーン', regionKey: 'Northeast' },
{ code: '36', th: 'ชัยภูมิ', en: 'Chaiyaphum', zh: '猜也奔', ja: 'チャイヤプーム', regionKey: 'Northeast' },
{ code: '37', th: 'อำนาจเจริญ', en: 'Amnat Charoen', zh: '安纳乍伦', ja: 'アムナートチャルーン', regionKey: 'Northeast' },
{ code: '38', th: 'บึงกาฬ', en: 'Bueng Kan', zh: '汶干', ja: 'ブンカーン', regionKey: 'Northeast' },
{ code: '39', th: 'หนองบัวลำภู', en: 'Nong Bua Lam Phu', zh: '农磨兰普', ja: 'ノーンブワラムプー', regionKey: 'Northeast' },
{ code: '40', th: 'ขอนแก่น', en: 'Khon Kaen', zh: '孔敬', ja: 'コーンケン', regionKey: 'Northeast' },
{ code: '41', th: 'อุดรธานี', en: 'Udon Thani', zh: '乌隆', ja: 'ウドーンターニー', regionKey: 'Northeast' },
{ code: '42', th: 'เลย', en: 'Loei', zh: '黎府', ja: 'ルーイ', regionKey: 'Northeast' },
{ code: '43', th: 'หนองคาย', en: 'Nong Khai', zh: '廊开', ja: 'ノーンカーイ', regionKey: 'Northeast' },
{ code: '44', th: 'มหาสารคาม', en: 'Maha Sarakham', zh: '玛哈沙拉堪', ja: 'マハーサーラカーム', regionKey: 'Northeast' },
{ code: '45', th: 'ร้อยเอ็ด', en: 'Roi Et', zh: '黎逸', ja: 'ローイエット', regionKey: 'Northeast' },
{ code: '46', th: 'กาฬสินธุ์', en: 'Kalasin', zh: '加拉信', ja: 'カーラシン', regionKey: 'Northeast' },
{ code: '47', th: 'สกลนคร', en: 'Sakon Nakhon', zh: '色军', ja: 'サコンナコーン', regionKey: 'Northeast' },
{ code: '48', th: 'นครพนม', en: 'Nakhon Phanom', zh: '那空帕侬', ja: 'ナコーンパノム', regionKey: 'Northeast' },
{ code: '49', th: 'มุกดาหาร', en: 'Mukdahan', zh: '穆达汉', ja: 'ムックダーハーン', regionKey: 'Northeast' },
{ code: '50', th: 'เชียงใหม่', en: 'Chiang Mai', zh: '清迈', ja: 'チェンマイ', regionKey: 'North' },
{ code: '51', th: 'ลำพูน', en: 'Lamphun', zh: '南奔', ja: 'ランプーン', regionKey: 'North' },
{ code: '52', th: 'ลำปาง', en: 'Lampang', zh: '南邦', ja: 'ランパーン', regionKey: 'North' },
{ code: '53', th: 'อุตรดิตถ์', en: 'Uttaradit', zh: '程逸', ja: 'ウッタラディット', regionKey: 'North' },
{ code: '54', th: 'แพร่', en: 'Phrae', zh: '帕府', ja: 'プレー', regionKey: 'North' },
{ code: '55', th: 'น่าน', en: 'Nan', zh: '难府', ja: 'ナーン', regionKey: 'North' },
{ code: '56', th: 'พะเยา', en: 'Phayao', zh: '帕夭', ja: 'パヤオ', regionKey: 'North' },
{ code: '57', th: 'เชียงราย', en: 'Chiang Rai', zh: '清莱', ja: 'チェンライ', regionKey: 'North' },
{ code: '58', th: 'แม่ฮ่องสอน', en: 'Mae Hong Son', zh: '夜丰颂', ja: 'メーホンソーン', regionKey: 'North' },
{ code: '60', th: 'นครสวรรค์', en: 'Nakhon Sawan', zh: '那空沙旺 (北榄坡)', ja: 'ナコーンサワン', regionKey: 'Central' },
{ code: '61', th: 'อุทัยธานี', en: 'Uthai Thani', zh: '乌泰他尼', ja: 'ウタイターニー', regionKey: 'Central' },
{ code: '62', th: 'กำแพงเพชร', en: 'Kamphaeng Phet', zh: '甘烹碧', ja: 'カンペーンペット', regionKey: 'Central' },
{ code: '63', th: 'ตาก', en: 'Tak', zh: '达府 (来兴)', ja: 'ターク', regionKey: 'West' },
{ code: '64', th: 'สุโขทัย', en: 'Sukhothai', zh: '素可泰', ja: 'スコータイ', regionKey: 'Central' },
{ code: '65', th: 'พิษณุโลก', en: 'Phitsanulok', zh: '彭世洛', ja: 'ピッサヌローク', regionKey: 'Central' },
{ code: '66', th: 'พิจิตร', en: 'Phichit', zh: '披集', ja: 'ピチット', regionKey: 'Central' },
{ code: '67', th: 'เพชรบูรณ์', en: 'Phetchabun', zh: '碧差汶', ja: 'ペッチャブーン', regionKey: 'Central' },
{ code: '70', th: 'ราชบุรี', en: 'Ratchaburi', zh: '叻丕', ja: 'ラーチャブリー', regionKey: 'West' },
{ code: '71', th: 'กาญจนบุรี', en: 'Kanchanaburi', zh: '北碧', ja: 'カーンチャナブリー', regionKey: 'West' },
{ code: '72', th: 'สุพรรณบุรี', en: 'Suphan Buri', zh: '素攀', ja: 'スパンブリー', regionKey: 'Central' },
{ code: '73', th: 'นครปฐม', en: 'Nakhon Pathom', zh: '佛统', ja: 'ナコーンパトム', regionKey: 'Central' },
{ code: '74', th: 'สมุทรสาคร', en: 'Samut Sakhon', zh: '龙仔厝', ja: 'サムットサーコーン', regionKey: 'Central' },
{ code: '75', th: 'สมุทรสงคราม', en: 'Samut Songkhram', zh: '夜功', ja: 'サムットソンクラーム', regionKey: 'Central' },
{ code: '76', th: 'เพชรบุรี', en: 'Phetchaburi', zh: '佛丕 (碧武里)', ja: 'ペッチャブリー', regionKey: 'West' },
{ code: '77', th: 'ประจวบคีรีขันธ์', en: 'Prachuap Khiri Khan', zh: '巴蜀 (华欣)', ja: 'プラチュワップキーリーカン', regionKey: 'West' },
{ code: '80', th: 'นครศรีธรรมราช', en: 'Nakhon Si Thammarat', zh: '洛坤', ja: 'ナコーンシータンマラート', regionKey: 'South' },
{ code: '81', th: 'กระบี่', en: 'Krabi', zh: '甲米', ja: 'クラビ', regionKey: 'South' },
{ code: '82', th: 'พังงา', en: 'Phangnga', zh: '攀牙', ja: 'パンガー', regionKey: 'South' },
{ code: '83', th: 'ภูเก็ต', en: 'Phuket', zh: '普吉', ja: 'プーケット', regionKey: 'South' },
{ code: '84', th: 'สุราษฎร์ธานี', en: 'Surat Thani', zh: '素叻他尼 (苏梅岛)', ja: 'スラートターニー', regionKey: 'South' },
{ code: '85', th: 'ระนอง', en: 'Ranong', zh: '拉廊', ja: 'ラノーン', regionKey: 'South' },
{ code: '86', th: 'ชุมพร', en: 'Chumphon', zh: '春蓬', ja: 'チュムポーン', regionKey: 'South' },
{ code: '90', th: 'สงขลา', en: 'Songkhla', zh: '宋卡 (合艾)', ja: 'ソンクラー', regionKey: 'South' },
{ code: '91', th: 'สตูล', en: 'Satun', zh: '沙敦', ja: 'サトゥーン', regionKey: 'South' },
{ code: '92', th: 'ตรัง', en: 'Trang', zh: '董里', ja: 'トラン', regionKey: 'South' },
{ code: '93', th: 'พัทลุง', en: 'Phatthalung', zh: '博他仑', ja: 'パッタルン', regionKey: 'South' },
{ code: '94', th: 'ปัตตานี', en: 'Pattani', zh: '北大年', ja: 'パッターニー', regionKey: 'South' },
{ code: '95', th: 'ยะลา', en: 'Yala', zh: '也拉', ja: 'ヤラー', regionKey: 'South' },
{ code: '96', th: 'นราธิวาส', en: 'Narathiwat', zh: '陶公 (那拉提瓦)', ja: 'ナラーティワート', regionKey: 'South' },
];
export const REGIONS_GEO: Record<string, { th: string; en: string; zh: string; ja: string }> = {
Central: { th: 'ภาคกลาง', en: 'Central', zh: '中部', ja: '中部地方' },
North: { th: 'ภาคเหนือ', en: 'North', zh: '北部', ja: '北部地方' },
Northeast: { th: 'ภาคอีสาน', en: 'Northeast', zh: '东北部 (伊善)', ja: '東北部 (イサーン)' },
East: { th: 'ภาคตะวันออก', en: 'East', zh: '东部', ja: '東部地方' },
West: { th: 'ภาคตะวันตก', en: 'West', zh: '西部', ja: '西部地方' },
South: { th: 'ภาคใต้', en: 'South', zh: '南部', ja: '南部地方' },
};
// Prominent districts multi-language dictionary
export const PROMINENT_DISTRICTS: Record<string, { en: string; zh: string; ja: string }> = {
'คลองเตย': { en: 'Khlong Toei', zh: '空堤区', ja: 'クローントゥーイ' },
'พระนคร': { en: 'Phra Nakhon', zh: '拍那空区', ja: 'プラナコーン' },
'บางรัก': { en: 'Bang Rak', zh: '挽叻区', ja: 'バンラック' },
'ปทุมวัน': { en: 'Pathum Wan', zh: '巴吞旺区', ja: 'パトゥムワン' },
'วัฒนา': { en: 'Watthana', zh: '瓦他那区', ja: 'ワッタナー' },
'จตุจักร': { en: 'Chatuchak', zh: '乍都节区', ja: 'チャトゥチャック' },
'ห้วยขวาง': { en: 'Huai Khwang', zh: '汇权区', ja: 'フアイクワーン' },
'สาทร': { en: 'Sathon', zh: '沙吞区', ja: 'サートーン' },
'พญาไท': { en: 'Phaya Thai', zh: '披耶泰区', ja: 'パヤータイ' },
'ดินแดง': { en: 'Din Daeng', zh: '邻铃区', ja: 'ディンデーン' },
'บางนา': { en: 'Bang Na', zh: '邦纳区', ja: 'バンナー' },
'หัวหิน': { en: 'Hua Hin', zh: '华欣', ja: 'ホアヒン' },
'บางละมุง': { en: 'Bang Lamung (Pattaya)', zh: '挽腊蒙 (芭提雅)', ja: 'バーンラムン (パタヤ)' },
'เมืองเชียงใหม่': { en: 'Mueang Chiang Mai', zh: '清迈府治县 (市区)', ja: 'ムアンチェンマイ' },
'เมืองภูเก็ต': { en: 'Mueang Phuket', zh: '普吉府治县 (市区)', ja: 'ムアンプーケット' },
'กะทู้': { en: 'Kathu (Patong)', zh: '甲涂县 (芭东)', ja: 'カトゥー (パトン)' },
'ถลาง': { en: 'Thalang', zh: '他朗县', ja: 'タラーン' },
'เกาะสมุย': { en: 'Ko Samui', zh: '苏梅岛', ja: 'サムイ島' },
'หาดใหญ่': { en: 'Hat Yai', zh: '合艾', ja: 'ハートヤイ' },
};
function normalizeGeoStr(str: string): string {
return (str || '')
.toLowerCase()
.replace(/^(จังหวัด|จ\.|อำเภอ|เขต|อ\.)\s*/, '')
.replace(/[\s\-_.,()]/g, '')
.trim();
}
/**
* Get localized province name according to selected language
*/
export function getLocalizedProvinceName(provinceInput: string | null | undefined, lang: string = 'th'): string {
if (!provinceInput) return '';
const clean = normalizeGeoStr(provinceInput);
const found = THAILAND_PROVINCES_GEO.find(
(p) =>
p.code === provinceInput ||
normalizeGeoStr(p.th) === clean ||
normalizeGeoStr(p.en) === clean ||
clean.includes(normalizeGeoStr(p.th)) ||
normalizeGeoStr(p.th).includes(clean)
);
if (!found) return provinceInput;
const code = (lang || 'th').toLowerCase();
if (code.startsWith('en')) return found.en;
if (code.startsWith('zh')) return found.zh;
if (code.startsWith('ja')) return found.ja;
return found.th;
}
/**
* Get localized region name according to selected language
*/
export function getLocalizedRegionName(regionInput: string | null | undefined, lang: string = 'th'): string {
if (!regionInput) return '';
const clean = regionInput.trim().toLowerCase();
let matchKey = '';
for (const [k, v] of Object.entries(REGIONS_GEO)) {
if (
k.toLowerCase() === clean ||
v.th === regionInput ||
v.en.toLowerCase() === clean ||
clean.includes(k.toLowerCase()) ||
clean.includes(v.th) ||
(k === 'Northeast' && (clean.includes('อีสาน') || clean.includes('เฉียงเหนือ') || clean.includes('northeast'))) ||
(k === 'Central' && (clean.includes('กลาง') || clean.includes('central'))) ||
(k === 'North' && (clean.includes('เหนือ') || clean.includes('north'))) ||
(k === 'South' && (clean.includes('ใต้') || clean.includes('south'))) ||
(k === 'East' && ((clean.includes('ตะวันออก') && !clean.includes('เฉียงเหนือ')) || clean.includes('east'))) ||
(k === 'West' && (clean.includes('ตะวันตก') || clean.includes('west')))
) {
matchKey = k;
break;
}
}
const region = REGIONS_GEO[matchKey];
if (!region) return regionInput;
const code = (lang || 'th').toLowerCase();
if (code.startsWith('en')) return region.en;
if (code.startsWith('zh')) return region.zh;
if (code.startsWith('ja')) return region.ja;
return region.th;
}
/**
* Get localized district name according to selected language
*/
export function getLocalizedDistrictName(districtTh: string, districtEn?: string, lang: string = 'th'): string {
if (!districtTh) return districtEn || '';
const cleanTh = districtTh.replace(/^(อำเภอ|เขต|อ\.)\s*/, '').trim();
// Check prominent districts
const prominent = PROMINENT_DISTRICTS[cleanTh];
const code = (lang || 'th').toLowerCase();
if (code.startsWith('en')) {
if (prominent?.en) return prominent.en;
return (districtEn || cleanTh).replace(/^(Amphoe|Khet)\s+/i, '').trim();
}
if (code.startsWith('zh')) {
if (prominent?.zh) return prominent.zh;
return districtEn || cleanTh;
}
if (code.startsWith('ja')) {
if (prominent?.ja) return prominent.ja;
return districtEn || cleanTh;
}
return cleanTh;
}
+2
View File
@@ -17,10 +17,12 @@
"@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", "d3-geo": "^3.1.1",
"i18next": "^26.0.4",
"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",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-quill-new": "^3.8.3", "react-quill-new": "^3.8.3",
"react-router-dom": "^7.14.0", "react-router-dom": "^7.14.0",
"recharts": "^3.8.1", "recharts": "^3.8.1",