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 (
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 */}
{/* 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:
{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 && (
)}
{/* 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 */}
{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)
| Date | Sales | Project | Budget | Type | Media |
{filteredVisits.slice(0, 50).map((row, idx) => (
| {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)}
| Date | Sales | Project | Value | Commission | Type | Media |
{filteredDeals.slice(0, 50).map((row, idx) => (
| {row.DateRaw} |
{row.Sales} |
{row.Project} |
{formatCurrency(row.Value)} |
{formatCurrency(row.Commission)} |
{row.Section} |
{row.Media || '-'} |
))}
>
)}
)}
);
};
export default App;