import React, { useState, useMemo, useEffect, useCallback } from 'react'; import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, AreaChart, Area, LabelList } from 'recharts'; import { LayoutDashboard, MapPin, User, Megaphone, DollarSign, Calendar, Menu, X, TrendingUp, Users, RefreshCw, Loader2, Share2, CheckCircle, Search, Info, AlertCircle, Clock, Wallet, BarChart3, PieChart as PieChartIcon, ArrowUpRight, Target, Award, ChevronDown, ChevronUp, CheckSquare, Square, Briefcase, UserCheck, Globe, ArrowRightCircle, Star, Columns, Repeat, Map, Building } from 'lucide-react'; // --- Configuration --- const SHEET_ID = "1g_hI4Irkti9C36z7jevU8BCFKyA8MGclYRWbo-aI8eA"; const LEAD_URL = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/export?format=csv&gid=0`; const VISIT_TAB_GID = "2023917287"; const VISIT_URL = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/export?format=csv&gid=${VISIT_TAB_GID}`; // ดึงข้อมูล Deals จาก Sheet หลัก (1g_hI...) ตาม Tab GID ที่ระบุ const DEAL_SHEET_ID = SHEET_ID; const DEAL_TAB_GID = "1181123508"; const DEAL_URL = `https://docs.google.com/spreadsheets/d/${DEAL_SHEET_ID}/export?format=csv&gid=${DEAL_TAB_GID}`; // Default Fallback Columns (เผื่อระบบ Auto-detect หาหัวคอลัมน์ไม่เจอ) const DEAL_COLS = { SALES: 0, DATE: 1, SECTION: 3, PROJECT: 4, VALUE: 5, COMMISSION: 6 // เพิ่มคอลัมน์ Commission }; // --- Helper Functions --- const cleanString = (val) => String(val || '').trim(); const cleanNum = (val) => { if (!val) return 0; try { const str = String(val).replace(/,/g, '').replace(/"/g, '').replace(/฿/g, '').trim(); const n = parseFloat(str); return isNaN(n) ? 0 : n; } catch (e) { return 0; } }; const parseDateString = (dateStr) => { if (!dateStr) return null; const str = cleanString(dateStr); const parts = str.split('/'); if (parts.length !== 3) return null; return new Date(parseInt(parts[2], 10), parseInt(parts[1], 10) - 1, parseInt(parts[0], 10)); }; const getMonthFromDate = (dateObj) => { if (!dateObj || isNaN(dateObj.getTime())) return null; const monthsRef = ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"]; return monthsRef[dateObj.getMonth()]; }; const getBudgetRange = (value, section) => { const valStr = cleanString(value).toUpperCase().replace(/[^0-9.KM-]/g, ''); if (!valStr) return 'Other'; const parts = valStr.split('-'); const convert = (s) => { if (s.includes('K')) return parseFloat(s.replace('K', '')) * 1000; if (s.includes('M')) return parseFloat(s.replace('M', '')) * 1000000; return parseFloat(s) || 0; }; const num = parts.length === 2 ? (convert(parts[0]) + convert(parts[1])) / 2 : convert(parts[0]); const type = cleanString(section).toLowerCase(); if (type.includes('rent')) { if (num >= 10000 && num <= 15000) return '10-15K'; if (num > 15000 && num <= 20000) return '16-20K'; if (num > 20000 && num <= 50000) return '30-50K'; if (num > 50000 && num <= 99000) return '51-99K'; if (num >= 100000) return '100K+'; } else if (type.includes('buy')) { if (num >= 1000000 && num < 2000000) return '1-1.9M'; if (num >= 2000000 && num < 3000000) return '2-2.9M'; if (num >= 3000000 && num < 5000000) return '3-4.9M'; if (num >= 5000000 && num < 10000000) return '5-9.9M'; if (num >= 10000000) return '10M+'; } return 'Other'; }; const formatCurrency = (val) => new Intl.NumberFormat('th-TH', { style: 'currency', currency: 'THB', maximumFractionDigits: 0 }).format(val || 0); const formatShortCurrency = (val) => { const n = Number(val); if (isNaN(n)) return '0'; if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; return n.toString(); }; const COLORS = ['#F97316', '#3B82F6', '#10B981', '#8B5CF6', '#EC4899', '#F59E0B', '#6366F1', '#14B8A6']; // --- CSV Parsing --- const robustCSVParse = (text) => { const result = []; let row = []; let field = ''; let inQuotes = false; if (!text) return []; for (let i = 0; i < text.length; i++) { const char = text[i]; const nextChar = text[i+1]; if (char === '"') { if (inQuotes && nextChar === '"') { field += '"'; i++; } else { inQuotes = !inQuotes; } } else if (char === ',' && !inQuotes) { row.push(field.trim()); field = ''; } else if ((char === '\r' || char === '\n') && !inQuotes) { if (field !== '' || row.length > 0) { row.push(field.trim()); result.push(row); row = []; field = ''; } if (char === '\r' && nextChar === '\n') i++; } else { field += char; } } if (field !== '' || row.length > 0) { row.push(field.trim()); result.push(row); } return result; }; // --- Components --- const StatBarChart = ({ title, data, compareMode, color = "#F97316", icon: Icon, height = "380px" }) => { const validData = useMemo(() => Array.isArray(data) ? data : [], [data]); return (
{Icon &&
}

