x
This commit is contained in:
1 parent
f8e11d5568
commit
8e35cb48d1
13 files changed
+3846
-1
No files matched your search
@@ -60,6 +60,9 @@ import OmnichannelSalesMapPage from './pages/analytics/OmnichannelSalesMapPage'
|
||||
import ProductSalesMapPage from './pages/analytics/ProductSalesMapPage'
|
||||
import MalaysiaSalesMapPage from './pages/analytics/MalaysiaSalesMapPage'
|
||||
import VietnamSalesMapPage from './pages/analytics/VietnamSalesMapPage'
|
||||
import RepeatCustomersPage from './pages/analytics/RepeatCustomersPage'
|
||||
import RepeatCustomersCalendarPage from './pages/analytics/RepeatCustomersCalendarPage'
|
||||
import RepeatCustomersTimelinePage from './pages/analytics/RepeatCustomersTimelinePage'
|
||||
import MarketplaceProductsPage from './pages/tools/MarketplaceProductsPage'
|
||||
|
||||
function App() {
|
||||
@@ -127,6 +130,9 @@ function App() {
|
||||
<Route path="/business-analytics/sales-map-products" element={<ProductSalesMapPage />} />
|
||||
<Route path="/business-analytics/malaysia-map" element={<MalaysiaSalesMapPage />} />
|
||||
<Route path="/business-analytics/vietnam-map" element={<VietnamSalesMapPage />} />
|
||||
<Route path="/business-analytics/repeat-customers" element={<RepeatCustomersPage />} />
|
||||
<Route path="/business-analytics/repeat-customers-calendar" element={<RepeatCustomersCalendarPage />} />
|
||||
<Route path="/business-analytics/repeat-customers-timeline" element={<RepeatCustomersTimelinePage />} />
|
||||
{/* Tools */}
|
||||
<Route path="/tools" element={<Navigate to="/tools/marketplace-products" replace />} />
|
||||
<Route path="/tools/marketplace-products" element={<MarketplaceProductsPage />} />
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Layers,
|
||||
Globe2,
|
||||
Users,
|
||||
UserCheck,
|
||||
Image as ImageIcon,
|
||||
MessageSquare,
|
||||
Store,
|
||||
@@ -35,6 +36,8 @@ import {
|
||||
FileJson,
|
||||
FolderArchive,
|
||||
Map,
|
||||
Calendar,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -98,6 +101,9 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) {
|
||||
{ icon: Package, label: t('nav.productSalesMap', 'Product Sales Map'), to: '/business-analytics/sales-map-products' },
|
||||
{ icon: Map, label: t('nav.malaysiaSalesMap', 'Malaysia Map'), to: '/business-analytics/malaysia-map' },
|
||||
{ icon: Map, label: t('nav.vietnamSalesMap', 'Vietnam Map'), to: '/business-analytics/vietnam-map' },
|
||||
{ icon: UserCheck, label: t('nav.repeatCustomers', 'Repeat Customers'), to: '/business-analytics/repeat-customers' },
|
||||
{ icon: Calendar, label: t('nav.repeatCustomersCalendar', 'Repeat Customers (Calendar)'), to: '/business-analytics/repeat-customers-calendar' },
|
||||
{ icon: Clock, label: t('nav.repeatCustomersTimeline', 'Repeat Customers (Timeline)'), to: '/business-analytics/repeat-customers-timeline' },
|
||||
]
|
||||
|
||||
const TOOL_MENU = [
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { X, ShoppingBag, Package, Calendar, MapPin, DollarSign, Tag } from 'lucide-react';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht } from '../../utils/thaiGeoUtils';
|
||||
|
||||
interface OrderItem {
|
||||
name: string;
|
||||
variation?: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
interface OrderHistory {
|
||||
orderId: string;
|
||||
channel: 'tiktok' | 'shopee' | 'lazada';
|
||||
shop: string;
|
||||
date: string;
|
||||
status: string;
|
||||
amount: number;
|
||||
shippingFee: number;
|
||||
trackingId?: string;
|
||||
deliveryOption?: string;
|
||||
items: OrderItem[];
|
||||
}
|
||||
|
||||
interface RepeatCustomerOrdersModalProps {
|
||||
customer: {
|
||||
id: string;
|
||||
identifier: string;
|
||||
displayName: string;
|
||||
primaryChannel: 'tiktok' | 'shopee' | 'lazada';
|
||||
location?: string;
|
||||
orderCount?: number;
|
||||
totalSpent?: number;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RepeatCustomerOrdersModal({ customer, onClose }: RepeatCustomerOrdersModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [orders, setOrders] = useState<OrderHistory[]>([]);
|
||||
const [customerDetails, setCustomerDetails] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('channel', customer.primaryChannel);
|
||||
params.set('identifier', customer.identifier);
|
||||
|
||||
fetch(`${API_BASE}/business-analytics/repeat-customers/detail?${params.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setOrders(data.orders || []);
|
||||
setCustomerDetails(data.customer || null);
|
||||
})
|
||||
.catch((err) => console.error('Failed to load customer orders:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [customer]);
|
||||
|
||||
const getChannelBadge = (channel: string) => {
|
||||
switch (channel) {
|
||||
case 'tiktok':
|
||||
return (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
TikTok Shop
|
||||
</span>
|
||||
);
|
||||
case 'shopee':
|
||||
return (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
Shopee
|
||||
</span>
|
||||
);
|
||||
case 'lazada':
|
||||
return (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
Lazada
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||
{channel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-6 bg-black/80 backdrop-blur-md animate-fade-in">
|
||||
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/[0.02]">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="w-11 h-11 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
|
||||
<ShoppingBag className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h2 className="text-lg font-bold text-white tracking-tight">
|
||||
{customer.displayName}
|
||||
</h2>
|
||||
{getChannelBadge(customer.primaryChannel)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-slate-400 mt-1">
|
||||
{customer.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-500" />
|
||||
{customer.location}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-emerald-400 font-medium">
|
||||
<DollarSign className="w-3.5 h-3.5" />
|
||||
ยอดสะสม: {fmtBaht(customerDetails?.totalSpent || customer.totalSpent || 0)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-indigo-300 font-medium">
|
||||
<Package className="w-3.5 h-3.5" />
|
||||
{customerDetails?.totalOrders || customer.orderCount || 0} คำสั่งซื้อ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto flex-1 space-y-4">
|
||||
{loading ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center gap-3 text-slate-400">
|
||||
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-sm">กำลังโหลดประวัติคำสั่งซื้อ...</p>
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-400">
|
||||
<ShoppingBag className="w-12 h-12 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-base font-medium text-slate-300">ไม่พบข้อมูลประวัติคำสั่งซื้อ</p>
|
||||
<p className="text-xs text-slate-500 mt-1">คำสั่งซื้ออาจถูกลบหรือไม่มีข้อมูลรายการสินค้า</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs font-semibold text-slate-400 tracking-wider uppercase flex items-center justify-between">
|
||||
<span>ไทม์ไลน์คำสั่งซื้อทั้งหมด ({orders.length} รายการ)</span>
|
||||
<span className="text-[11px] font-normal text-slate-500">เรียงจากล่าสุดไปอดีต</span>
|
||||
</div>
|
||||
|
||||
{orders.map((order, idx) => (
|
||||
<div
|
||||
key={order.orderId || idx}
|
||||
className="bg-slate-950/60 border border-white/10 rounded-xl p-4 transition-all hover:border-indigo-500/30"
|
||||
>
|
||||
{/* Order Top Bar */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pb-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-xs font-mono text-slate-300 font-medium">
|
||||
#{orders.length - idx}
|
||||
</span>
|
||||
<span className="font-mono text-sm font-semibold text-white">
|
||||
{order.orderId}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 bg-white/5 px-2 py-0.5 rounded border border-white/5">
|
||||
{order.shop}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-white/10">
|
||||
{order.status || 'สำเร็จ'}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-emerald-400">
|
||||
{fmtBaht(order.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Meta */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-xs text-slate-400 mt-2.5">
|
||||
{order.date && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-slate-500" />
|
||||
{order.date}
|
||||
</span>
|
||||
)}
|
||||
{order.trackingId && (
|
||||
<span className="flex items-center gap-1 font-mono text-slate-300">
|
||||
<Tag className="w-3.5 h-3.5 text-slate-500" />
|
||||
Tracking: {order.trackingId}
|
||||
</span>
|
||||
)}
|
||||
{order.shippingFee > 0 && (
|
||||
<span className="text-slate-500">
|
||||
ค่าจัดส่ง: {fmtBaht(order.shippingFee)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
{order.items && order.items.length > 0 && (
|
||||
<div className="mt-3.5 pt-3 border-t border-white/5 space-y-2">
|
||||
{order.items.map((item, itemIdx) => (
|
||||
<div
|
||||
key={itemIdx}
|
||||
className="flex items-center justify-between text-xs bg-white/[0.02] p-2 rounded-lg border border-white/5"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 pr-2">
|
||||
<Package className="w-3.5 h-3.5 text-indigo-400 shrink-0" />
|
||||
<span className="font-medium text-slate-200 truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
{item.variation && (
|
||||
<span className="text-slate-400 bg-white/5 px-1.5 py-0.5 rounded text-[11px] shrink-0">
|
||||
{item.variation}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span className="text-slate-400 font-mono">
|
||||
x{item.quantity}
|
||||
</span>
|
||||
<span className="text-slate-300 font-semibold font-mono">
|
||||
{fmtBaht(item.price)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-white/10 bg-white/[0.02] flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">
|
||||
คำนวณจากประวัติคำสั่งซื้อในระบบ HomAroma
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-sm font-medium text-white transition-all"
|
||||
>
|
||||
ปิดหน้าต่าง
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { Table2, Calendar, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function RepeatCustomersNavTabs() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const TABS = [
|
||||
{
|
||||
to: '/business-analytics/repeat-customers',
|
||||
label: t('repeatCustomers.tabsTable', 'ตารางภาพรวม (Table)'),
|
||||
icon: Table2,
|
||||
end: true,
|
||||
},
|
||||
{
|
||||
to: '/business-analytics/repeat-customers-calendar',
|
||||
label: t('repeatCustomers.tabsCalendar', 'ปฏิทินการซื้อซ้ำ (Calendar)'),
|
||||
icon: Calendar,
|
||||
badge: 'Heatmap',
|
||||
},
|
||||
{
|
||||
to: '/business-analytics/repeat-customers-timeline',
|
||||
label: t('repeatCustomers.tabsTimeline', 'ไทม์ไลน์รอบซื้อ (Timeline)'),
|
||||
icon: Clock,
|
||||
badge: 'Predictive',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-1.5 bg-slate-950/70 border border-white/10 rounded-2xl backdrop-blur-md overflow-x-auto">
|
||||
{TABS.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
end={tab.end}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold whitespace-nowrap transition-all ${
|
||||
isActive
|
||||
? 'bg-gradient-to-r from-indigo-600 to-purple-600 text-white shadow-lg shadow-indigo-600/25 border border-white/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{tab.label}</span>
|
||||
{tab.badge && (
|
||||
<span className="px-1.5 py-0.2 rounded text-[10px] font-mono font-normal bg-white/15 text-indigo-200">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,9 @@
|
||||
"productSalesMap": "Product Sales Map",
|
||||
"malaysiaSalesMap": "Malaysia Map",
|
||||
"vietnamSalesMap": "Vietnam Map",
|
||||
"repeatCustomers": "Repeat Customers (Table)",
|
||||
"repeatCustomersCalendar": "Repeat Customers (Calendar)",
|
||||
"repeatCustomersTimeline": "Repeat Customers (Timeline)",
|
||||
"tools": "Tools",
|
||||
"marketplaceProducts": "Marketplace Products",
|
||||
"tiktok": "TikTok Channel",
|
||||
@@ -1384,6 +1387,33 @@
|
||||
"CONTACT_INFO": "Beautiful information box showing address, telephone, and social details.",
|
||||
"CONTACT_FORM": "General support email inquiry contact form."
|
||||
}
|
||||
},
|
||||
"repeatCustomers": {
|
||||
"title": "Repeat Customers",
|
||||
"subtitle": "Analyze customer retention and repeat purchase behavior across Shopee, Lazada, and TikTok with Repurchase Rate & LTV metrics.",
|
||||
"exportCsv": "Export CSV",
|
||||
"allChannels": "All Channels",
|
||||
"repeatOnly": "Repeat Buyers Only (≥ 2 Orders)",
|
||||
"allBuyers": "All Buyers (≥ 1 Order)",
|
||||
"kpiTotalCustomers": "Total Customers",
|
||||
"kpiRepeatCustomers": "Repeat Customers",
|
||||
"kpiRepurchaseRate": "Repurchase Rate",
|
||||
"kpiRepeatRevenue": "Repeat Revenue",
|
||||
"kpiAvgInterval": "Avg. Repurchase Cycle",
|
||||
"frequencyDistribution": "Purchase Frequency Distribution",
|
||||
"topRepeatProducts": "Top Repurchased Products",
|
||||
"tableTitle": "Repeat Customers & Order History",
|
||||
"tabsTable": "Table View",
|
||||
"tabsCalendar": "Calendar View",
|
||||
"tabsTimeline": "Timeline View"
|
||||
},
|
||||
"repeatCustomersCalendar": {
|
||||
"title": "Repeat Customers Calendar",
|
||||
"subtitle": "Track daily repeat order activity, revenue volume, and identify peak repurchase days."
|
||||
},
|
||||
"repeatCustomersTimeline": {
|
||||
"title": "Repeat Customers Timeline",
|
||||
"subtitle": "Analyze customer repurchase intervals, order journeys, and predict next purchase dates to prevent churn."
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"businessAnalytics": "ビジネス分析",
|
||||
"omnichannelSalesMap": "オムニチャネル売上マップ",
|
||||
"productSalesMap": "商品別売上マップ",
|
||||
"repeatCustomers": "リピート顧客分析 (一覧)",
|
||||
"repeatCustomersCalendar": "リピート顧客分析 (カレンダー)",
|
||||
"repeatCustomersTimeline": "リピート顧客分析 (タイムライン)",
|
||||
"tools": "ツール",
|
||||
"marketplaceProducts": "マーケットプレイス商品",
|
||||
"tiktok": "TikTokチャネル",
|
||||
@@ -1381,6 +1384,33 @@
|
||||
"CONTACT_INFO": "住所、電話番号、SNSなどを美しくまとめた問い合わせ先情報ボックス",
|
||||
"CONTACT_FORM": "一般的な問い合わせを受け付けるメールフォーム"
|
||||
}
|
||||
},
|
||||
"repeatCustomers": {
|
||||
"title": "リピート顧客分析 (Repeat Customers)",
|
||||
"subtitle": "Shopee、Lazada、TikTokからのリピート購入行動、リピート率 (Repurchase Rate)、LTVを分析",
|
||||
"exportCsv": "CSV出力",
|
||||
"allChannels": "全チャネル (All Channels)",
|
||||
"repeatOnly": "リピート顧客のみ (≥ 2回)",
|
||||
"allBuyers": "全顧客 (≥ 1回)",
|
||||
"kpiTotalCustomers": "総顧客数",
|
||||
"kpiRepeatCustomers": "リピート顧客数",
|
||||
"kpiRepurchaseRate": "リピート購入率",
|
||||
"kpiRepeatRevenue": "リピート売上高",
|
||||
"kpiAvgInterval": "平均リピートサイクル",
|
||||
"frequencyDistribution": "購入頻度分布 (Purchase Frequency)",
|
||||
"topRepeatProducts": "人気リピート購入商品",
|
||||
"tableTitle": "リピート顧客リストと購入履歴",
|
||||
"tabsTable": "テーブル表示",
|
||||
"tabsCalendar": "カレンダー表示",
|
||||
"tabsTimeline": "タイムライン表示"
|
||||
},
|
||||
"repeatCustomersCalendar": {
|
||||
"title": "リピート顧客カレンダー (Repeat Customers Calendar)",
|
||||
"subtitle": "日々のリピート注文と売上推移を確認し、リピート購入のピーク日 (Peak Days) を分析"
|
||||
},
|
||||
"repeatCustomersTimeline": {
|
||||
"title": "リピート顧客タイムライン (Repeat Customers Timeline)",
|
||||
"subtitle": "顧客ごとの購入サイクル (Purchase Cycles) を可視化し、離脱防止に向けた次回購入時期を予測"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
"productSalesMap": "แผนที่ยอดขายรายสินค้า",
|
||||
"malaysiaSalesMap": "แผนที่มาเลเซีย (Malaysia Map)",
|
||||
"vietnamSalesMap": "แผนที่เวียดนาม (Vietnam Map)",
|
||||
"repeatCustomers": "ลูกค้าซื้อซ้ำ (ตาราง)",
|
||||
"repeatCustomersCalendar": "ลูกค้าซื้อซ้ำ (ปฏิทิน)",
|
||||
"repeatCustomersTimeline": "ลูกค้าซื้อซ้ำ (ไทม์ไลน์)",
|
||||
"tools": "เครื่องมือช่วยเหลือ",
|
||||
"marketplaceProducts": "สินค้า Marketplace",
|
||||
"tiktok": "ช่องทาง TikTok",
|
||||
@@ -1383,6 +1386,33 @@
|
||||
"CONTACT_INFO": "กล่องข้อมูลติดต่อ แสดงที่อยู่ เบอร์โทร อีเมล และโซเชียลมีเดียอย่างสวยงาม",
|
||||
"CONTACT_FORM": "ฟอร์มส่งข้อความติดต่อสอบถามทั่วไปทางอีเมล"
|
||||
}
|
||||
},
|
||||
"repeatCustomers": {
|
||||
"title": "ลูกค้าซื้อซ้ำ (Repeat Customers)",
|
||||
"subtitle": "วิเคราะห์พฤติกรรมการซื้อซ้ำของลูกค้าจาก Shopee, Lazada และ TikTok พร้อมตัวชี้วัด Repurchase Rate & LTV",
|
||||
"exportCsv": "Export CSV",
|
||||
"allChannels": "ทุกช่องทาง (All Channels)",
|
||||
"repeatOnly": "เฉพาะลูกค้าซื้อซ้ำ (≥ 2 ครั้ง)",
|
||||
"allBuyers": "ลูกค้าทั้งหมด (≥ 1 ครั้ง)",
|
||||
"kpiTotalCustomers": "ลูกค้าทั้งหมด",
|
||||
"kpiRepeatCustomers": "ลูกค้าซื้อซ้ำ",
|
||||
"kpiRepurchaseRate": "อัตราการซื้อซ้ำ",
|
||||
"kpiRepeatRevenue": "ยอดขายจากลูกค้าซื้อซ้ำ",
|
||||
"kpiAvgInterval": "รอบเวลาซื้อซ้ำเฉลี่ย",
|
||||
"frequencyDistribution": "การแจกแจงความถี่ในการสั่งซื้อ (Purchase Frequency)",
|
||||
"topRepeatProducts": "สินค้ายอดฮิตที่ลูกค้าซื้อซ้ำมากที่สุด",
|
||||
"tableTitle": "รายชื่อลูกค้าและประวัติการซื้อซ้ำ",
|
||||
"tabsTable": "ตารางภาพรวม (Table)",
|
||||
"tabsCalendar": "ปฏิทินการซื้อซ้ำ (Calendar)",
|
||||
"tabsTimeline": "ไทม์ไลน์รอบซื้อ (Timeline)"
|
||||
},
|
||||
"repeatCustomersCalendar": {
|
||||
"title": "ปฏิทินการซื้อซ้ำ (Repeat Customers Calendar)",
|
||||
"subtitle": "ตรวจสอบจังหวะและยอดคำสั่งซื้อซ้ำรายวัน และวิเคราะห์วันที่มีการซื้อซ้ำสูงสุด (Peak Days)"
|
||||
},
|
||||
"repeatCustomersTimeline": {
|
||||
"title": "ไทม์ไลน์รอบการซื้อซ้ำ (Repeat Customers Timeline)",
|
||||
"subtitle": "วิเคราะห์วงรอบการกลับมาซื้อ (Purchase Cycles) และคาดการณ์กำหนดซื้อซ้ำรอบถัดไปเพื่อป้องกันการ Churn"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,9 @@
|
||||
"businessAnalytics": "商业数据分析",
|
||||
"omnichannelSalesMap": "全渠道销售地图",
|
||||
"productSalesMap": "商品销售地图",
|
||||
"repeatCustomers": "复购客户分析 (表格)",
|
||||
"repeatCustomersCalendar": "复购客户分析 (日历)",
|
||||
"repeatCustomersTimeline": "复购客户分析 (时间轴)",
|
||||
"tools": "辅助工具",
|
||||
"marketplaceProducts": "电商平台商品",
|
||||
"tiktok": "TikTok 渠道",
|
||||
@@ -1382,6 +1385,33 @@
|
||||
"CONTACT_INFO": "精美联系方式展示框,汇集地址、电话、邮箱及社交媒体",
|
||||
"CONTACT_FORM": "通用客户咨询与支持邮件表单"
|
||||
}
|
||||
},
|
||||
"repeatCustomers": {
|
||||
"title": "复购客户分析 (Repeat Customers)",
|
||||
"subtitle": "分析 Shopee、Lazada 和 TikTok 客户的复购行为、留存率 (Repurchase Rate) 以及客户终身价值 (LTV)",
|
||||
"exportCsv": "导出 CSV",
|
||||
"allChannels": "所有渠道 (All Channels)",
|
||||
"repeatOnly": "仅复购客户 (≥ 2 次)",
|
||||
"allBuyers": "全部客户 (≥ 1 次)",
|
||||
"kpiTotalCustomers": "客户总数",
|
||||
"kpiRepeatCustomers": "复购客户数",
|
||||
"kpiRepurchaseRate": "复购率",
|
||||
"kpiRepeatRevenue": "复购贡献销售额",
|
||||
"kpiAvgInterval": "平均复购周期",
|
||||
"frequencyDistribution": "购买频次分布 (Purchase Frequency)",
|
||||
"topRepeatProducts": "最受复购欢迎商品",
|
||||
"tableTitle": "复购客户名单与历史订单",
|
||||
"tabsTable": "表格视图",
|
||||
"tabsCalendar": "日历视图",
|
||||
"tabsTimeline": "时间轴视图"
|
||||
},
|
||||
"repeatCustomersCalendar": {
|
||||
"title": "复购客户日历 (Repeat Customers Calendar)",
|
||||
"subtitle": "查看每日复购订单与销售额,分析复购高峰日 (Peak Days)"
|
||||
},
|
||||
"repeatCustomersTimeline": {
|
||||
"title": "复购客户时间轴 (Repeat Customers Timeline)",
|
||||
"subtitle": "分析客户复购周期 (Purchase Cycles) 并预测下次购买时间,防止客户流失"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Calendar as CalendarIcon,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
ShoppingBag,
|
||||
DollarSign,
|
||||
Users,
|
||||
Flame,
|
||||
X,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
|
||||
import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal';
|
||||
|
||||
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||
type StatusFilter = 'completed' | 'all' | 'cancelled';
|
||||
|
||||
export default function RepeatCustomersCalendarPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Current month state
|
||||
const [currentDate, setCurrentDate] = useState(() => {
|
||||
const d = new Date();
|
||||
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
});
|
||||
|
||||
const monthParam = useMemo(() => {
|
||||
const y = currentDate.getFullYear();
|
||||
const m = String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||
return `${y}-${m}`;
|
||||
}, [currentDate]);
|
||||
|
||||
const [channel, setChannel] = useState<ChannelType>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [calendarData, setCalendarData] = useState<any>(null);
|
||||
|
||||
// Selected Day for Drawer
|
||||
const [selectedDayData, setSelectedDayData] = useState<any>(null);
|
||||
|
||||
// Selected Customer for Detail Modal
|
||||
const [selectedCustomerForModal, setSelectedCustomerForModal] = useState<any>(null);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('month', monthParam);
|
||||
if (channel !== 'all') params.set('channel', channel);
|
||||
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||
|
||||
fetch(`${API_BASE}/business-analytics/repeat-customers/calendar?${params.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setCalendarData(data);
|
||||
})
|
||||
.catch((err) => console.error('Failed to load calendar repeat data:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [monthParam, channel, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Month navigation
|
||||
const prevMonth = () => {
|
||||
setCurrentDate((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
|
||||
setSelectedDayData(null);
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
setCurrentDate((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
|
||||
setSelectedDayData(null);
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
const now = new Date();
|
||||
setCurrentDate(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||
setSelectedDayData(null);
|
||||
};
|
||||
|
||||
// Calendar Math
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth();
|
||||
const firstDayIndex = (new Date(year, month, 1).getDay() + 6) % 7; // Monday = 0
|
||||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
|
||||
const daysMap = useMemo(() => {
|
||||
const map = new Map<number, any>();
|
||||
if (calendarData?.days) {
|
||||
for (const d of calendarData.days) {
|
||||
map.set(d.day, d);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [calendarData]);
|
||||
|
||||
const maxDailyRevenue = useMemo(() => {
|
||||
if (!calendarData?.days || calendarData.days.length === 0) return 1;
|
||||
return Math.max(...calendarData.days.map((d: any) => d.revenue || 0), 1);
|
||||
}, [calendarData]);
|
||||
|
||||
const getHeatmapClass = (rev: number) => {
|
||||
if (!rev) return 'bg-slate-950/40 border-white/5';
|
||||
const ratio = rev / maxDailyRevenue;
|
||||
if (ratio > 0.75) return 'bg-indigo-600/30 border-indigo-500/50 shadow-lg shadow-indigo-600/10';
|
||||
if (ratio > 0.4) return 'bg-indigo-700/20 border-indigo-500/30';
|
||||
if (ratio > 0.15) return 'bg-indigo-800/15 border-indigo-500/20';
|
||||
return 'bg-slate-900 border-white/10';
|
||||
};
|
||||
|
||||
const monthNamesTh = [
|
||||
'มกราคม', 'กุมภาพันธ์', 'มีนาคม', 'เมษายน', 'พฤษภาคม', 'มิถุนายน',
|
||||
'กรกฎาคม', 'สิงหาคม', 'กันยายน', 'ตุลาคม', 'พฤศจิกายน', 'ธันวาคม',
|
||||
];
|
||||
|
||||
const weekdayNames = ['จันทร์', 'อังคาร', 'พุธ', 'พฤหัสบดี', 'ศุกร์', 'เสาร์', 'อาทิตย์'];
|
||||
|
||||
const summary = calendarData?.summary || {
|
||||
totalRepeatOrders: 0,
|
||||
totalRepeatRevenue: 0,
|
||||
totalUniqueRepeatCustomers: 0,
|
||||
peakDay: { date: '', revenue: 0, orders: 0 },
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
|
||||
{/* ── Top Header Bar ────────────────────────────────────────── */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
|
||||
<CalendarIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
||||
{t('repeatCustomersCalendar.title', 'ปฏิทินการซื้อซ้ำ (Repeat Customers Calendar)')}
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
|
||||
{t(
|
||||
'repeatCustomersCalendar.subtitle',
|
||||
'ตรวจสอบจังหวะและยอดคำสั่งซื้อซ้ำรายวัน และวิเคราะห์วันที่มีการซื้อซ้ำสูงสุด (Peak Days)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'รีเฟรช')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
|
||||
<RepeatCustomersNavTabs />
|
||||
|
||||
{/* ── Month Controls & Filter Bar ─────────────────────────────── */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-4">
|
||||
{/* Month Navigator */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={prevMonth}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/5 transition-all"
|
||||
title="เดือนก่อนหน้า"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-base sm:text-lg font-bold text-white tracking-tight">
|
||||
{monthNamesTh[month]} {year + 543}
|
||||
</h2>
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-2.5 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-[11px] font-medium text-slate-400 hover:text-white border border-white/5 transition-all"
|
||||
>
|
||||
เดือนปัจจุบัน
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={nextMonth}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white border border-white/5 transition-all"
|
||||
title="เดือนถัดไป"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Channel & Status Filters */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Channel selection */}
|
||||
<div className="flex items-center gap-1 p-1 bg-slate-950/60 rounded-xl border border-white/5">
|
||||
<button
|
||||
onClick={() => setChannel('all')}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'all'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
ทุกช่องทาง
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('tiktok')}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'tiktok'
|
||||
? 'bg-rose-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('shopee')}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'shopee'
|
||||
? 'bg-orange-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Shopee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setChannel('lazada')}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'lazada'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
Lazada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
||||
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-xl px-2.5 py-2 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="completed">เฉพาะสำเร็จ</option>
|
||||
<option value="all">ทั้งหมด (รวมยกเลิก)</option>
|
||||
<option value="cancelled">เฉพาะยกเลิก</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Monthly KPI Summary ────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5">
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">ยอดขายซื้อซ้ำประจำเดือน</span>
|
||||
<DollarSign className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-amber-400 font-mono">
|
||||
{fmtBaht(summary.totalRepeatRevenue)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">ออเดอร์ซื้อซ้ำในเดือนนี้</span>
|
||||
<ShoppingBag className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-purple-400 font-mono">
|
||||
{fmtNum(summary.totalRepeatOrders)} <span className="text-xs font-normal text-slate-500">ออเดอร์</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">ลูกค้าที่กลับมาซื้อซ้ำ</span>
|
||||
<Users className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-emerald-400 font-mono">
|
||||
{fmtNum(summary.totalUniqueRepeatCustomers)} <span className="text-xs font-normal text-slate-500">คน</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">วันซื้อซ้ำสูงสุด (Peak Day)</span>
|
||||
<Flame className="w-4 h-4 text-rose-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-bold text-rose-400">
|
||||
{summary.peakDay?.date ? (
|
||||
<>
|
||||
<span>วันที่ {summary.peakDay.date.slice(8, 10)}</span>
|
||||
<span className="text-xs text-slate-300 font-mono block font-normal mt-0.5">
|
||||
{fmtBaht(summary.peakDay.revenue)} ({summary.peakDay.orders} ออเดอร์)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-slate-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Calendar Grid ─────────────────────────────────────────── */}
|
||||
<div className="bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md p-4 sm:p-5 overflow-hidden">
|
||||
{/* Weekday headers */}
|
||||
<div className="grid grid-cols-7 gap-2 text-center text-xs font-semibold text-slate-400 pb-3 border-b border-white/10">
|
||||
{weekdayNames.map((w, idx) => (
|
||||
<div key={idx} className={idx >= 5 ? 'text-amber-400/80' : ''}>
|
||||
{w}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Days grid */}
|
||||
<div className="grid grid-cols-7 gap-2 mt-3">
|
||||
{/* Empty cells before month start */}
|
||||
{Array.from({ length: firstDayIndex }).map((_, idx) => (
|
||||
<div key={`empty-${idx}`} className="min-h-[95px] sm:min-h-[115px] rounded-xl bg-white/[0.01] border border-transparent opacity-30" />
|
||||
))}
|
||||
|
||||
{/* Days of month */}
|
||||
{Array.from({ length: daysInMonth }).map((_, idx) => {
|
||||
const dayNum = idx + 1;
|
||||
const dayData = daysMap.get(dayNum);
|
||||
const hasData = dayData && dayData.ordersCount > 0;
|
||||
const isSelected = selectedDayData?.day === dayNum;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`day-${dayNum}`}
|
||||
onClick={() => {
|
||||
if (hasData) {
|
||||
setSelectedDayData(dayData);
|
||||
}
|
||||
}}
|
||||
className={`min-h-[95px] sm:min-h-[115px] p-2 sm:p-2.5 rounded-xl border flex flex-col justify-between transition-all ${
|
||||
hasData
|
||||
? `cursor-pointer hover:border-indigo-400 hover:scale-[1.02] ${getHeatmapClass(dayData.revenue)}`
|
||||
: 'bg-slate-950/30 border-white/5 text-slate-600'
|
||||
} ${isSelected ? 'ring-2 ring-indigo-500 border-indigo-400' : ''}`}
|
||||
>
|
||||
{/* Top: Day number & order badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs sm:text-sm font-bold ${hasData ? 'text-white' : 'text-slate-500'}`}>
|
||||
{dayNum}
|
||||
</span>
|
||||
|
||||
{hasData && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-[10px] font-mono font-bold bg-indigo-500/20 text-indigo-300 border border-indigo-500/30">
|
||||
{dayData.ordersCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom: Revenue & channel indicators */}
|
||||
{hasData ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="text-[11px] sm:text-xs font-bold text-amber-300 font-mono">
|
||||
{fmtBaht(dayData.revenue)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{dayData.channelBreakdown.tiktok.orders > 0 && (
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400" title={`TikTok: ${dayData.channelBreakdown.tiktok.orders} ออเดอร์`} />
|
||||
)}
|
||||
{dayData.channelBreakdown.shopee.orders > 0 && (
|
||||
<span className="w-2 h-2 rounded-full bg-orange-400" title={`Shopee: ${dayData.channelBreakdown.shopee.orders} ออเดอร์`} />
|
||||
)}
|
||||
{dayData.channelBreakdown.lazada.orders > 0 && (
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400" title={`Lazada: ${dayData.channelBreakdown.lazada.orders} ออเดอร์`} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[10px] text-slate-700">ไม่มีคำสั่งซื้อ</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Daily Orders Side Drawer / Modal ───────────────────────── */}
|
||||
{selectedDayData && (
|
||||
<div className="fixed inset-0 z-50 flex justify-end bg-black/70 backdrop-blur-sm animate-fade-in">
|
||||
<div className="w-full max-w-xl bg-slate-900 border-l border-white/10 h-full flex flex-col shadow-2xl overflow-hidden">
|
||||
{/* Drawer Header */}
|
||||
<div className="p-5 border-b border-white/10 flex items-center justify-between bg-white/[0.02]">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white tracking-tight">
|
||||
คำสั่งซื้อซ้ำ — วันที่ {selectedDayData.day} {monthNamesTh[month]}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
|
||||
<span className="text-amber-400 font-semibold font-mono">
|
||||
ยอดรวม {fmtBaht(selectedDayData.revenue)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>{selectedDayData.ordersCount} คำสั่งซื้อ</span>
|
||||
<span>•</span>
|
||||
<span>{selectedDayData.uniqueCustomersCount} ลูกค้า</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedDayData(null)}
|
||||
className="p-2 rounded-xl bg-white/5 hover:bg-white/10 text-slate-400 hover:text-white transition-all border border-white/5"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Orders List */}
|
||||
<div className="p-5 overflow-y-auto flex-1 space-y-3">
|
||||
{selectedDayData.orders.map((order: any, idx: number) => {
|
||||
const getBadge = (ch: string) => {
|
||||
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok</span>;
|
||||
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
|
||||
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">Lazada</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="p-3.5 rounded-xl bg-slate-950/60 border border-white/5 space-y-2 hover:border-indigo-500/30 transition-all"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{getBadge(order.channel)}
|
||||
<span className="font-semibold text-sm text-slate-200">
|
||||
{order.customerName}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-400 font-mono">
|
||||
{fmtBaht(order.amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span className="font-mono text-slate-500">#{order.orderId}</span>
|
||||
{order.time && (
|
||||
<span className="flex items-center gap-1 text-slate-500 font-mono">
|
||||
<Clock className="w-3 h-3" />
|
||||
{order.time} น.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{order.topProducts?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1 border-t border-white/5">
|
||||
{order.topProducts.map((p: string, pIdx: number) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="px-1.5 py-0.5 rounded bg-white/5 text-slate-300 text-[10px] truncate max-w-[200px]"
|
||||
>
|
||||
{p}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCustomerForModal({
|
||||
id: order.customerKey,
|
||||
identifier: order.customerKey.split(':')[1] || order.customerKey,
|
||||
displayName: order.customerName,
|
||||
primaryChannel: order.channel,
|
||||
});
|
||||
}}
|
||||
className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
ดูประวัติลูกค้าคนนี้
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Drawer Footer */}
|
||||
<div className="p-4 border-t border-white/10 bg-white/[0.01] flex justify-end">
|
||||
<button
|
||||
onClick={() => setSelectedDayData(null)}
|
||||
className="px-4 py-2 rounded-xl bg-white/10 hover:bg-white/15 text-xs font-medium text-white transition-all"
|
||||
>
|
||||
ปิดหน้าต่าง
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Customer Detail Orders Modal ────────────────────────────── */}
|
||||
{selectedCustomerForModal && (
|
||||
<RepeatCustomerOrdersModal
|
||||
customer={selectedCustomerForModal}
|
||||
onClose={() => setSelectedCustomerForModal(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Users,
|
||||
UserCheck,
|
||||
Repeat,
|
||||
DollarSign,
|
||||
Search,
|
||||
Download,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Layers,
|
||||
Award,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||
import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal';
|
||||
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
|
||||
|
||||
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||
type StatusFilter = 'completed' | 'all' | 'cancelled';
|
||||
type TimePeriod = 'all' | '30days' | '90days' | 'this_year' | 'custom';
|
||||
type SortBy = 'orders' | 'totalSpent' | 'lastOrderDate' | 'daysBetween';
|
||||
|
||||
export default function RepeatCustomersPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Filters state
|
||||
const [channel, setChannel] = useState<ChannelType>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('completed');
|
||||
const [period, setPeriod] = useState<TimePeriod>('all');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [minOrders, setMinOrders] = useState<number>(2); // default: repeat customers (>= 2)
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('orders');
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(25);
|
||||
|
||||
// Data state
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setPage(1);
|
||||
}, 400);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
// Handle Preset Period Change
|
||||
const handlePeriodChange = (newPeriod: TimePeriod) => {
|
||||
setPeriod(newPeriod);
|
||||
setPage(1);
|
||||
const now = new Date();
|
||||
if (newPeriod === 'all') {
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
} else if (newPeriod === '30days') {
|
||||
const past = new Date();
|
||||
past.setDate(now.getDate() - 30);
|
||||
setDateFrom(past.toISOString().slice(0, 10));
|
||||
setDateTo(now.toISOString().slice(0, 10));
|
||||
} else if (newPeriod === '90days') {
|
||||
const past = new Date();
|
||||
past.setDate(now.getDate() - 90);
|
||||
setDateFrom(past.toISOString().slice(0, 10));
|
||||
setDateTo(now.toISOString().slice(0, 10));
|
||||
} else if (newPeriod === 'this_year') {
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1);
|
||||
setDateFrom(startOfYear.toISOString().slice(0, 10));
|
||||
setDateTo(now.toISOString().slice(0, 10));
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch Data
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (channel !== 'all') params.set('channel', channel);
|
||||
if (statusFilter) params.set('statusFilter', statusFilter);
|
||||
if (dateFrom) params.set('dateFrom', dateFrom);
|
||||
if (dateTo) params.set('dateTo', dateTo);
|
||||
params.set('minOrders', minOrders.toString());
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
params.set('sortBy', sortBy);
|
||||
params.set('page', page.toString());
|
||||
params.set('limit', limit.toString());
|
||||
|
||||
fetch(`${API_BASE}/business-analytics/repeat-customers?${params.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((resData) => {
|
||||
setData(resData);
|
||||
})
|
||||
.catch((err) => console.error('Failed to load repeat customers data:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [channel, statusFilter, dateFrom, dateTo, minOrders, debouncedSearch, sortBy, page, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Export to CSV
|
||||
const handleExportCsv = () => {
|
||||
if (!data || !data.customers || data.customers.length === 0) return;
|
||||
|
||||
const headers = [
|
||||
'Customer Identifier',
|
||||
'Display Name',
|
||||
'Primary Channel',
|
||||
'Channels',
|
||||
'Total Orders',
|
||||
'Total Spent (THB)',
|
||||
'AOV (THB)',
|
||||
'First Order Date',
|
||||
'Last Order Date',
|
||||
'Avg Days Between Orders',
|
||||
'Location',
|
||||
'Top Purchased Products',
|
||||
];
|
||||
|
||||
const rows = data.customers.map((c: any) => [
|
||||
`"${c.identifier || ''}"`,
|
||||
`"${c.displayName || ''}"`,
|
||||
`"${c.primaryChannel || ''}"`,
|
||||
`"${(c.channels || []).join(', ')}"`,
|
||||
c.orderCount,
|
||||
c.totalSpent,
|
||||
c.avgOrderValue,
|
||||
`"${c.firstOrderDate || ''}"`,
|
||||
`"${c.lastOrderDate || ''}"`,
|
||||
c.daysBetweenOrders || 0,
|
||||
`"${c.location || ''}"`,
|
||||
`"${(c.topProducts || []).join(', ')}"`,
|
||||
]);
|
||||
|
||||
const csvContent = '\uFEFF' + [headers.join(','), ...rows.map((r: any) => r.join(','))].join('\n');
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute(
|
||||
'download',
|
||||
`homaroma_repeat_customers_${channel}_${new Date().toISOString().slice(0, 10)}.csv`
|
||||
);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const summary = data?.summary || {
|
||||
totalCustomers: 0,
|
||||
repeatCustomers: 0,
|
||||
singleOrderCustomers: 0,
|
||||
repurchaseRate: 0,
|
||||
totalRevenue: 0,
|
||||
repeatRevenue: 0,
|
||||
repeatRevenueShare: 0,
|
||||
avgRepurchaseIntervalDays: 0,
|
||||
};
|
||||
|
||||
const getChannelBadge = (ch: string) => {
|
||||
switch (ch) {
|
||||
case 'tiktok':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">
|
||||
TikTok
|
||||
</span>
|
||||
);
|
||||
case 'shopee':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">
|
||||
Shopee
|
||||
</span>
|
||||
);
|
||||
case 'lazada':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||
Lazada
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[11px] font-semibold bg-slate-500/10 text-slate-400 border border-slate-500/20">
|
||||
{ch}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
|
||||
{/* ── Top Header Bar ────────────────────────────────────────── */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
||||
{t('repeatCustomers.title', 'ลูกค้าซื้อซ้ำ (Repeat Customers)')}
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
|
||||
{t(
|
||||
'repeatCustomers.subtitle',
|
||||
'วิเคราะห์พฤติกรรมการซื้อซ้ำของลูกค้าจาก Shopee, Lazada และ TikTok พร้อมตัวชี้วัด Repurchase Rate & LTV'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'รีเฟรช')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
disabled={loading || !data?.customers?.length}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 border border-indigo-500/30 text-xs font-semibold text-white shadow-lg shadow-indigo-600/20 transition-all disabled:opacity-50"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
{t('repeatCustomers.exportCsv', 'Export CSV')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
|
||||
<RepeatCustomersNavTabs />
|
||||
|
||||
{/* ── Filters & Controls Bar ─────────────────────────────────── */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md space-y-4">
|
||||
{/* Channel Selection Tabs */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('all');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'all'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{t('repeatCustomers.allChannels', 'ทุกช่องทาง (All Channels)')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('tiktok');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'tiktok'
|
||||
? 'bg-rose-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-rose-400" />
|
||||
TikTok Shop
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('shopee');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'shopee'
|
||||
? 'bg-orange-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-orange-400" />
|
||||
Shopee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('lazada');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'lazada'
|
||||
? 'bg-blue-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span className="w-2 h-2 rounded-full bg-blue-400" />
|
||||
Lazada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Min Orders Filter Toggle */}
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMinOrders(2);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
minOrders === 2
|
||||
? 'bg-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{t('repeatCustomers.repeatOnly', 'เฉพาะลูกค้าซื้อซ้ำ (≥ 2 ครั้ง)')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setMinOrders(1);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
minOrders === 1
|
||||
? 'bg-purple-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{t('repeatCustomers.allBuyers', 'ลูกค้าทั้งหมด (≥ 1 ครั้ง)')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Filter Row */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-3 border-t border-white/5">
|
||||
{/* Period Presets */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{(
|
||||
[
|
||||
{ key: 'all', label: 'ทั้งหมด' },
|
||||
{ key: '30days', label: '30 วันล่าสุด' },
|
||||
{ key: '90days', label: '90 วันล่าสุด' },
|
||||
{ key: 'this_year', label: 'ปีนี้' },
|
||||
{ key: 'custom', label: 'กำหนดเอง' },
|
||||
] as { key: TimePeriod; label: string }[]
|
||||
).map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
onClick={() => handlePeriodChange(p.key)}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-all ${
|
||||
period === p.key
|
||||
? 'bg-white/15 text-white border border-white/20'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Date Picker inputs */}
|
||||
{period === 'custom' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => {
|
||||
setDateFrom(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
<span className="text-slate-500 text-xs">ถึง</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => {
|
||||
setDateTo(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-lg px-2.5 py-1.5 focus:outline-none focus:border-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Filter & Search Box */}
|
||||
<div className="flex items-center gap-2 flex-1 max-w-md">
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value as StatusFilter);
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-xl px-2.5 py-1.5 focus:outline-none focus:border-indigo-500 shrink-0"
|
||||
>
|
||||
<option value="completed">เฉพาะคำสั่งซื้อสำเร็จ</option>
|
||||
<option value="all">คำสั่งซื้อทั้งหมด (รวมยกเลิก)</option>
|
||||
<option value="cancelled">เฉพาะที่ยกเลิก</option>
|
||||
</select>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="ค้นหาชื่อลูกค้า, Username, จังหวัด..."
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-950 border border-white/10 rounded-xl text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── KPI Summary Cards ─────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3.5 sm:gap-4">
|
||||
{/* Card 1: Total Unique Customers */}
|
||||
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-white/20 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-slate-400">
|
||||
{t('repeatCustomers.kpiTotalCustomers', 'ลูกค้าทั้งหมด')}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400">
|
||||
<Users className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
||||
{fmtNum(summary.totalCustomers)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 ml-1.5">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
ลูกค้าไม่ซ้ำ (Unique Customers)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Repeat Customers Count */}
|
||||
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-purple-500/30 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-300">
|
||||
{t('repeatCustomers.kpiRepeatCustomers', 'ลูกค้าซื้อซ้ำ')}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
|
||||
<UserCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-xl sm:text-2xl font-bold text-purple-400 tracking-tight">
|
||||
{fmtNum(summary.repeatCustomers)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 ml-1.5">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
สั่งซื้อตั้งแต่ 2 ครั้งขึ้นไป
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Repurchase Rate % */}
|
||||
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-emerald-500/30 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-emerald-300">
|
||||
{t('repeatCustomers.kpiRepurchaseRate', 'อัตราการซื้อซ้ำ')}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
|
||||
<Repeat className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-xl sm:text-2xl font-bold text-emerald-400 tracking-tight">
|
||||
{summary.repurchaseRate}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
สัดส่วนลูกค้าที่กลับมาซื้อซ้ำ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Repeat Revenue */}
|
||||
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-amber-500/30 transition-all">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-amber-300">
|
||||
{t('repeatCustomers.kpiRepeatRevenue', 'ยอดขายจากลูกค้าซื้อซ้ำ')}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-lg sm:text-xl font-bold text-amber-400 tracking-tight">
|
||||
{fmtBaht(summary.repeatRevenue)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
คิดเป็น {summary.repeatRevenueShare}% ของยอดขายรวม
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Average Days to Repurchase */}
|
||||
<div className="bg-slate-900/50 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md relative overflow-hidden group hover:border-indigo-500/30 transition-all col-span-2 md:col-span-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-indigo-300">
|
||||
{t('repeatCustomers.kpiAvgInterval', 'รอบเวลาซื้อซ้ำเฉลี่ย')}
|
||||
</span>
|
||||
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 border border-indigo-500/20 flex items-center justify-center text-indigo-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-xl sm:text-2xl font-bold text-indigo-400 tracking-tight">
|
||||
{summary.avgRepurchaseIntervalDays > 0 ? `${summary.avgRepurchaseIntervalDays}` : '-'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 ml-1.5">วัน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-1">
|
||||
ระยะห่างเฉลี่ยระหว่างการซื้อซ้ำ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cohorts & Top Products Grid (2 Columns) ────────────────── */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-5">
|
||||
{/* Col 1: Frequency Cohorts (7 cols) */}
|
||||
<div className="lg:col-span-7 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-purple-400" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{t('repeatCustomers.frequencyDistribution', 'การแจกแจงความถี่ในการสั่งซื้อ (Purchase Frequency)')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">จำแนกตามจำนวนออเดอร์</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3.5">
|
||||
{data?.cohorts?.map((cohort: any, idx: number) => {
|
||||
const colors = [
|
||||
'bg-slate-700',
|
||||
'bg-blue-500',
|
||||
'bg-purple-500',
|
||||
'bg-amber-400',
|
||||
];
|
||||
const textColors = [
|
||||
'text-slate-300',
|
||||
'text-blue-400',
|
||||
'text-purple-400',
|
||||
'text-amber-400',
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={idx} className="bg-slate-950/40 p-3 rounded-xl border border-white/5 space-y-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={`font-semibold ${textColors[idx % 4]}`}>
|
||||
{cohort.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-slate-400">
|
||||
{fmtNum(cohort.count)} คน ({cohort.percentage}%)
|
||||
</span>
|
||||
<span className="font-semibold text-slate-200">
|
||||
{fmtBaht(cohort.revenue)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-slate-800/80 rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${colors[idx % 4]} transition-all duration-500`}
|
||||
style={{ width: `${Math.min(100, cohort.percentage || 0)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Channel stats footer */}
|
||||
{data?.channelBreakdown && (
|
||||
<div className="mt-4 pt-3.5 border-t border-white/5 grid grid-cols-3 gap-2 text-center text-xs">
|
||||
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
|
||||
<span className="text-rose-400 font-semibold block">TikTok Shop</span>
|
||||
<span className="text-slate-300 font-bold block mt-0.5">
|
||||
{data.channelBreakdown.tiktok?.repurchaseRate}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{fmtNum(data.channelBreakdown.tiktok?.repeatCust || 0)} คนซื้อซ้ำ
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
|
||||
<span className="text-orange-400 font-semibold block">Shopee</span>
|
||||
<span className="text-slate-300 font-bold block mt-0.5">
|
||||
{data.channelBreakdown.shopee?.repurchaseRate}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{fmtNum(data.channelBreakdown.shopee?.repeatCust || 0)} คนซื้อซ้ำ
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white/[0.02] p-2 rounded-lg border border-white/5">
|
||||
<span className="text-blue-400 font-semibold block">Lazada</span>
|
||||
<span className="text-slate-300 font-bold block mt-0.5">
|
||||
{data.channelBreakdown.lazada?.repurchaseRate}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
{fmtNum(data.channelBreakdown.lazada?.repeatCust || 0)} คนซื้อซ้ำ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Col 2: Top Repurchased Products (5 cols) */}
|
||||
<div className="lg:col-span-5 bg-slate-900/50 p-5 rounded-2xl border border-white/10 backdrop-blur-md flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="w-4 h-4 text-amber-400" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
{t('repeatCustomers.topRepeatProducts', 'สินค้ายอดฮิตที่ลูกค้าซื้อซ้ำมากที่สุด')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">Top 10 SKUs</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-[330px] overflow-y-auto pr-1">
|
||||
{!data?.topRepeatProducts || data.topRepeatProducts.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-500 text-xs">
|
||||
ไม่มีข้อมูลสินค้าที่ถูกซื้อซ้ำในช่วงเวลานี้
|
||||
</div>
|
||||
) : (
|
||||
data.topRepeatProducts.map((p: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between p-2.5 rounded-xl bg-slate-950/40 border border-white/5 text-xs hover:border-white/10 transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pr-2">
|
||||
<span
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center font-bold text-[11px] shrink-0 ${
|
||||
idx === 0
|
||||
? 'bg-amber-500 text-slate-950'
|
||||
: idx === 1
|
||||
? 'bg-slate-300 text-slate-950'
|
||||
: idx === 2
|
||||
? 'bg-amber-700 text-white'
|
||||
: 'bg-white/10 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="font-medium text-slate-200 truncate">
|
||||
{p.productName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 text-right">
|
||||
<span className="text-emerald-400 font-semibold font-mono">
|
||||
{fmtNum(p.repeatBuyerCount)} คน
|
||||
</span>
|
||||
<span className="text-slate-500 text-[11px]">
|
||||
({fmtNum(p.orderAppearances)} ออเดอร์)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-slate-500 mt-3 pt-2.5 border-t border-white/5">
|
||||
แนะนำให้นำสินค้าเหล่านี้ไปทำแคมเปญ Re-order และโปรโมชั่นซื้อซ้ำ (Bundle/Subscription)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Repeat Customers Table ─────────────────────────────────── */}
|
||||
<div className="bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md overflow-hidden">
|
||||
{/* Table Top Controls */}
|
||||
<div className="p-4 sm:p-5 border-b border-white/10 flex flex-wrap items-center justify-between gap-3 bg-white/[0.01]">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<UserCheck className="w-5 h-5 text-indigo-400" />
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white">
|
||||
{t('repeatCustomers.tableTitle', 'รายชื่อลูกค้าและประวัติการซื้อซ้ำ')}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">
|
||||
พบทั้งหมด {fmtNum(data?.pagination?.total || 0)} รายการ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort By selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
จัดเรียงตาม:
|
||||
</span>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => {
|
||||
setSortBy(e.target.value as SortBy);
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-200 text-xs rounded-xl px-3 py-1.5 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="orders">จำนวนออเดอร์สูงสุด</option>
|
||||
<option value="totalSpent">ยอดใช้จ่ายสะสมสูงสุด (LTV)</option>
|
||||
<option value="lastOrderDate">วันที่สั่งซื้อล่าสุด</option>
|
||||
<option value="daysBetween">รอบเวลาซื้อซ้ำสั้นที่สุด</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table View */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 bg-white/[0.02] text-slate-400 font-semibold">
|
||||
<th className="py-3.5 px-4">#</th>
|
||||
<th className="py-3.5 px-4">ลูกค้า (Identifier / Name)</th>
|
||||
<th className="py-3.5 px-4">ช่องทาง</th>
|
||||
<th className="py-3.5 px-4 text-center">จำนวนออเดอร์</th>
|
||||
<th className="py-3.5 px-4 text-right">ยอดซื้อสะสม (LTV)</th>
|
||||
<th className="py-3.5 px-4 text-right">ยอดเฉลี่ย / ออเดอร์</th>
|
||||
<th className="py-3.5 px-4">ซื้อล่าสุด</th>
|
||||
<th className="py-3.5 px-4 text-center">รอบซื้อซ้ำเฉลี่ย</th>
|
||||
<th className="py-3.5 px-4">สินค้าที่ซื้อบ่อย</th>
|
||||
<th className="py-3.5 px-4 text-right">การจัดการ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="py-20 text-center text-slate-400">
|
||||
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
กำลังประมวลผลข้อมูลลูกค้าซื้อซ้ำ...
|
||||
</td>
|
||||
</tr>
|
||||
) : !data?.customers || data.customers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="py-16 text-center text-slate-500">
|
||||
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.customers.map((cust: any, idx: number) => {
|
||||
const rowNumber = (page - 1) * limit + idx + 1;
|
||||
return (
|
||||
<tr
|
||||
key={cust.id || idx}
|
||||
className="hover:bg-white/[0.02] transition-colors"
|
||||
>
|
||||
<td className="py-3 px-4 font-mono text-slate-500">
|
||||
{rowNumber}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-100 text-sm">
|
||||
{cust.displayName}
|
||||
</span>
|
||||
{cust.location && (
|
||||
<span className="text-[11px] text-slate-400 mt-0.5">
|
||||
{cust.location}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{getChannelBadge(cust.primaryChannel)}
|
||||
{cust.shops?.length > 1 && (
|
||||
<span className="text-[10px] text-slate-500 bg-white/5 px-1 rounded">
|
||||
+{cust.shops.length - 1} ร้าน
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-center">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center px-2.5 py-1 rounded-full font-bold text-xs font-mono ${
|
||||
cust.orderCount >= 6
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/30'
|
||||
: cust.orderCount >= 3
|
||||
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
|
||||
: 'bg-indigo-500/20 text-indigo-300 border border-indigo-500/30'
|
||||
}`}
|
||||
>
|
||||
{cust.orderCount} ครั้ง
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-right font-bold text-slate-200 font-mono text-sm">
|
||||
{fmtBaht(cust.totalSpent)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-right font-medium text-slate-400 font-mono">
|
||||
{fmtBaht(cust.avgOrderValue)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-slate-300">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{cust.lastOrderDate?.slice(0, 10) || '-'}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-500">
|
||||
แรกเริ่ม: {cust.firstOrderDate?.slice(0, 10) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-center">
|
||||
{cust.daysBetweenOrders > 0 ? (
|
||||
<span className="text-indigo-400 font-mono font-medium">
|
||||
{cust.daysBetweenOrders} วัน
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-600">-</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{cust.topProducts?.map((prod: string, pIdx: number) => (
|
||||
<span
|
||||
key={pIdx}
|
||||
className="px-1.5 py-0.5 rounded bg-white/5 text-slate-300 text-[10px] truncate max-w-[180px] border border-white/5"
|
||||
title={prod}
|
||||
>
|
||||
{prod}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-3 px-4 text-right">
|
||||
<button
|
||||
onClick={() => setSelectedCustomer(cust)}
|
||||
className="px-2.5 py-1.5 rounded-lg bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 border border-indigo-500/30 hover:text-white text-xs font-medium transition-all"
|
||||
>
|
||||
ดูออเดอร์
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Table Pagination Bar */}
|
||||
{data?.pagination && data.pagination.totalPages > 1 && (
|
||||
<div className="p-4 border-t border-white/10 flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400 bg-white/[0.01]">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
กำลังแสดงหน้า <span className="font-semibold text-white">{page}</span> จาก{' '}
|
||||
<span className="font-semibold text-white">{data.pagination.totalPages}</span> หน้า
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<span>แสดง:</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => {
|
||||
setLimit(Number(e.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-lg px-2 py-1 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value={25}>25 แถว</option>
|
||||
<option value={50}>50 แถว</option>
|
||||
<option value={100}>100 แถว</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="px-3 py-1 rounded-lg bg-slate-950 font-mono font-semibold text-white border border-white/10">
|
||||
{page} / {data.pagination.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pagination.totalPages, p + 1))}
|
||||
disabled={page >= data.pagination.totalPages || loading}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Order History Modal ─────────────────────────────────────── */}
|
||||
{selectedCustomer && (
|
||||
<RepeatCustomerOrdersModal
|
||||
customer={selectedCustomer}
|
||||
onClose={() => setSelectedCustomer(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
import { fmtBaht, fmtNum } from '../../utils/thaiGeoUtils';
|
||||
import RepeatCustomersNavTabs from '../../components/analytics/RepeatCustomersNavTabs';
|
||||
import RepeatCustomerOrdersModal from '../../components/analytics/RepeatCustomerOrdersModal';
|
||||
|
||||
type ChannelType = 'all' | 'tiktok' | 'shopee' | 'lazada';
|
||||
type ChurnStatus = 'all' | 'due_soon' | 'healthy' | 'churn_risk';
|
||||
|
||||
export default function RepeatCustomersTimelinePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [channel, setChannel] = useState<ChannelType>('all');
|
||||
const [churnStatus, setChurnStatus] = useState<ChurnStatus>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit, setLimit] = useState(20);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<any>(null);
|
||||
|
||||
// Debounce search
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedSearch(search);
|
||||
setPage(1);
|
||||
}, 400);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
|
||||
const fetchData = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (channel !== 'all') params.set('channel', channel);
|
||||
if (churnStatus !== 'all') params.set('churnStatus', churnStatus);
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
params.set('page', page.toString());
|
||||
params.set('limit', limit.toString());
|
||||
|
||||
fetch(`${API_BASE}/business-analytics/repeat-customers/timeline?${params.toString()}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((resData) => {
|
||||
setData(resData);
|
||||
})
|
||||
.catch((err) => console.error('Failed to load repeat timeline data:', err))
|
||||
.finally(() => setLoading(false));
|
||||
}, [channel, churnStatus, debouncedSearch, page, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const summary = data?.summary || {
|
||||
totalRepeatCustomers: 0,
|
||||
healthyCount: 0,
|
||||
dueSoonCount: 0,
|
||||
churnRiskCount: 0,
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'due_soon':
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30 animate-pulse">
|
||||
<AlertCircle className="w-3 h-3 text-amber-400" />
|
||||
ถึงรอบซื้อซ้ำ (Due Soon)
|
||||
</span>
|
||||
);
|
||||
case 'healthy':
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
|
||||
ซื้อตามรอบปกติ (Active)
|
||||
</span>
|
||||
);
|
||||
case 'churn_risk':
|
||||
return (
|
||||
<span className="flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-rose-500/15 text-rose-300 border border-rose-500/30">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-400" />
|
||||
เกินรอบซื้อ (Churn Risk)
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getChannelBadge = (ch: string) => {
|
||||
if (ch === 'tiktok') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-rose-500/10 text-rose-400 border border-rose-500/20">TikTok Shop</span>;
|
||||
if (ch === 'shopee') return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-orange-500/10 text-orange-400 border border-orange-500/20">Shopee</span>;
|
||||
return <span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">Lazada</span>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6 space-y-6 max-w-[1600px] mx-auto animate-fade-in text-slate-100">
|
||||
{/* ── Top Header Bar ────────────────────────────────────────── */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-slate-900/60 p-5 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400">
|
||||
<Clock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white tracking-tight">
|
||||
{t('repeatCustomersTimeline.title', 'ไทม์ไลน์รอบการซื้อซ้ำ (Repeat Customers Timeline)')}
|
||||
</h1>
|
||||
<p className="text-xs sm:text-sm text-slate-400 mt-0.5">
|
||||
{t(
|
||||
'repeatCustomersTimeline.subtitle',
|
||||
'วิเคราะห์วงรอบการกลับมาซื้อ (Purchase Cycles) และคาดการณ์กำหนดซื้อซ้ำรอบถัดไปเพื่อป้องกันการ Churn'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => fetchData()}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-xs font-medium text-slate-300 hover:text-white transition-all disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
{t('common.refresh', 'รีเฟรช')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Nav Tabs Bar ───────────────────────────────────────────── */}
|
||||
<RepeatCustomersNavTabs />
|
||||
|
||||
{/* ── Churn Risk & Retention Summary KPI Cards ───────────────── */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5">
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-slate-400">ลูกค้าซื้อซ้ำทั้งหมด</span>
|
||||
<Users className="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-white font-mono">
|
||||
{fmtNum(summary.totalRepeatCustomers)} <span className="text-xs text-slate-500 font-normal">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">ลูกค้าที่มี ≥ 2 ออเดอร์</p>
|
||||
</div>
|
||||
|
||||
{/* Due Soon Card */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setChurnStatus('due_soon');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
|
||||
churnStatus === 'due_soon' ? 'border-amber-400 ring-1 ring-amber-400' : 'border-amber-500/20 hover:border-amber-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-amber-300">ถึงรอบซื้อซ้ำ (Due Soon)</span>
|
||||
<AlertCircle className="w-4 h-4 text-amber-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-amber-400 font-mono">
|
||||
{fmtNum(summary.dueSoonCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-amber-400/80 mt-0.5">โอกาสทองในการส่งโปรโมชัน</p>
|
||||
</div>
|
||||
|
||||
{/* Healthy Card */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setChurnStatus('healthy');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
|
||||
churnStatus === 'healthy' ? 'border-emerald-400 ring-1 ring-emerald-400' : 'border-emerald-500/20 hover:border-emerald-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-emerald-300">ซื้อตามรอบปกติ (Active)</span>
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-emerald-400 font-mono">
|
||||
{fmtNum(summary.healthyCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-emerald-400/80 mt-0.5">ระยะเวลายังไม่เกินรอบเฉลี่ย</p>
|
||||
</div>
|
||||
|
||||
{/* Churn Risk Card */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setChurnStatus('churn_risk');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`bg-slate-900/50 p-4 rounded-2xl border backdrop-blur-md cursor-pointer transition-all hover:scale-[1.02] ${
|
||||
churnStatus === 'churn_risk' ? 'border-rose-400 ring-1 ring-rose-400' : 'border-rose-500/20 hover:border-rose-500/40'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-semibold text-rose-300">เสี่ยงหลุด (Churn Risk)</span>
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-bold text-rose-400 font-mono">
|
||||
{fmtNum(summary.churnRiskCount)} <span className="text-xs text-slate-500 font-normal">คน</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-rose-400/80 mt-0.5">เกินรอบเฉลี่ยไปมาก (Win-back)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filters & Search Bar ───────────────────────────────────── */}
|
||||
<div className="bg-slate-900/50 p-4 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Status Filter Tabs */}
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setChurnStatus('all');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
churnStatus === 'all'
|
||||
? 'bg-indigo-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
สถานะทั้งหมด
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChurnStatus('due_soon');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
churnStatus === 'due_soon'
|
||||
? 'bg-amber-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
ถึงรอบซื้อ (Due Soon)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChurnStatus('healthy');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
churnStatus === 'healthy'
|
||||
? 'bg-emerald-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
ปกติ (Active)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChurnStatus('churn_risk');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
churnStatus === 'churn_risk'
|
||||
? 'bg-rose-600 text-white shadow-md'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
เสี่ยงหลุด (Churn Risk)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Channel Selection */}
|
||||
<div className="flex items-center gap-1.5 p-1 bg-slate-950/60 rounded-xl border border-white/5">
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('all');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'all' ? 'bg-white/15 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
ทุกช่องทาง
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('tiktok');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'tiktok' ? 'bg-rose-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
TikTok
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('shopee');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'shopee' ? 'bg-orange-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Shopee
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setChannel('lazada');
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-2 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
channel === 'lazada' ? 'bg-blue-600 text-white' : 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Lazada
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative min-w-[220px] flex-1 max-w-sm">
|
||||
<Search className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="ค้นหาชื่อลูกค้า, Username..."
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs bg-slate-950 border border-white/10 rounded-xl text-slate-200 placeholder-slate-500 focus:outline-none focus:border-indigo-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Customer Journey Swimlane Cards ────────────────────────── */}
|
||||
<div className="space-y-4">
|
||||
{loading ? (
|
||||
<div className="py-24 text-center text-slate-400 bg-slate-900/50 rounded-2xl border border-white/10">
|
||||
<div className="w-8 h-8 border-2 border-indigo-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
กำลังคำนวณไทม์ไลน์และรอบการซื้อซ้ำ...
|
||||
</div>
|
||||
) : !data?.customers || data.customers.length === 0 ? (
|
||||
<div className="py-16 text-center text-slate-500 bg-slate-900/50 rounded-2xl border border-white/10">
|
||||
<Users className="w-10 h-10 mx-auto mb-2 opacity-30" />
|
||||
ไม่พบข้อมูลลูกค้าที่ตรงตามเงื่อนไข
|
||||
</div>
|
||||
) : (
|
||||
data.customers.map((cust: any) => (
|
||||
<div
|
||||
key={cust.id}
|
||||
className="bg-slate-900/60 p-4 sm:p-5 rounded-2xl border border-white/10 backdrop-blur-md hover:border-indigo-500/30 transition-all space-y-4"
|
||||
>
|
||||
{/* Card Top: Customer Identity & Status */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pb-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-800/80 border border-white/10 flex items-center justify-center font-bold text-sm text-indigo-400">
|
||||
{cust.displayName.slice(0, 2)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-bold text-base text-white">
|
||||
{cust.displayName}
|
||||
</span>
|
||||
{getChannelBadge(cust.primaryChannel)}
|
||||
{getStatusBadge(cust.churnStatus)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400 mt-1">
|
||||
{cust.location && (
|
||||
<span className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 text-slate-500" />
|
||||
{cust.location}
|
||||
</span>
|
||||
)}
|
||||
<span>•</span>
|
||||
<span className="text-emerald-400 font-semibold font-mono">
|
||||
ยอดสะสม {fmtBaht(cust.totalSpent)}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span className="font-bold text-indigo-300 font-mono">
|
||||
{cust.orderCount} คำสั่งซื้อ
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side: Cycle metrics */}
|
||||
<div className="flex items-center gap-4 text-xs bg-slate-950/60 px-3 py-2 rounded-xl border border-white/5">
|
||||
<div className="text-right">
|
||||
<span className="text-[11px] text-slate-500 block">รอบซื้อเฉลี่ย</span>
|
||||
<span className="font-bold font-mono text-indigo-400">
|
||||
{cust.avgIntervalDays} วัน
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/10" />
|
||||
<div className="text-right">
|
||||
<span className="text-[11px] text-slate-500 block">ซื้อล่าสุด</span>
|
||||
<span className="font-bold font-mono text-slate-300">
|
||||
{cust.daysSinceLastOrder} วันที่แล้ว
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-px h-6 bg-white/10" />
|
||||
<div className="text-right">
|
||||
<span className="text-[11px] text-slate-500 block">คาดการณ์รอบถัดไป</span>
|
||||
<span className="font-bold font-mono text-amber-400">
|
||||
{cust.predictedNextOrderDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Horizontal Timeline Bar (Swimlane) */}
|
||||
<div className="space-y-2 pt-1">
|
||||
<div className="text-[11px] text-slate-400 flex items-center justify-between">
|
||||
<span>ไทม์ไลน์คำสั่งซื้อ (Order Timeline)</span>
|
||||
<span className="text-slate-500 font-mono">
|
||||
{cust.firstOrderDate.slice(0, 10)} ───► {cust.lastOrderDate.slice(0, 10)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Bar with Nodes */}
|
||||
<div className="relative w-full py-4 px-3 bg-slate-950/50 rounded-xl border border-white/5 overflow-x-auto">
|
||||
<div className="relative flex items-center justify-between min-w-[500px]">
|
||||
{/* Connecting line */}
|
||||
<div className="absolute left-0 right-0 top-1/2 -translate-y-1/2 h-0.5 bg-gradient-to-r from-indigo-500 via-purple-500 to-amber-500" />
|
||||
|
||||
{/* Order Nodes */}
|
||||
{cust.orders.map((o: any, oIdx: number) => {
|
||||
const isLast = oIdx === cust.orders.length - 1;
|
||||
return (
|
||||
<div
|
||||
key={o.orderId || oIdx}
|
||||
className="relative z-10 flex flex-col items-center group cursor-pointer"
|
||||
title={`ออเดอร์ #${o.orderId} — ${o.date} (${fmtBaht(o.amount)})`}
|
||||
>
|
||||
{/* Node Circle */}
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full flex items-center justify-center font-bold text-[10px] shadow-lg transition-transform group-hover:scale-125 ${
|
||||
isLast
|
||||
? 'bg-indigo-500 text-white ring-4 ring-indigo-500/20'
|
||||
: 'bg-slate-800 text-slate-300 border border-indigo-400'
|
||||
}`}
|
||||
>
|
||||
{oIdx + 1}
|
||||
</div>
|
||||
|
||||
{/* Node Label Below */}
|
||||
<div className="mt-1.5 text-center whitespace-nowrap">
|
||||
<span className="text-[10px] text-slate-400 block font-mono">
|
||||
{o.date.slice(5, 10)}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-slate-200 font-mono block">
|
||||
{fmtBaht(o.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Predicted Next Order Marker */}
|
||||
<div className="relative z-10 flex flex-col items-center">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-dashed border-amber-400 bg-amber-500/20 flex items-center justify-center animate-pulse">
|
||||
<Sparkles className="w-2.5 h-2.5 text-amber-400" />
|
||||
</div>
|
||||
<div className="mt-1.5 text-center whitespace-nowrap">
|
||||
<span className="text-[10px] text-amber-300 font-medium block">
|
||||
รอบถัดไป
|
||||
</span>
|
||||
<span className="text-[10px] font-mono text-amber-400/80 block">
|
||||
~{cust.predictedNextOrderDate.slice(5, 10)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Bottom Actions */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs text-slate-500">
|
||||
{cust.orders?.[0]?.productNames?.[0] ? `สินค้าล่าสุด: ${cust.orders[cust.orders.length - 1]?.productNames?.[0] || ''}` : ''}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedCustomer(cust)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-xl bg-indigo-600/20 hover:bg-indigo-600/30 text-indigo-300 hover:text-white text-xs font-medium border border-indigo-500/30 transition-all"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
ดูประวัติคำสั่งซื้อทั้งหมด ({cust.orderCount} ครั้ง)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Pagination Bar ─────────────────────────────────────────── */}
|
||||
{data?.pagination && data.pagination.totalPages > 1 && (
|
||||
<div className="p-4 bg-slate-900/50 rounded-2xl border border-white/10 backdrop-blur-md flex flex-wrap items-center justify-between gap-3 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>
|
||||
กำลังแสดงหน้า <span className="font-semibold text-white">{page}</span> จาก{' '}
|
||||
<span className="font-semibold text-white">{data.pagination.totalPages}</span> หน้า (ทั้งหมด {fmtNum(data.pagination.total)} ลูกค้า)
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 text-slate-500">
|
||||
<span>แสดง:</span>
|
||||
<select
|
||||
value={limit}
|
||||
onChange={(e) => {
|
||||
setLimit(Number(e.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
className="bg-slate-950 border border-white/10 text-slate-300 text-xs rounded-lg px-2 py-1 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value={20}>20 คน</option>
|
||||
<option value={50}>50 คน</option>
|
||||
<option value={100}>100 คน</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1 || loading}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<span className="px-3 py-1 rounded-lg bg-slate-950 font-mono font-semibold text-white border border-white/10">
|
||||
{page} / {data.pagination.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(data.pagination.totalPages, p + 1))}
|
||||
disabled={page >= data.pagination.totalPages || loading}
|
||||
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-slate-300 hover:text-white disabled:opacity-30 border border-white/5"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Order Detail Modal ─────────────────────────────────────── */}
|
||||
{selectedCustomer && (
|
||||
<RepeatCustomerOrdersModal
|
||||
customer={selectedCustomer}
|
||||
onClose={() => setSelectedCustomer(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
Check,
|
||||
Building2,
|
||||
Layers,
|
||||
Sparkle,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { API_BASE } from '@homaroma/types';
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user