{title}

title.includes('Spend') || title.includes('Budget') || title.includes('Value') || title.includes('Commission') ? formatCurrency(value) : value} /> {compareMode && } title.includes('Spend') || title.includes('Budget') || title.includes('Value') || title.includes('Commission') ? formatShortCurrency(value) : value} /> {compareMode && ( )}
); }; const MultiSelectFilter = ({ label, icon, options, selectedValues, onToggle, isOpen, onToggleOpen }) => { const [searchTerm, setSearchTerm] = useState(""); const safeOptions = Array.isArray(options) ? options : []; const safeSelected = Array.isArray(selectedValues) ? selectedValues : []; const filteredOptions = useMemo(() => { if (!searchTerm) return safeOptions; return safeOptions.filter(opt => String(opt).toLowerCase().includes(searchTerm.toLowerCase())); }, [safeOptions, searchTerm]); return (
{isOpen && (
setSearchTerm(e.target.value)} className="w-full pl-8 pr-3 py-2 bg-white border border-gray-200 rounded-xl text-[10px] focus:ring-2 focus:ring-orange-500 outline-none transition-all font-bold" />
{!searchTerm && ( <>
)} {filteredOptions.length > 0 ? ( filteredOptions.map(opt => ( )) ) : (
ไม่พบข้อมูล...
)}
)}
); }; const App = () => { const [activeTab, setActiveTab] = useState('overview'); const [leadData, setLeadData] = useState([]); const [marketingData, setMarketingData] = useState([]); const [visitData, setVisitData] = useState([]); const [dealData, setDealData] = useState([]); const [dealHeaders, setDealHeaders] = useState([]); const [loading, setLoading] = useState(true); const [lastUpdated, setLastUpdated] = useState(null); const [sidebarOpen, setSidebarOpen] = useState(true); const [showToast, setShowToast] = useState(false); const [compareMode, setCompareMode] = useState(false); const [compareMonth, setCompareMonth] = useState('JAN'); const [compareStartDate, setCompareStartDate] = useState(''); const [compareEndDate, setCompareEndDate] = useState(''); const [openFilters, setOpenFilters] = useState({ ads: true, location: false, media: false, pca: false }); const [filters, setFilters] = useState({ startDate: '', endDate: '', month: 'All', section: 'All', budgetRange: 'All', location: [], media: [], sales: 'All', pca: [], ads: [] }); const toggleMultiSelect = (key, val) => { setFilters(prev => { const current = prev[key] || []; if (val === 'ALL') return { ...prev, [key]: [] }; const exists = current.includes(val); const updated = exists ? current.filter(item => item !== val) : [...current, val]; return { ...prev, [key]: updated }; }); }; // Safe Fetch Logic const fetchData = useCallback(async () => { setLoading(true); try { // 1. Leads & Ads const response = await fetch(`${LEAD_URL}&t=${Date.now()}`); if (!response.ok) throw new Error("Network error"); const csvText = await response.text(); const allRows = robustCSVParse(csvText); const mappedLeads = allRows.slice(1).filter(cols => cols.length >= 17).map(cols => ({ no: cols[0], Sales: cols[1], PCA: cols[2], Section: cols[3], DateRaw: cols[4], Time: cleanString(cols[5]), Month: String(cols[6]).toUpperCase(), media: cols[12], ADS: cols[13], Budget: cols[16], Location: cols[17], Grade: cols[18] })).filter(item => item.no && !isNaN(parseInt(item.no)) && item.Sales && item.Sales !== "-"); const monthsRef = ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"]; const mappedMkt = allRows.slice(1) .filter(cols => { if (cols.length <= 25 || !cols[25]) return false; const raw = String(cols[25]).toUpperCase().trim(); const parsed = parseDateString(raw); return monthsRef.includes(raw) || (parsed && !isNaN(parsed.getTime())); }) .map(cols => { const rawDateStr = String(cols[25]).trim(); const parsedDate = parseDateString(rawDateStr); const isFullDate = parsedDate && !isNaN(parsedDate.getTime()); const fb = cleanNum(cols[26]); const ig = cleanNum(cols[27]); const tiktok = cleanNum(cols[28]); const web = cleanNum(cols[29]); const line = cleanNum(cols[30]); const living = cleanNum(cols[31]); const dd = cleanNum(cols[32]); const propit = cleanNum(cols[33]); return { rawDate: rawDateStr, parsedDate: isFullDate ? parsedDate : null, month: isFullDate ? getMonthFromDate(parsedDate) : rawDateStr.toUpperCase(), fb, ig, tiktok, web, line, living, dd, propit, totalSpend: fb + ig + tiktok + web + line + living + dd + propit, }; }); setLeadData(mappedLeads); setMarketingData(mappedMkt); // 2. Visits if (VISIT_TAB_GID) { try { const visitRes = await fetch(`${VISIT_URL}&t=${Date.now()}`); if (visitRes.ok) { const visitText = await visitRes.text(); const visitRows = robustCSVParse(visitText); if (visitRows.length > 0) { const vHeaders = visitRows[0].map(h => String(h).toLowerCase().trim()); let idxSales = 0, idxDate = 1, idxAds = 2, idxSection = 3, idxProject = 4, idxBudget = 5, idxMedia = -1; // Auto-detect อย่างรัดกุม ป้องกันการดึงคอลัมน์ผิด vHeaders.forEach((h, i) => { if(h === 'sales' || h === 'sale' || h === 'ชื่อเซลส์' || h === 'เซล') idxSales = i; if(h === 'date' || h === 'วันที่') idxDate = i; if(h === 'ads' || h === 'แอด') idxAds = i; if(h === 'section' || h === 'type' || h.includes('ประเภท') || h.includes('สถานะ') || h.includes('rent/buy') || h.includes('rent / buy')) idxSection = i; if(h === 'project' || h.includes('โครงการ')) idxProject = i; if(h === 'budget' || h.includes('งบ')) idxBudget = i; if(h === 'media' || h.includes('สื่อ') || h.includes('ช่องทาง')) idxMedia = i; }); const mappedVisits = visitRows.slice(1).map(cols => { if(!cols[idxSales]) return null; return { Sales: cleanString(cols[idxSales]), DateRaw: cols[idxDate], ADS: cols[idxAds], Section: cleanString(cols[idxSection]), Project: cols[idxProject], Budget: cols[idxBudget], Media: idxMedia !== -1 ? cleanString(cols[idxMedia]) : '' }; }).filter(Boolean); setVisitData(mappedVisits); } } } catch (e) { console.warn("Visit tab error", e); } } // 3. Closed Deals if (DEAL_TAB_GID) { try { const dealRes = await fetch(`${DEAL_URL}&t=${Date.now()}`); if (dealRes.ok) { const dealText = await dealRes.text(); const dealRows = robustCSVParse(dealText); if(dealRows.length > 0) { setDealHeaders(dealRows[0]); const headers = dealRows[0].map(h => String(h).toLowerCase().trim()); let idxSales = DEAL_COLS.SALES; let idxDate = DEAL_COLS.DATE; let idxSection = DEAL_COLS.SECTION; let idxProject = DEAL_COLS.PROJECT; let idxValue = DEAL_COLS.VALUE; let idxCommission = DEAL_COLS.COMMISSION; let idxMedia = -1; // Auto-detect อย่างรัดกุม ป้องกันการดึงคอลัมน์ผิด headers.forEach((h, i) => { if(h === 'sales' || h === 'sale' || h === 'ชื่อเซลส์' || h === 'เซล') idxSales = i; if(h === 'date' || h === 'วันที่') idxDate = i; if(h === 'section' || h === 'type' || h.includes('ประเภท') || h.includes('สถานะ') || h.includes('rent/buy') || h.includes('rent / buy')) idxSection = i; if(h === 'project' || h.includes('โครงการ')) idxProject = i; if(h === 'value' || h.includes('price') || h.includes('ยอด') || h.includes('ราคา') || h.includes('มูลค่า')) idxValue = i; if(h === 'commission' || h.includes('คอม') || h.includes('com')) idxCommission = i; if(h === 'media' || h.includes('สื่อ') || h.includes('ช่องทาง')) idxMedia = i; }); const mappedDeals = dealRows.slice(1).map(cols => { if(cols.length === 0 || (!cols[idxSales] && !cols[idxProject] && !cols[idxValue])) return null; return { Sales: cleanString(cols[idxSales]) || 'Unknown', DateRaw: cols[idxDate] || '', Section: cleanString(cols[idxSection]) || 'Unknown', Project: cols[idxProject] || '-', ValueRaw: cols[idxValue] || '0', Value: cleanNum(cols[idxValue]), CommissionRaw: cols[idxCommission] || '0', Commission: cleanNum(cols[idxCommission]), Media: idxMedia !== -1 ? cleanString(cols[idxMedia]) : '' }; }).filter(Boolean); setDealData(mappedDeals); } } } catch (e) { console.warn("Deal tab error", e); } } setLastUpdated(new Date().toLocaleTimeString()); } catch (error) { console.error("Fetch Error:", error); } finally { setLoading(false); } }, []); // Initial Load & URL Params useEffect(() => { const params = new URLSearchParams(window.location.search); let hasParams = false; const newFilters = { ...filters }; Object.keys(filters).forEach(key => { const val = params.get(key); if (val) { if (['ads', 'location', 'media', 'pca'].includes(key)) { newFilters[key] = val.split(','); } else { newFilters[key] = val; } hasParams = true; } }); if (hasParams) setFilters(newFilters); fetchData(); // Run once on mount }, []); // Empty dependency array to run only once const handleShare = () => { const params = new URLSearchParams(); Object.entries(filters).forEach(([key, val]) => { if (Array.isArray(val)) { if (val.length > 0) params.append(key, val.join(',')); } else { if (val && val !== 'All') params.append(key, val); } }); const shareUrl = `${window.location.origin}${window.location.pathname}?${params.toString()}`; const el = document.createElement('textarea'); el.value = shareUrl; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); setShowToast(true); setTimeout(() => setShowToast(false), 2000); }; // Helper for filter logic const checkMultiSelect = (itemVal, filterVal) => { if (!filterVal || filterVal.length === 0) return true; return filterVal.includes(cleanString(itemVal)); }; // --- Filtered Data Memos --- const filteredLeads = useMemo(() => { return leadData.filter(item => { const itemDate = parseDateString(item.DateRaw); let matchDate = true; if (filters.startDate) { const s = new Date(filters.startDate); s.setHours(0,0,0,0); if (!itemDate || itemDate < s) matchDate = false; } if (filters.endDate) { const e = new Date(filters.endDate); e.setHours(23,59,59,999); if (!itemDate || itemDate > e) matchDate = false; } const matchMonth = filters.month === 'All' || item.Month === String(filters.month).toUpperCase(); const matchSales = filters.sales === 'All' || item.Sales === filters.sales; const matchSection = filters.section === 'All' || String(item.Section).toLowerCase().includes(String(filters.section).toLowerCase()); const matchBudget = filters.budgetRange === 'All' || getBudgetRange(item.Budget, item.Section) === filters.budgetRange; const matchLoc = checkMultiSelect(item.Location, filters.location); const matchAds = checkMultiSelect(item.ADS, filters.ads); const matchMedia = checkMultiSelect(item.media, filters.media); const matchPCA = checkMultiSelect(item.PCA, filters.pca); return matchDate && matchMonth && matchSales && matchSection && matchBudget && matchLoc && matchAds && matchMedia && matchPCA; }); }, [leadData, filters]); const filteredMkt = useMemo(() => { const sLimit = filters.startDate ? new Date(filters.startDate) : null; if (sLimit) sLimit.setHours(0,0,0,0); const eLimit = filters.endDate ? new Date(filters.endDate) : null; if (eLimit) eLimit.setHours(23,59,59,999); const monthsRef = ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"]; return marketingData.filter(m => { const matchMonthDropdown = filters.month === 'All' || m.month === String(filters.month).toUpperCase(); if (!matchMonthDropdown) return false; if (m.parsedDate) { // Daily Data if (sLimit && m.parsedDate < sLimit) return false; if (eLimit && m.parsedDate > eLimit) return false; } else { // Monthly Data (JAN etc) if (sLimit || eLimit) { const idx = monthsRef.indexOf(m.month); if (idx === -1) return false; // Estimate month range for 2026 const mStart = new Date(2026, idx, 1); const mEnd = new Date(2026, idx + 1, 0, 23, 59, 59); // If selected range is completely outside this month if (sLimit && mEnd < sLimit) return false; if (eLimit && mStart > eLimit) return false; } } return true; }); }, [marketingData, filters]); const filteredVisits = useMemo(() => { return visitData.filter(v => { const matchSales = filters.sales === 'All' || v.Sales === filters.sales; const vDate = parseDateString(v.DateRaw); let matchDate = true; if (filters.startDate) { const s = new Date(filters.startDate); s.setHours(0,0,0,0); if (!vDate || vDate < s) matchDate = false; } if (filters.endDate) { const e = new Date(filters.endDate); e.setHours(23,59,59,999); if (!vDate || vDate > e) matchDate = false; } let vMonth = vDate ? getMonthFromDate(vDate) : null; const matchMonth = filters.month === 'All' || (vMonth === filters.month.toUpperCase()); const matchSection = filters.section === 'All' || String(v.Section).toLowerCase().includes(String(filters.section).toLowerCase()); const matchMedia = checkMultiSelect(v.Media, filters.media); return matchSales && matchDate && matchSection && matchMonth && matchMedia; }); }, [visitData, filters]); const filteredDeals = useMemo(() => { return dealData.filter(d => { const matchSales = filters.sales === 'All' || d.Sales === filters.sales; const dDate = parseDateString(d.DateRaw); let matchDate = true; if (filters.startDate) { const s = new Date(filters.startDate); s.setHours(0,0,0,0); if (!dDate || dDate < s) matchDate = false; } if (filters.endDate) { const e = new Date(filters.endDate); e.setHours(23,59,59,999); if (!dDate || dDate > e) matchDate = false; } let dMonth = dDate ? getMonthFromDate(dDate) : null; const matchMonth = filters.month === 'All' || (dMonth === filters.month.toUpperCase()); const matchSection = filters.section === 'All' || String(d.Section).toLowerCase().includes(String(filters.section).toLowerCase()); const matchMedia = checkMultiSelect(d.Media, filters.media); return matchSales && matchDate && matchSection && matchMonth && matchMedia; }); }, [dealData, filters]); // --- Derived Stats --- const options = useMemo(() => { const getUniques = (key) => [...new Set(leadData.map(item => cleanString(item[key])))].filter(v => v).sort(); return { months: ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"], medias: getUniques('media'), salesList: getUniques('Sales'), pcaList: getUniques('PCA'), locations: [...new Set(leadData.map(item => item.Location))].filter(v => v).sort(), adsList: getUniques('ADS'), rentRanges: ['10-15K', '16-20K', '30-50K', '51-99K', '100K+'], buyRanges: ['1-1.9M', '2-2.9M', '3-4.9M', '5-9.9M', '10M+'] }; }, [leadData]); const onlineLeadsCount = useMemo(() => { const onlineSources = ['facebook page', 'ig', 'tiktok', 'line', 'line@', 'living insider', 'youtube', 'property hub', 'website', 'dd property']; return filteredLeads.filter(lead => { const m = String(lead.media || '').toLowerCase(); const isOnline = onlineSources.some(src => m.includes(src)); const isPC = m.includes('facebook page pc'); return isOnline && !isPC; }).length; }, [filteredLeads]); // --- Comparison Logic --- const comparedLeads = useMemo(() => { if (!compareMode) return []; // Limits const sLimit = compareStartDate ? new Date(compareStartDate) : null; if (sLimit) sLimit.setHours(0,0,0,0); const eLimit = compareEndDate ? new Date(compareEndDate) : null; if (eLimit) eLimit.setHours(23,59,59,999); return leadData.filter(item => { const itemDate = parseDateString(item.DateRaw); // Date Check if (sLimit && (!itemDate || itemDate < sLimit)) return false; if (eLimit && (!itemDate || itemDate > eLimit)) return false; // Month Check if (compareMonth !== 'All' && item.Month !== compareMonth.toUpperCase()) return false; // Use same filters as main view for apples-to-apples comparison const matchSales = filters.sales === 'All' || item.Sales === filters.sales; const matchSection = filters.section === 'All' || String(item.Section).toLowerCase().includes(String(filters.section).toLowerCase()); const matchBudget = filters.budgetRange === 'All' || getBudgetRange(item.Budget, item.Section) === filters.budgetRange; const matchLoc = checkMultiSelect(item.Location, filters.location); const matchAds = checkMultiSelect(item.ADS, filters.ads); const matchMedia = checkMultiSelect(item.media, filters.media); const matchPCA = checkMultiSelect(item.PCA, filters.pca); return matchSales && matchSection && matchBudget && matchLoc && matchAds && matchMedia && matchPCA; }); }, [leadData, filters, compareMode, compareMonth, compareStartDate, compareEndDate]); const leadStats = useMemo(() => { const calc = (data, key, limit = 10) => { const counts = data.reduce((acc, curr) => { const v = cleanString(curr[key]) || "N/A"; acc[v] = (acc[v] || 0) + 1; return acc; }, {}); return Object.entries(counts).map(([name, value]) => ({ name, value })); }; const base = { section: calc(filteredLeads, 'Section'), sales: calc(filteredLeads, 'Sales'), pca: calc(filteredLeads, 'PCA'), media: calc(filteredLeads, 'media'), ads: calc(filteredLeads, 'ADS'), location: calc(filteredLeads, 'Location'), grade: calc(filteredLeads, 'Grade'), }; const comp = { section: calc(comparedLeads, 'Section'), sales: calc(comparedLeads, 'Sales'), pca: calc(comparedLeads, 'PCA'), media: calc(comparedLeads, 'media'), ads: calc(comparedLeads, 'ADS'), location: calc(comparedLeads, 'Location'), grade: calc(comparedLeads, 'Grade'), }; // NEW: Time Stats Grouping (Modified) const calcTime = (data) => { const buckets = { '09:00 - 12:00': 0, '12:00 - 15:00': 0, '15:00 - 18:00': 0, '18:00 - 21:00': 0, '21:00 - 00:00': 0 }; data.forEach(item => { const t = cleanString(item.Time); if (!t) return; let h = -1; const parts = t.replace('.', ':').split(':'); if (parts.length > 0) h = parseInt(parts[0]); if (!isNaN(h)) { if (h >= 9 && h < 12) buckets['09:00 - 12:00']++; else if (h >= 12 && h < 15) buckets['12:00 - 15:00']++; else if (h >= 15 && h < 18) buckets['15:00 - 18:00']++; else if (h >= 18 && h < 21) buckets['18:00 - 21:00']++; else if (h >= 21 || h === 0) buckets['21:00 - 00:00']++; // Includes midnight 00:00 as end of day } }); return Object.entries(buckets).map(([name, value]) => ({ name, value })); }; const baseTime = calcTime(filteredLeads); const compTime = calcTime(comparedLeads); // Combine for StatBarChart const mergedTime = baseTime.map((item, i) => ({ name: item.name, value: item.value, compareValue: compTime[i]?.value || 0 })); // Merge for chart const merge = (key) => { const b = base[key]; const c = comp[key]; const map = {}; b.forEach(i => map[i.name] = { name: i.name, value: i.value, compareValue: 0 }); c.forEach(i => { if (!map[i.name]) map[i.name] = { name: i.name, value: 0, compareValue: 0 }; map[i.name].compareValue = i.value; }); return Object.values(map).sort((x,y) => x.value - y.value).slice(-10); // Sort by value ascending for bar chart y-axis }; // Budget special handling const calcBudget = (data, sect, ranges) => { return ranges.map(r => ({ name: r, value: data.filter(i => cleanString(i.Section).toLowerCase().includes(sect) && getBudgetRange(i.Budget, i.Section) === r).length })); }; const rentBase = calcBudget(filteredLeads, 'rent', options.rentRanges); const rentComp = calcBudget(comparedLeads, 'rent', options.rentRanges); const mergedRent = rentBase.map((r, i) => ({ name: r.name, value: r.value, compareValue: rentComp[i].value })); const buyBase = calcBudget(filteredLeads, 'buy', options.buyRanges); const buyComp = calcBudget(comparedLeads, 'buy', options.buyRanges); const mergedBuy = buyBase.map((r, i) => ({ name: r.name, value: r.value, compareValue: buyComp[i].value })); return { section: merge('section'), sales: merge('sales'), pca: merge('pca'), media: merge('media'), ads: merge('ads'), location: merge('location'), grade: merge('grade'), budgetRent: mergedRent, budgetBuy: mergedBuy, time: mergedTime }; }, [filteredLeads, comparedLeads, options]); // --- Aggregations --- const allMonthlySummaries = useMemo(() => { const grouped = marketingData.reduce((acc, curr) => { const m = curr.month; if (!m) return acc; if (!acc[m]) acc[m] = { month: m, totalSpend: 0 }; acc[m].totalSpend += curr.totalSpend; return acc; }, {}); const monthsOrder = ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"]; return Object.values(grouped).sort((a, b) => monthsOrder.indexOf(a.month) - monthsOrder.indexOf(b.month)); }, [marketingData]); const monthlyMarketingSummaries = useMemo(() => { const grouped = filteredMkt.reduce((acc, curr) => { const m = curr.month; if (!m) return acc; if (!acc[m]) acc[m] = { month: m, fb:0, ig:0, tiktok:0, web:0, line:0, living:0, dd:0, propit:0, totalSpend:0 }; acc[m].fb += curr.fb; acc[m].ig += curr.ig; acc[m].tiktok += curr.tiktok; acc[m].web += curr.web; acc[m].line += curr.line; acc[m].living += curr.living; acc[m].dd += curr.dd; acc[m].propit += curr.propit; acc[m].totalSpend += curr.totalSpend; return acc; }, {}); const monthsOrder = ["JAN", "FEB", "MAR", "APR", "MAY", "JUNE", "JULY", "AUG", "SEP", "OCT", "NOV", "DEC"]; return Object.values(grouped).sort((a, b) => monthsOrder.indexOf(a.month) - monthsOrder.indexOf(b.month)); }, [filteredMkt]); // NEW: Calculate Total Spend by Media Channel based on filtered data const mediaSpendStats = useMemo(() => { if (!filteredMkt.length) return []; const totals = { fb:0, ig:0, tiktok:0, web:0, line:0, living:0, dd:0, propit:0 }; filteredMkt.forEach(m => { totals.fb += m.fb || 0; totals.ig += m.ig || 0; totals.tiktok += m.tiktok || 0; totals.web += m.web || 0; totals.line += m.line || 0; totals.living += m.living || 0; totals.dd += m.dd || 0; totals.propit += m.propit || 0; }); return [ { name: 'Facebook', value: totals.fb }, { name: 'TikTok', value: totals.tiktok }, { name: 'Line OA', value: totals.line }, { name: 'Instagram', value: totals.ig }, { name: 'Website', value: totals.web }, { name: 'Living Insider', value: totals.living }, { name: 'DD Property', value: totals.dd }, { name: 'Propit', value: totals.propit } ].sort((a,b) => b.value - a.value).filter(i => i.value > 0); }, [filteredMkt]); const overviewPieData = useMemo(() => { const totalFb = filteredMkt.reduce((a,b)=>a+b.fb,0); const totalTiktok = filteredMkt.reduce((a,b)=>a+b.tiktok,0); const totalLine = filteredMkt.reduce((a,b)=>a+b.line,0); const totalIg = filteredMkt.reduce((a,b)=>a+b.ig,0); const totalOther = filteredMkt.reduce((a,b)=>a+(b.web+b.living+b.dd+b.propit),0); return [ { name: 'FB', value: totalFb }, { name: 'TikTok', value: totalTiktok }, { name: 'Line OA', value: totalLine }, { name: 'IG', value: totalIg }, { name: 'Other', value: totalOther } ].filter(v => v.value > 0); }, [filteredMkt]); const visitStats = useMemo(() => { const calc = (key) => { const counts = filteredVisits.reduce((acc, curr) => { const v = cleanString(curr[key]) || "Unknown"; acc[v] = (acc[v] || 0) + 1; return acc; }, {}); return Object.entries(counts).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value).slice(0, 10); }; const rentCount = filteredVisits.filter(v => cleanString(v.Section).toLowerCase().includes('rent')).length; const buyCount = filteredVisits.filter(v => cleanString(v.Section).toLowerCase().includes('buy')).length; // Budget for visits (Reuse ranges) const budgetRent = options.rentRanges.map(name => ({ name, value: filteredVisits.filter(v => cleanString(v.Section).toLowerCase().includes('rent') && getBudgetRange(v.Budget, 'rent') === name).length })); const budgetBuy = options.buyRanges.map(name => ({ name, value: filteredVisits.filter(v => cleanString(v.Section).toLowerCase().includes('buy') && getBudgetRange(v.Budget, 'buy') === name).length })); return { bySales: calc('Sales'), byProject: calc('Project'), byMedia: calc('Media'), rentCount, buyCount, budgetRent, budgetBuy, sectionData: [{name: 'Rent', value: rentCount}, {name: 'Buy', value: buyCount}] }; }, [filteredVisits, options]); const dealStats = useMemo(() => { const calc = (key) => { const counts = filteredDeals.reduce((acc, curr) => { const v = cleanString(curr[key]) || "Unknown"; acc[v] = (acc[v] || 0) + 1; return acc; }, {}); return Object.entries(counts).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value).slice(0, 10); }; const calcSum = (groupKey, sumKey) => { const sums = filteredDeals.reduce((acc, curr) => { const name = cleanString(curr[groupKey]) || "Unknown"; const val = Number(curr[sumKey]) || 0; acc[name] = (acc[name] || 0) + val; return acc; }, {}); return Object.entries(sums).map(([name, value]) => ({ name, value })).sort((a, b) => b.value - a.value).slice(0, 10); }; const rentCount = filteredDeals.filter(v => cleanString(v.Section).toLowerCase().includes('rent')).length; const buyCount = filteredDeals.filter(v => cleanString(v.Section).toLowerCase().includes('buy')).length; const totalValue = filteredDeals.reduce((acc, curr) => acc + (curr.Value || 0), 0); const totalCommission = filteredDeals.reduce((acc, curr) => acc + (curr.Commission || 0), 0); // แยก Commission ตามประเภท Rent และ Buy const totalCommissionRent = filteredDeals.filter(v => cleanString(v.Section).toLowerCase().includes('rent')).reduce((acc, curr) => acc + (curr.Commission || 0), 0); const totalCommissionBuy = filteredDeals.filter(v => cleanString(v.Section).toLowerCase().includes('buy')).reduce((acc, curr) => acc + (curr.Commission || 0), 0); return { bySales: calc('Sales'), byProject: calc('Project'), byMedia: calc('Media'), topSalesByValue: calcSum('Sales', 'Value'), topSalesByCommission: calcSum('Sales', 'Commission'), totalValue, totalCommission, totalCommissionRent, totalCommissionBuy, rentCount, buyCount, sectionData: [{name: 'Rent', value: rentCount}, {name: 'Buy', value: buyCount}] }; }, [filteredDeals]); // NEW: ข้อมูลสำหรับกราฟ Pipeline สรุปรวม 3 แท็บในหน้า Analytics const pipelineData = useMemo(() => [ { name: '1. Leads', value: filteredLeads.length }, { name: '2. Visits', value: filteredVisits.length }, { name: '3. Closed Deals', value: filteredDeals.length } ], [filteredLeads.length, filteredVisits.length, filteredDeals.length]); const salesPipelineStats = useMemo(() => { const map = {}; const add = (data, key) => { data.forEach(item => { const name = cleanString(item.Sales); if (!name) return; if (!map[name]) map[name] = { name, Leads: 0, Visits: 0, Deals: 0, Commission: 0 }; map[name][key]++; }); }; add(filteredLeads, 'Leads'); add(filteredVisits, 'Visits'); // สำหรับ Deals ให้บวกจำนวนและรวมยอด Commission เข้าไปด้วย filteredDeals.forEach(item => { const name = cleanString(item.Sales); if (!name) return; if (!map[name]) map[name] = { name, Leads: 0, Visits: 0, Deals: 0, Commission: 0 }; map[name].Deals++; map[name].Commission += (item.Commission || 0); }); return Object.values(map).sort((a,b) => b.Leads - a.Leads).slice(0, 10); }, [filteredLeads, filteredVisits, filteredDeals]); const compareLabel = useMemo(() => { if (!compareMode) return 'Cost Per Lead'; if (compareStartDate || compareEndDate) return 'Leads (ช่วงที่เทียบ)'; if (compareMonth !== 'All') return `Leads (${compareMonth})`; return 'Leads (เทียบ)'; }, [compareMode, compareStartDate, compareEndDate, compareMonth]); const totalFilteredSpend = useMemo(() => filteredMkt.reduce((a,b) => a + b.totalSpend, 0), [filteredMkt]); const costPerLead = filteredLeads.length > 0 ? totalFilteredSpend / filteredLeads.length : 0; if (loading && leadData.length === 0) { return (

Amber Realty Lead 2026 Updating...

); } return (
{/* Sidebar */} {/* Main Panel */}
{VISIT_TAB_GID && } {DEAL_TAB_GID && }
{/* Banner */}
Expert Real Estate Partner

AMBER REALTY
LEAD 2026

{/* KPI Summary */}
{[ { label: 'Leads', val: filteredLeads.length, icon: , color: 'text-orange-600' }, { label: 'Ads Spend', val: formatCurrency(totalFilteredSpend), icon: , color: 'text-blue-600' }, { label: 'Cost/Lead', val: compareMode ? comparedLeads.length : formatCurrency(costPerLead), icon: compareMode ? : , color: 'text-purple-600' }, { label: 'Visits', val: filteredVisits.length, icon: , color: 'text-indigo-600' }, { label: 'Closed Deals', val: filteredDeals.length, icon: , color: 'text-green-600' }, { label: 'Deals Value', val: formatShortCurrency(dealStats.totalValue), icon: , color: 'text-yellow-600' }, { label: 'Commission', val: formatShortCurrency(dealStats.totalCommission), icon: , color: 'text-teal-600' }, ].map((kpi, i) => (
{kpi.icon}
{kpi.label} {kpi.val}
))}
{activeTab === 'overview' && (

Platform Spend Share

`${name}: ${formatShortCurrency(value)}`}> {overviewPieData.map((_, index) => )} formatCurrency(v)} />

Marketing Trend (Monthly)

formatCurrency(v)} /> formatShortCurrency(v)} style={{ fill: '#F97316', fontSize: '10px', fontWeight: 'black' }} />
)} {activeTab === 'leads' && (

Analytics Comparison

เปรียบเทียบข้อมูล

{compareMode && (
เริ่ม: setCompareStartDate(e.target.value)} className="p-2 bg-orange-50 border border-orange-200 rounded-xl text-xs font-bold text-orange-600 outline-none"/>
ถึง: setCompareEndDate(e.target.value)} className="p-2 bg-orange-50 border border-orange-200 rounded-xl text-xs font-bold text-orange-600 outline-none"/>
หรือเดือน:
)}
{/* NEW: Pipeline Overview (Leads -> Visits -> Deals) */}

Overall Pipeline (Funnel)

Sales Conversion (Leads vs Visits vs Deals)

{ if (active && payload && payload.length) { const data = payload[0].payload; return (

{label}

{payload.map((entry, index) => (

{entry.name}: {entry.value}

))} {data.Commission > 0 && (

Commission: {formatCurrency(data.Commission)}

)}
); } return null; }} />
{/* NEW: Time Chart */}

Lead Entry Time Slots

{compareMode && } {compareMode && ( )}
)} {activeTab === 'marketing' && (
{/* NEW: Ads Monthly Bar Chart & Media Breakdown */}

Monthly Ads Spend (Bar Chart)

formatCurrency(v)} /> formatShortCurrency(v)} style={{ fill: '#3B82F6', fontSize: '10px', fontWeight: 'black' }} />
{monthlyMarketingSummaries.length > 0 ? monthlyMarketingSummaries.map((m, i) => (

{m.month} SUMMARY

Total Budget

{formatCurrency(m.totalSpend)}

Facebook

{formatCurrency(m.fb)}

TikTok

{formatCurrency(m.tiktok)}

Line OA

{formatCurrency(m.line)}

)) : (
ไม่พบข้อมูล กรุณาตรวจสอบคอลัมน์ Y-AH
)}
)} {activeTab === 'visit' && (
Visit (Rent) {visitStats.rentCount}
Visit (Buy) {visitStats.buyCount}

Visit Type Distribution

`${name}: ${value}`}> {COLORS.map((_, index) => )}

Visit Records (Filtered)

{filteredVisits.slice(0, 50).map((row, idx) => ( ))}
DateSalesProjectBudgetTypeMedia
{row.DateRaw} {row.Sales} {row.Project} {row.Budget} {row.Section} {row.Media || '-'}
{filteredVisits.length === 0 &&
No visits found matching filters.
}
)} {activeTab === 'deals' && (
{filteredDeals.length === 0 && (

ไม่พบข้อมูล Deals หรือการตั้งค่าคอลัมน์ไม่ตรงกัน

ระบบดึงข้อมูลจาก Tab GID: {DEAL_TAB_GID}

{dealHeaders.length > 0 && (

หัวคอลัมน์ที่ระบบอ่านได้จากบรรทัดแรก (โปรดตรวจสอบว่ามีคำว่า Sale, Date, Project, Value หรือไม่):

{dealHeaders.map((h, i) => ( Col {i}: {h || '-ว่าง-'} ))}
)}
)} {filteredDeals.length > 0 && ( <>
Deals (Rent) {dealStats.rentCount}
Deals (Buy) {dealStats.buyCount}
Total Deal Value {formatCurrency(dealStats.totalValue)}
Commission (Rent) {formatCurrency(dealStats.totalCommissionRent)}
Commission (Buy) {formatCurrency(dealStats.totalCommissionBuy)}
Total Commission {formatCurrency(dealStats.totalCommission)}

Closed Deals Records

{filteredDeals.slice(0, 50).map((row, idx) => ( ))}
DateSalesProjectValueCommissionTypeMedia
{row.DateRaw} {row.Sales} {row.Project} {formatCurrency(row.Value)} {formatCurrency(row.Commission)} {row.Section} {row.Media || '-'}
)}
)}
); }; export default App;