1865 lines
68 KiB
JavaScript
1865 lines
68 KiB
JavaScript
// 封装带 Token 的 fetch (全局函数)
|
||
async function authenticatedFetch(url, options = {}) {
|
||
const token = localStorage.getItem('crmToken');
|
||
const headers = {
|
||
...options.headers,
|
||
'Authorization': `Bearer ${token}`
|
||
};
|
||
|
||
const response = await fetch(url, { ...options, headers });
|
||
|
||
if (response.status === 401) {
|
||
localStorage.removeItem('crmToken');
|
||
window.location.href = '/static/login.html';
|
||
return;
|
||
}
|
||
|
||
return response;
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
// 登录守卫
|
||
const token = localStorage.getItem('crmToken');
|
||
if (!token && !window.location.pathname.endsWith('login.html')) {
|
||
window.location.href = '/static/login.html';
|
||
return;
|
||
}
|
||
|
||
// Navigation
|
||
const navItems = document.querySelectorAll('.nav-item');
|
||
const customerSection = document.getElementById('customerSection');
|
||
const dashboardSection = document.getElementById('dashboardSection');
|
||
const pageTitle = document.getElementById('pageTitle');
|
||
|
||
// Elements
|
||
const createCustomerForm = document.getElementById('createCustomerForm');
|
||
const importFileForm = document.getElementById('importFileForm');
|
||
const customerTableBody = document.getElementById('customerTableBody');
|
||
const addCustomerBtn = document.getElementById('addCustomerBtn');
|
||
const importBtn = document.getElementById('importBtn');
|
||
const createModal = document.getElementById('createModal');
|
||
const importModal = document.getElementById('importModal');
|
||
const editModal = document.getElementById('editModal');
|
||
const editCustomerForm = document.getElementById('editCustomerForm');
|
||
const menuToggle = document.getElementById('menuToggle');
|
||
const sidebar = document.querySelector('.sidebar');
|
||
const customerFilter = document.getElementById('customerFilter');
|
||
const customerSearchInput = document.getElementById('customerSearchInput');
|
||
const contentArea = document.querySelector('.content-area');
|
||
const refreshCustomersBtn = document.getElementById('refreshCustomersBtn');
|
||
const exportCustomersBtn = document.getElementById('exportCustomersBtn');
|
||
|
||
let allCustomers = [];
|
||
let filteredCustomers = [];
|
||
let dashboardCustomers = [];
|
||
|
||
// Chart instances
|
||
let statusChartInstance = null;
|
||
let typeChartInstance = null;
|
||
let trendChartInstance = null;
|
||
|
||
// Current section tracking
|
||
let currentSection = 'customer';
|
||
|
||
// Pagination state
|
||
let currentPage = 1;
|
||
let pageSize = 10;
|
||
let totalPages = 1;
|
||
let totalItems = 0;
|
||
|
||
let selectedCustomerFilter = '';
|
||
let selectedTypeFilter = '';
|
||
let selectedStatusProgressFilter = '';
|
||
let customerStartDate = '';
|
||
let customerEndDate = '';
|
||
let customerSearchQuery = '';
|
||
|
||
let cellTooltipEl = null;
|
||
let tooltipAnchorCell = null;
|
||
let tooltipHideTimer = null;
|
||
|
||
function ensureCellTooltip() {
|
||
if (cellTooltipEl) return cellTooltipEl;
|
||
|
||
cellTooltipEl = document.createElement('div');
|
||
cellTooltipEl.className = 'cell-tooltip';
|
||
cellTooltipEl.style.display = 'none';
|
||
document.body.appendChild(cellTooltipEl);
|
||
|
||
cellTooltipEl.addEventListener('mouseenter', () => {
|
||
if (tooltipHideTimer) {
|
||
clearTimeout(tooltipHideTimer);
|
||
tooltipHideTimer = null;
|
||
}
|
||
});
|
||
|
||
cellTooltipEl.addEventListener('mouseleave', (e) => {
|
||
const nextTarget = e.relatedTarget;
|
||
if (tooltipAnchorCell && nextTarget && tooltipAnchorCell.contains(nextTarget)) return;
|
||
hideCellTooltip(0);
|
||
});
|
||
|
||
return cellTooltipEl;
|
||
}
|
||
|
||
function hideCellTooltip(delayMs = 120) {
|
||
if (tooltipHideTimer) clearTimeout(tooltipHideTimer);
|
||
tooltipHideTimer = setTimeout(() => {
|
||
if (!cellTooltipEl) return;
|
||
cellTooltipEl.style.display = 'none';
|
||
cellTooltipEl.textContent = '';
|
||
cellTooltipEl.removeAttribute('data-placement');
|
||
tooltipAnchorCell = null;
|
||
}, delayMs);
|
||
}
|
||
|
||
function positionCellTooltipForCell(cell) {
|
||
if (!cellTooltipEl) return;
|
||
|
||
const rect = cell.getBoundingClientRect();
|
||
const viewportPadding = 12;
|
||
const offset = 10;
|
||
|
||
cellTooltipEl.style.left = '0px';
|
||
cellTooltipEl.style.top = '0px';
|
||
cellTooltipEl.style.visibility = 'hidden';
|
||
cellTooltipEl.style.display = 'block';
|
||
|
||
const tipRect = cellTooltipEl.getBoundingClientRect();
|
||
const tipWidth = tipRect.width;
|
||
const tipHeight = tipRect.height;
|
||
|
||
const canShowAbove = rect.top >= tipHeight + offset + viewportPadding;
|
||
const placement = canShowAbove ? 'top' : 'bottom';
|
||
cellTooltipEl.setAttribute('data-placement', placement);
|
||
|
||
const leftUnclamped = rect.left + rect.width / 2 - tipWidth / 2;
|
||
const left = Math.max(viewportPadding, Math.min(leftUnclamped, window.innerWidth - viewportPadding - tipWidth));
|
||
|
||
const top = placement === 'top'
|
||
? rect.top - tipHeight - offset
|
||
: Math.min(rect.bottom + offset, window.innerHeight - viewportPadding - tipHeight);
|
||
|
||
cellTooltipEl.style.left = `${Math.round(left)}px`;
|
||
cellTooltipEl.style.top = `${Math.round(top)}px`;
|
||
cellTooltipEl.style.visibility = 'visible';
|
||
}
|
||
|
||
function showCellTooltipForCell(cell) {
|
||
const rawText = (cell.getAttribute('data-tooltip') || '').trim();
|
||
if (!rawText) return;
|
||
|
||
const tip = ensureCellTooltip();
|
||
tip.textContent = rawText;
|
||
tooltipAnchorCell = cell;
|
||
|
||
if (tooltipHideTimer) {
|
||
clearTimeout(tooltipHideTimer);
|
||
tooltipHideTimer = null;
|
||
}
|
||
|
||
positionCellTooltipForCell(cell);
|
||
}
|
||
|
||
function bindCellTooltipEvents() {
|
||
customerTableBody.addEventListener('mouseover', (e) => {
|
||
const cell = e.target && e.target.closest ? e.target.closest('td.has-overflow') : null;
|
||
if (!cell || !customerTableBody.contains(cell)) return;
|
||
|
||
if (tooltipAnchorCell === cell && cellTooltipEl && cellTooltipEl.style.display === 'block') return;
|
||
showCellTooltipForCell(cell);
|
||
});
|
||
|
||
customerTableBody.addEventListener('mouseout', (e) => {
|
||
const fromCell = e.target && e.target.closest ? e.target.closest('td.has-overflow') : null;
|
||
if (!fromCell) return;
|
||
const nextTarget = e.relatedTarget;
|
||
if (cellTooltipEl && nextTarget && cellTooltipEl.contains(nextTarget)) return;
|
||
if (tooltipAnchorCell === fromCell) hideCellTooltip(120);
|
||
});
|
||
|
||
const reposition = () => {
|
||
if (!cellTooltipEl || cellTooltipEl.style.display !== 'block' || !tooltipAnchorCell) return;
|
||
positionCellTooltipForCell(tooltipAnchorCell);
|
||
};
|
||
|
||
window.addEventListener('resize', reposition);
|
||
window.addEventListener('scroll', reposition, true);
|
||
if (contentArea) contentArea.addEventListener('scroll', reposition, { passive: true });
|
||
}
|
||
|
||
bindCellTooltipEvents();
|
||
|
||
function normalizeDateValue(dateValue) {
|
||
const raw = String(dateValue || '').trim();
|
||
if (!raw) return '';
|
||
|
||
const cleaned = raw.replace(/\./g, '-').replace(/\//g, '-');
|
||
const parts = cleaned.split('-').filter(Boolean);
|
||
if (parts.length < 3) return '';
|
||
|
||
const year = parts[0].padStart(4, '0');
|
||
const month = parts[1].padStart(2, '0');
|
||
const day = parts[2].padStart(2, '0');
|
||
return `${year}-${month}-${day}`;
|
||
}
|
||
|
||
// Navigation event listeners
|
||
navItems.forEach(item => {
|
||
item.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
const section = this.getAttribute('data-section');
|
||
switchSection(section);
|
||
});
|
||
});
|
||
|
||
// Menu toggle for mobile
|
||
menuToggle.addEventListener('click', function () {
|
||
sidebar.classList.toggle('open');
|
||
});
|
||
|
||
// Close sidebar when clicking outside on mobile
|
||
document.addEventListener('click', function (e) {
|
||
if (window.innerWidth <= 768) {
|
||
if (!sidebar.contains(e.target) && !menuToggle.contains(e.target)) {
|
||
sidebar.classList.remove('open');
|
||
}
|
||
}
|
||
});
|
||
|
||
// Sidebar toggle functionality
|
||
const sidebarToggle = document.getElementById('sidebarToggle');
|
||
|
||
if (sidebarToggle && sidebar) {
|
||
sidebarToggle.addEventListener('click', function () {
|
||
sidebar.classList.toggle('collapsed');
|
||
|
||
// Update button title
|
||
if (sidebar.classList.contains('collapsed')) {
|
||
sidebarToggle.title = '展开侧边栏';
|
||
} else {
|
||
sidebarToggle.title = '收起侧边栏';
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add Customer button
|
||
addCustomerBtn.addEventListener('click', function () {
|
||
createModal.style.display = 'block';
|
||
});
|
||
|
||
// Import button
|
||
importBtn.addEventListener('click', function () {
|
||
importModal.style.display = 'block';
|
||
});
|
||
|
||
// Close create modal
|
||
createModal.querySelector('.close').addEventListener('click', function () {
|
||
createModal.style.display = 'none';
|
||
});
|
||
|
||
createModal.querySelector('.cancel-create').addEventListener('click', function () {
|
||
createModal.style.display = 'none';
|
||
});
|
||
|
||
// Close import modal
|
||
importModal.querySelector('.close').addEventListener('click', function () {
|
||
importModal.style.display = 'none';
|
||
});
|
||
|
||
importModal.querySelector('.cancel-import').addEventListener('click', function () {
|
||
importModal.style.display = 'none';
|
||
});
|
||
|
||
// Close edit modal
|
||
editModal.querySelector('.close').addEventListener('click', function () {
|
||
editModal.style.display = 'none';
|
||
});
|
||
|
||
editModal.querySelector('.cancel-edit').addEventListener('click', function () {
|
||
editModal.style.display = 'none';
|
||
});
|
||
|
||
// File name display
|
||
const importFile = document.getElementById('importFile');
|
||
const fileName = document.getElementById('fileName');
|
||
|
||
importFile.addEventListener('change', function () {
|
||
if (this.files.length > 0) {
|
||
fileName.textContent = this.files[0].name;
|
||
} else {
|
||
fileName.textContent = '选择文件...';
|
||
}
|
||
});
|
||
|
||
// Customer filter change event
|
||
customerFilter.addEventListener('change', function () {
|
||
selectedCustomerFilter = this.value;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
});
|
||
|
||
if (customerSearchInput) {
|
||
customerSearchInput.addEventListener('input', function () {
|
||
customerSearchQuery = (this.value || '').trim();
|
||
if (currentSection === 'customer') {
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Type filter change event
|
||
const typeFilter = document.getElementById('typeFilter');
|
||
if (typeFilter) {
|
||
typeFilter.addEventListener('change', function () {
|
||
selectedTypeFilter = this.value;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
});
|
||
}
|
||
|
||
// Customer date range filter events
|
||
const customerStartDateInput = document.getElementById('customerStartDate');
|
||
const customerEndDateInput = document.getElementById('customerEndDate');
|
||
|
||
if (customerStartDateInput) {
|
||
customerStartDateInput.addEventListener('change', function () {
|
||
customerStartDate = this.value;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
});
|
||
}
|
||
|
||
if (customerEndDateInput) {
|
||
customerEndDateInput.addEventListener('change', function () {
|
||
customerEndDate = this.value;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
});
|
||
}
|
||
|
||
// Status progress filter change event
|
||
const statusProgressFilter = document.getElementById('statusProgressFilter');
|
||
if (statusProgressFilter) {
|
||
statusProgressFilter.addEventListener('change', function () {
|
||
selectedStatusProgressFilter = this.value;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
});
|
||
}
|
||
|
||
// Apply date filter for dashboard
|
||
document.getElementById('applyFilters').addEventListener('click', function () {
|
||
const startDate = document.getElementById('startDate').value;
|
||
const endDate = document.getElementById('endDate').value;
|
||
applyDateFilter(startDate, endDate);
|
||
});
|
||
|
||
// Chart title change events
|
||
document.getElementById('statusChartTitle').addEventListener('input', function () {
|
||
applyDateFilter(document.getElementById('startDate').value, document.getElementById('endDate').value);
|
||
});
|
||
|
||
document.getElementById('typeChartTitle').addEventListener('input', function () {
|
||
applyDateFilter(document.getElementById('startDate').value, document.getElementById('endDate').value);
|
||
});
|
||
|
||
// Switch between sections
|
||
function switchSection(section) {
|
||
navItems.forEach(item => {
|
||
item.classList.remove('active');
|
||
});
|
||
|
||
// Hide all sections first
|
||
customerSection.classList.remove('active');
|
||
dashboardSection.classList.remove('active');
|
||
if (document.getElementById('followupSection')) {
|
||
document.getElementById('followupSection').classList.remove('active');
|
||
}
|
||
if (document.getElementById('trialPeriodsSection')) {
|
||
document.getElementById('trialPeriodsSection').classList.remove('active');
|
||
}
|
||
|
||
if (section === 'customer') {
|
||
customerSection.classList.add('active');
|
||
document.querySelector('[data-section="customer"]').classList.add('active');
|
||
pageTitle.textContent = '客户管理';
|
||
currentSection = 'customer';
|
||
loadCustomers();
|
||
} else if (section === 'trialPeriods') {
|
||
console.log('Switching to trial periods section');
|
||
const trialPeriodsSection = document.getElementById('trialPeriodsSection');
|
||
if (trialPeriodsSection) {
|
||
trialPeriodsSection.classList.add('active');
|
||
}
|
||
document.querySelector('[data-section="trialPeriods"]').classList.add('active');
|
||
pageTitle.textContent = '客户试用时间';
|
||
currentSection = 'trialPeriods';
|
||
|
||
// Load customers first, then trial periods
|
||
if (typeof loadCustomersForDropdown === 'function' && typeof loadAllTrialPeriods === 'function') {
|
||
console.log('Loading customers first, then trial periods');
|
||
loadCustomersForDropdown().then(() => {
|
||
console.log('Customers loaded, now loading trial periods');
|
||
loadAllTrialPeriods();
|
||
});
|
||
} else {
|
||
console.error('Required functions not available!');
|
||
}
|
||
} else if (section === 'dashboard') {
|
||
dashboardSection.classList.add('active');
|
||
document.querySelector('[data-section="dashboard"]').classList.add('active');
|
||
pageTitle.textContent = '数据仪表板';
|
||
currentSection = 'dashboard';
|
||
loadDashboardData();
|
||
} else if (section === 'followup') {
|
||
const followupSection = document.getElementById('followupSection');
|
||
if (followupSection) {
|
||
followupSection.classList.add('active');
|
||
}
|
||
document.querySelector('[data-section="followup"]').classList.add('active');
|
||
pageTitle.textContent = '客户跟进';
|
||
currentSection = 'followup';
|
||
if (typeof loadFollowUps === 'function') {
|
||
loadFollowUps();
|
||
}
|
||
}
|
||
|
||
// Close sidebar on mobile after navigation
|
||
if (window.innerWidth <= 768) {
|
||
sidebar.classList.remove('open');
|
||
}
|
||
}
|
||
|
||
// Load customers from API
|
||
async function loadCustomers() {
|
||
try {
|
||
const response = await authenticatedFetch('/api/customers?page=1&pageSize=1000');
|
||
const data = await response.json();
|
||
|
||
if (data.customers) {
|
||
allCustomers = data.customers;
|
||
populateCustomerFilter();
|
||
populateTypeFilter();
|
||
applyAllCustomerFilters();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading customers:', error);
|
||
}
|
||
}
|
||
|
||
// Populate customer filter dropdown
|
||
function populateCustomerFilter() {
|
||
console.log('Populating filter, allCustomers:', allCustomers);
|
||
const uniqueCustomers = [...new Set(allCustomers.map(c => c.customerName).filter(c => c))];
|
||
console.log('Unique customers:', uniqueCustomers);
|
||
customerFilter.innerHTML = '<option value="">全部客户</option>';
|
||
|
||
uniqueCustomers.forEach(customer => {
|
||
const option = document.createElement('option');
|
||
option.value = customer;
|
||
option.textContent = customer;
|
||
customerFilter.appendChild(option);
|
||
});
|
||
|
||
if (selectedCustomerFilter) {
|
||
customerFilter.value = selectedCustomerFilter;
|
||
}
|
||
}
|
||
|
||
// Populate type filter dropdown
|
||
function populateTypeFilter() {
|
||
const typeFilterElement = document.getElementById('typeFilter');
|
||
if (!typeFilterElement) return;
|
||
|
||
const uniqueTypes = [...new Set(allCustomers.map(c => c.type).filter(t => t))];
|
||
typeFilterElement.innerHTML = '<option value="">全部类型</option>';
|
||
|
||
uniqueTypes.forEach(type => {
|
||
const option = document.createElement('option');
|
||
option.value = type;
|
||
option.textContent = type;
|
||
typeFilterElement.appendChild(option);
|
||
});
|
||
|
||
if (selectedTypeFilter) {
|
||
typeFilterElement.value = selectedTypeFilter;
|
||
}
|
||
}
|
||
|
||
// Filter customers by selected customer
|
||
function filterCustomers(selectedCustomer) {
|
||
selectedCustomerFilter = selectedCustomer;
|
||
currentPage = 1;
|
||
applyAllCustomerFilters();
|
||
}
|
||
|
||
function customerMatchesQuery(customer, query) {
|
||
if (!query) return true;
|
||
|
||
const fields = [
|
||
customer.customerName,
|
||
customer.intendedProduct,
|
||
customer.version,
|
||
customer.description,
|
||
customer.solution,
|
||
customer.type,
|
||
customer.module,
|
||
customer.statusProgress,
|
||
customer.reporter
|
||
];
|
||
|
||
const haystack = fields
|
||
.map(v => String(v || ''))
|
||
.join(' ')
|
||
.toLowerCase();
|
||
|
||
const normalizedHaystack = haystack.replace(/[\/.]/g, '-');
|
||
const needle = query.toLowerCase();
|
||
const normalizedNeedle = needle.replace(/[\/.]/g, '-');
|
||
|
||
return normalizedHaystack.includes(normalizedNeedle);
|
||
}
|
||
|
||
function applyAllCustomerFilters() {
|
||
let next = [...allCustomers];
|
||
|
||
if (selectedCustomerFilter) {
|
||
next = next.filter(c => c.customerName === selectedCustomerFilter);
|
||
}
|
||
|
||
if (selectedTypeFilter) {
|
||
next = next.filter(c => c.type === selectedTypeFilter);
|
||
}
|
||
|
||
if (customerStartDate) {
|
||
next = next.filter(c => {
|
||
const date = normalizeDateValue(c.intendedProduct);
|
||
return date && date >= customerStartDate;
|
||
});
|
||
}
|
||
|
||
if (customerEndDate) {
|
||
next = next.filter(c => {
|
||
const date = normalizeDateValue(c.intendedProduct);
|
||
return date && date <= customerEndDate;
|
||
});
|
||
}
|
||
|
||
if (selectedStatusProgressFilter) {
|
||
next = next.filter(c => c.statusProgress === selectedStatusProgressFilter);
|
||
}
|
||
|
||
if (customerSearchQuery) {
|
||
next = next.filter(c => customerMatchesQuery(c, customerSearchQuery));
|
||
}
|
||
|
||
filteredCustomers = next;
|
||
applyCustomerFilter();
|
||
}
|
||
|
||
function toCsvCell(value) {
|
||
const raw = String(value ?? '');
|
||
const normalized = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
const escaped = normalized.replace(/"/g, '""');
|
||
return `"${escaped}"`;
|
||
}
|
||
|
||
function downloadCsv(filename, rows) {
|
||
const content = ['\uFEFF' + rows[0], ...rows.slice(1)].join('\r\n');
|
||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
a.remove();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
function exportCustomersToCsv(customers) {
|
||
const header = [
|
||
'客户',
|
||
'咨询时间',
|
||
'版本',
|
||
'描述',
|
||
'解决方案',
|
||
'类型',
|
||
'模块',
|
||
'状态与进度'
|
||
].map(toCsvCell).join(',');
|
||
|
||
const lines = customers.map(c => {
|
||
const date = normalizeDateValue(c.intendedProduct) || (c.intendedProduct || '');
|
||
const cells = [
|
||
c.customerName || '',
|
||
date,
|
||
c.version || '',
|
||
c.description || '',
|
||
c.solution || '',
|
||
c.type || '',
|
||
c.module || '',
|
||
c.statusProgress || ''
|
||
];
|
||
return cells.map(toCsvCell).join(',');
|
||
});
|
||
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||
const d = String(now.getDate()).padStart(2, '0');
|
||
const hh = String(now.getHours()).padStart(2, '0');
|
||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||
const filename = `客户列表_${y}${m}${d}_${hh}${mm}${ss}.csv`;
|
||
|
||
downloadCsv(filename, [header, ...lines]);
|
||
}
|
||
|
||
if (refreshCustomersBtn) {
|
||
refreshCustomersBtn.addEventListener('click', () => {
|
||
if (currentSection === 'customer') {
|
||
loadCustomers();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (exportCustomersBtn) {
|
||
exportCustomersBtn.addEventListener('click', () => {
|
||
if (currentSection !== 'customer') return;
|
||
exportCustomersToCsv(filteredCustomers);
|
||
});
|
||
}
|
||
|
||
// Apply customer filter and render table with pagination
|
||
function applyCustomerFilter() {
|
||
totalItems = filteredCustomers.length;
|
||
totalPages = Math.ceil(totalItems / pageSize);
|
||
|
||
const startIndex = (currentPage - 1) * pageSize;
|
||
const endIndex = startIndex + pageSize;
|
||
const paginatedCustomers = filteredCustomers.slice(startIndex, endIndex);
|
||
|
||
renderCustomerTable(paginatedCustomers);
|
||
updatePaginationControls();
|
||
}
|
||
|
||
// Load all customers for dashboard
|
||
async function loadAllCustomers() {
|
||
console.log('loadAllCustomers called');
|
||
try {
|
||
const response = await authenticatedFetch('/api/customers?page=1&pageSize=1000');
|
||
const data = await response.json();
|
||
|
||
console.log('Dashboard data received:', data);
|
||
|
||
if (data.customers) {
|
||
dashboardCustomers = data.customers;
|
||
console.log('Dashboard customers set:', dashboardCustomers.length, 'customers');
|
||
updateDashboardStats(dashboardCustomers);
|
||
renderStatusChart(dashboardCustomers);
|
||
renderTypeChart(dashboardCustomers);
|
||
renderTrendChart(dashboardCustomers);
|
||
} else {
|
||
console.error('No customers in dashboard data');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading all customers:', error);
|
||
}
|
||
}
|
||
|
||
// Apply date filter for dashboard
|
||
function applyDateFilter(startDate, endDate) {
|
||
let filteredData = dashboardCustomers;
|
||
|
||
if (startDate) {
|
||
filteredData = filteredData.filter(c => {
|
||
if (!c.intendedProduct) return false;
|
||
const date = normalizeDateValue(c.intendedProduct);
|
||
return date && date >= startDate;
|
||
});
|
||
}
|
||
|
||
if (endDate) {
|
||
filteredData = filteredData.filter(c => {
|
||
if (!c.intendedProduct) return false;
|
||
const date = normalizeDateValue(c.intendedProduct);
|
||
return date && date <= endDate;
|
||
});
|
||
}
|
||
|
||
updateDashboardStats(filteredData);
|
||
renderStatusChart(filteredData);
|
||
renderTypeChart(filteredData);
|
||
renderTrendChart(filteredData);
|
||
}
|
||
|
||
// Render customer table
|
||
function renderCustomerTable(customers) {
|
||
customerTableBody.innerHTML = '';
|
||
|
||
customers.forEach(customer => {
|
||
const row = document.createElement('tr');
|
||
|
||
const date = customer.intendedProduct || '';
|
||
|
||
|
||
const fields = [
|
||
{ value: customer.customerName || '', name: 'customerName' },
|
||
{ value: date, name: 'date' },
|
||
{ value: customer.version || '', name: 'version' },
|
||
{ value: customer.description || '', name: 'description' },
|
||
{ value: customer.solution || '', name: 'solution' },
|
||
{ value: customer.type || '', name: 'type' },
|
||
{ value: customer.module || '', name: 'module' },
|
||
{ value: customer.statusProgress || '', name: 'statusProgress' }
|
||
];
|
||
|
||
fields.forEach(field => {
|
||
const td = document.createElement('td');
|
||
const textValue = String(field.value ?? '');
|
||
td.textContent = textValue;
|
||
td.setAttribute('data-tooltip', textValue);
|
||
|
||
if (field.name === 'description' || field.name === 'solution') {
|
||
td.classList.add('overflow-cell');
|
||
}
|
||
|
||
row.appendChild(td);
|
||
});
|
||
|
||
const actionTd = document.createElement('td');
|
||
actionTd.innerHTML = `
|
||
<button class="action-btn edit-btn" data-id="${customer.id}">
|
||
<i class="fas fa-edit"></i>
|
||
</button>
|
||
<button class="action-btn delete-btn" data-id="${customer.id}">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
`;
|
||
row.appendChild(actionTd);
|
||
|
||
customerTableBody.appendChild(row);
|
||
});
|
||
|
||
checkTextOverflow();
|
||
|
||
document.querySelectorAll('.edit-btn').forEach(btn => {
|
||
btn.addEventListener('click', function () {
|
||
const customerId = this.getAttribute('data-id');
|
||
openEditModal(customerId);
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.delete-btn').forEach(btn => {
|
||
btn.addEventListener('click', function () {
|
||
const customerId = this.getAttribute('data-id');
|
||
deleteCustomer(customerId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function checkTextOverflow() {
|
||
// Use requestAnimationFrame to ensure DOM is fully rendered before checking overflow
|
||
requestAnimationFrame(() => {
|
||
const cells = customerTableBody.querySelectorAll('td[data-tooltip]');
|
||
cells.forEach(cell => {
|
||
const cellText = (cell.getAttribute('data-tooltip') || '').trim();
|
||
const shouldAlwaysShowTooltip = cell.classList.contains('overflow-cell') && cellText.length > 0;
|
||
const hasOverflow = cell.scrollWidth > cell.clientWidth;
|
||
|
||
if (shouldAlwaysShowTooltip || hasOverflow) {
|
||
cell.classList.add('has-overflow');
|
||
} else {
|
||
cell.classList.remove('has-overflow');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// Update pagination controls
|
||
function updatePaginationControls() {
|
||
const startItem = totalItems === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||
const endItem = Math.min(currentPage * pageSize, totalItems);
|
||
|
||
document.getElementById('paginationInfo').textContent =
|
||
`显示 ${startItem}-${endItem} 共 ${totalItems} 条`;
|
||
|
||
document.getElementById('firstPage').disabled = currentPage === 1;
|
||
document.getElementById('prevPage').disabled = currentPage === 1;
|
||
document.getElementById('nextPage').disabled = currentPage === totalPages;
|
||
document.getElementById('lastPage').disabled = currentPage === totalPages;
|
||
|
||
const pageNumbers = document.getElementById('pageNumbers');
|
||
pageNumbers.innerHTML = '';
|
||
|
||
const maxVisiblePages = 5;
|
||
let startPage = Math.max(1, currentPage - Math.floor(maxVisiblePages / 2));
|
||
let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1);
|
||
|
||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
const pageBtn = document.createElement('button');
|
||
pageBtn.className = `page-number ${i === currentPage ? 'active' : ''}`;
|
||
pageBtn.textContent = i;
|
||
pageBtn.addEventListener('click', () => {
|
||
currentPage = i;
|
||
loadCustomers();
|
||
});
|
||
pageNumbers.appendChild(pageBtn);
|
||
}
|
||
}
|
||
|
||
// Create customer
|
||
createCustomerForm.addEventListener('submit', async function (e) {
|
||
e.preventDefault();
|
||
|
||
const formData = {
|
||
customerName: document.getElementById('createCustomerName').value,
|
||
intendedProduct: document.getElementById('createIntendedProduct').value,
|
||
version: document.getElementById('createVersion').value,
|
||
description: document.getElementById('createDescription').value,
|
||
solution: document.getElementById('createSolution').value,
|
||
type: document.getElementById('createType').value,
|
||
module: document.getElementById('createModule').value,
|
||
statusProgress: document.getElementById('createStatusProgress').value,
|
||
reporter: '' // Trial periods managed separately
|
||
};
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/customers', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(formData)
|
||
});
|
||
|
||
if (response.ok) {
|
||
createCustomerForm.reset();
|
||
createModal.style.display = 'none';
|
||
loadCustomers();
|
||
alert('客户创建成功!');
|
||
} else {
|
||
alert('创建客户时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating customer:', error);
|
||
alert('创建客户时出错');
|
||
}
|
||
});
|
||
|
||
// Import customers
|
||
importFileForm.addEventListener('submit', async function (e) {
|
||
e.preventDefault();
|
||
|
||
const formData = new FormData();
|
||
const fileInput = document.getElementById('importFile');
|
||
|
||
if (!fileInput.files.length) {
|
||
alert('请选择要导入的文件');
|
||
return;
|
||
}
|
||
|
||
formData.append('file', fileInput.files[0]);
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/customers/import', {
|
||
method: 'POST',
|
||
body: formData
|
||
});
|
||
|
||
if (response.ok) {
|
||
const result = await response.json();
|
||
const message = `客户导入成功!\n导入数量: ${result.importedCount}\n重复数量: ${result.duplicateCount}`;
|
||
alert(message);
|
||
importFileForm.reset();
|
||
fileName.textContent = '选择文件...';
|
||
importModal.style.display = 'none';
|
||
loadCustomers();
|
||
} else {
|
||
alert('导入客户时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error importing customers:', error);
|
||
alert('导入客户时出错');
|
||
}
|
||
});
|
||
|
||
// Open edit modal
|
||
async function openEditModal(customerId) {
|
||
try {
|
||
const response = await authenticatedFetch(`/api/customers/${customerId}`);
|
||
const customer = await response.json();
|
||
|
||
document.getElementById('editCustomerId').value = customer.id;
|
||
document.getElementById('editCustomerName').value = customer.customerName || '';
|
||
document.getElementById('editIntendedProduct').value = normalizeDateValue(customer.intendedProduct);
|
||
document.getElementById('editVersion').value = customer.version || '';
|
||
document.getElementById('editDescription').value = customer.description || '';
|
||
document.getElementById('editSolution').value = customer.solution || '';
|
||
document.getElementById('editType').value = customer.type || '';
|
||
document.getElementById('editModule').value = customer.module || '';
|
||
document.getElementById('editStatusProgress').value = customer.statusProgress || '';
|
||
|
||
// Parse trial period and fill datetime inputs
|
||
const reporter = customer.reporter || '';
|
||
if (reporter) {
|
||
// Split by ~ or ~
|
||
const parts = reporter.split(/[~~]/);
|
||
if (parts.length === 2) {
|
||
const parseDateTime = (str) => {
|
||
// Parse format like "2026/1/5 10:00" or "2026-1-5 10:00"
|
||
const cleaned = str.trim();
|
||
const match = cleaned.match(/(\d{4})[/-](\d{1,2})[/-](\d{1,2})\s+(\d{1,2}):(\d{1,2})/);
|
||
if (match) {
|
||
const [_, year, month, day, hours, minutes] = match;
|
||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||
}
|
||
return '';
|
||
};
|
||
|
||
document.getElementById('editTrialStart').value = parseDateTime(parts[0]);
|
||
document.getElementById('editTrialEnd').value = parseDateTime(parts[1]);
|
||
}
|
||
}
|
||
|
||
// Load trial periods for this customer
|
||
if (typeof loadTrialPeriods === 'function') {
|
||
await loadTrialPeriods(customer.id);
|
||
}
|
||
|
||
editModal.style.display = 'block';
|
||
} catch (error) {
|
||
console.error('Error loading customer for edit:', error);
|
||
}
|
||
}
|
||
|
||
window.addEventListener('click', function (e) {
|
||
if (e.target === createModal) {
|
||
createModal.style.display = 'none';
|
||
}
|
||
if (e.target === importModal) {
|
||
importModal.style.display = 'none';
|
||
}
|
||
if (e.target === editModal) {
|
||
editModal.style.display = 'none';
|
||
}
|
||
});
|
||
|
||
// Update customer
|
||
editCustomerForm.addEventListener('submit', async function (e) {
|
||
e.preventDefault();
|
||
|
||
const customerId = document.getElementById('editCustomerId').value;
|
||
|
||
const formData = {
|
||
customerName: document.getElementById('editCustomerName').value,
|
||
intendedProduct: document.getElementById('editIntendedProduct').value,
|
||
version: document.getElementById('editVersion').value,
|
||
description: document.getElementById('editDescription').value,
|
||
solution: document.getElementById('editSolution').value,
|
||
type: document.getElementById('editType').value,
|
||
module: document.getElementById('editModule').value,
|
||
statusProgress: document.getElementById('editStatusProgress').value,
|
||
reporter: '' // Trial periods managed separately
|
||
};
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/customers/${customerId}`, {
|
||
method: 'PUT',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(formData)
|
||
});
|
||
|
||
if (response.ok) {
|
||
editModal.style.display = 'none';
|
||
loadCustomers();
|
||
alert('客户更新成功!');
|
||
} else {
|
||
alert('更新客户时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error updating customer:', error);
|
||
alert('更新客户时出错');
|
||
}
|
||
});
|
||
|
||
// Delete customer
|
||
async function deleteCustomer(customerId) {
|
||
if (!confirm('确定要删除这个客户吗?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/customers/${customerId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (response.ok) {
|
||
loadCustomers();
|
||
alert('客户删除成功!');
|
||
} else {
|
||
alert('删除客户时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting customer:', error);
|
||
alert('删除客户时出错');
|
||
}
|
||
}
|
||
|
||
// Dashboard functionality
|
||
async function loadDashboardData() {
|
||
console.log('loadDashboardData called');
|
||
await loadAllCustomers();
|
||
await loadFollowUpCount();
|
||
}
|
||
|
||
// Update dashboard statistics
|
||
function updateDashboardStats(customers) {
|
||
const totalCustomers = new Set(customers.map(c => c.customerName).filter(c => c)).size;
|
||
|
||
const now = new Date();
|
||
const currentMonth = String(now.getMonth() + 1).padStart(2, '0');
|
||
const currentYear = now.getFullYear();
|
||
const newCustomers = new Set(
|
||
customers.filter(customer => {
|
||
if (!customer.customerName) return false;
|
||
const normalized = normalizeDateValue(customer.intendedProduct);
|
||
if (!normalized) return false;
|
||
const year = parseInt(normalized.slice(0, 4), 10);
|
||
const month = normalized.slice(5, 7);
|
||
return year === currentYear && month === currentMonth;
|
||
}).map(c => c.customerName).filter(c => c)
|
||
).size;
|
||
|
||
// 客户总数:统计去重的客户名称数量(与totalCustomers一致)
|
||
const totalCustomersCount = new Set(customers.map(c => c.customerName).filter(c => c)).size;
|
||
|
||
const completed = new Set(
|
||
customers.filter(c =>
|
||
c.statusProgress && (c.statusProgress.includes('已修复') || c.statusProgress.includes('完成') || c.statusProgress.toLowerCase().includes('complete'))
|
||
).map(c => c.customerName).filter(c => c)
|
||
).size;
|
||
|
||
document.getElementById('totalCustomers').textContent = totalCustomers;
|
||
document.getElementById('newCustomers').textContent = newCustomers;
|
||
document.getElementById('totalProducts').textContent = totalCustomersCount;
|
||
document.getElementById('completedTasks').textContent = completed;
|
||
}
|
||
|
||
// Load follow-up count for dashboard
|
||
async function loadFollowUpCount() {
|
||
try {
|
||
const response = await authenticatedFetch('/api/followups?page=1&pageSize=1000');
|
||
const data = await response.json();
|
||
|
||
if (data.followUps) {
|
||
const followUpCount = data.followUps.length;
|
||
document.getElementById('followUpCount').textContent = followUpCount;
|
||
} else {
|
||
document.getElementById('followUpCount').textContent = '0';
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading follow-up count:', error);
|
||
document.getElementById('followUpCount').textContent = '0';
|
||
}
|
||
}
|
||
|
||
function renderStatusChart(customers) {
|
||
const ctx = document.getElementById('statusChart').getContext('2d');
|
||
const selectedField = document.getElementById('chartFieldSelect').value;
|
||
const chartTitle = document.getElementById('statusChartTitle').value || '数据分布';
|
||
|
||
const fieldCount = {};
|
||
customers.forEach(customer => {
|
||
const value = customer[selectedField] || '未设置';
|
||
fieldCount[value] = (fieldCount[value] || 0) + 1;
|
||
});
|
||
|
||
const labels = Object.keys(fieldCount);
|
||
const data = Object.values(fieldCount);
|
||
|
||
if (statusChartInstance) {
|
||
statusChartInstance.destroy();
|
||
}
|
||
|
||
statusChartInstance = new Chart(ctx, {
|
||
type: 'pie',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
data: data,
|
||
backgroundColor: [
|
||
'#FF6B35',
|
||
'#F28C28',
|
||
'#333',
|
||
'#4CAF50',
|
||
'#2196F3',
|
||
'#FFC107',
|
||
'#9C27B0',
|
||
'#00BCD4',
|
||
'#8BC34A',
|
||
'#FF5722'
|
||
]
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: {
|
||
position: 'left',
|
||
labels: {
|
||
generateLabels: function (chart) {
|
||
const data = chart.data;
|
||
if (data.labels.length && data.datasets.length) {
|
||
return data.labels.map((label, i) => {
|
||
const value = data.datasets[0].data[i];
|
||
return {
|
||
text: `${label}: ${value}`,
|
||
fillStyle: data.datasets[0].backgroundColor[i],
|
||
hidden: false,
|
||
index: i
|
||
};
|
||
});
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: chartTitle,
|
||
font: {
|
||
size: 16,
|
||
weight: 'bold'
|
||
}
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context) {
|
||
const label = context.label || '';
|
||
const value = context.parsed;
|
||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||
const percentage = ((value / total) * 100).toFixed(1);
|
||
return `${label}: ${value} (${percentage}%)`;
|
||
}
|
||
}
|
||
},
|
||
datalabels: {
|
||
formatter: (value, ctx) => {
|
||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||
const percentage = ((value / total) * 100).toFixed(1);
|
||
// 显示具体数值和百分比
|
||
return `${value}\n(${percentage}%)`;
|
||
},
|
||
color: '#fff',
|
||
font: {
|
||
weight: 'bold',
|
||
size: 12
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// Render customer type chart
|
||
function renderTypeChart(customers) {
|
||
console.log('renderTypeChart called with customers:', customers);
|
||
const canvas = document.getElementById('typeChart');
|
||
console.log('typeChart canvas element:', canvas);
|
||
|
||
if (!canvas) {
|
||
console.error('typeChart canvas not found');
|
||
return;
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const chartTitle = document.getElementById('typeChartTitle').value || '客户类型';
|
||
|
||
if (typeChartInstance) {
|
||
typeChartInstance.destroy();
|
||
}
|
||
|
||
const selectedField = document.getElementById('typeChartFieldSelect').value;
|
||
const typeCount = {};
|
||
customers.forEach(customer => {
|
||
const type = customer[selectedField] || '未设置';
|
||
typeCount[type] = (typeCount[type] || 0) + 1;
|
||
});
|
||
|
||
const labels = Object.keys(typeCount);
|
||
const data = Object.values(typeCount);
|
||
|
||
console.log('Type chart labels:', labels);
|
||
console.log('Type chart data:', data);
|
||
|
||
typeChartInstance = new Chart(ctx, {
|
||
type: 'doughnut',
|
||
data: {
|
||
labels: labels,
|
||
datasets: [{
|
||
data: data,
|
||
backgroundColor: [
|
||
'#333',
|
||
'#FF6B35',
|
||
'#F28C28',
|
||
'#4CAF50',
|
||
'#2196F3',
|
||
'#FFC107'
|
||
]
|
||
}]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
plugins: {
|
||
legend: {
|
||
position: 'left',
|
||
labels: {
|
||
generateLabels: function (chart) {
|
||
const data = chart.data;
|
||
if (data.labels.length && data.datasets.length) {
|
||
return data.labels.map((label, i) => {
|
||
const value = data.datasets[0].data[i];
|
||
return {
|
||
text: `${label}: ${value}`,
|
||
fillStyle: data.datasets[0].backgroundColor[i],
|
||
hidden: false,
|
||
index: i
|
||
};
|
||
});
|
||
}
|
||
return [];
|
||
}
|
||
}
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: chartTitle,
|
||
font: {
|
||
size: 16,
|
||
weight: 'bold'
|
||
}
|
||
},
|
||
tooltip: {
|
||
callbacks: {
|
||
label: function (context) {
|
||
const label = context.label || '';
|
||
const value = context.parsed;
|
||
const total = context.dataset.data.reduce((a, b) => a + b, 0);
|
||
const percentage = ((value / total) * 100).toFixed(1);
|
||
return `${label}: ${value} (${percentage}%)`;
|
||
}
|
||
}
|
||
},
|
||
datalabels: {
|
||
formatter: (value, ctx) => {
|
||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||
const percentage = ((value / total) * 100).toFixed(1);
|
||
// 显示具体数值和百分比
|
||
return `${value}\n(${percentage}%)`;
|
||
},
|
||
color: '#fff',
|
||
font: {
|
||
weight: 'bold',
|
||
size: 12
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('Type chart created successfully');
|
||
}
|
||
|
||
// Render trend line chart
|
||
function renderTrendChart(customers) {
|
||
const canvas = document.getElementById('trendChart');
|
||
if (!canvas) {
|
||
console.error('trendChart canvas not found');
|
||
return;
|
||
}
|
||
|
||
const ctx = canvas.getContext('2d');
|
||
const trendType = document.getElementById('trendTypeSelect')?.value || 'customer';
|
||
|
||
if (trendChartInstance) {
|
||
trendChartInstance.destroy();
|
||
}
|
||
|
||
// Group data by date
|
||
const dateMap = {};
|
||
|
||
customers.forEach(customer => {
|
||
const dateStr = normalizeDateValue(customer.intendedProduct);
|
||
if (!dateStr) return;
|
||
|
||
if (!dateMap[dateStr]) {
|
||
dateMap[dateStr] = {
|
||
customers: new Set(),
|
||
demands: 0,
|
||
issues: 0
|
||
};
|
||
}
|
||
|
||
// Count customers
|
||
if (customer.customerName) {
|
||
dateMap[dateStr].customers.add(customer.customerName);
|
||
}
|
||
|
||
// Count demands and issues based on type
|
||
const type = (customer.type || '').toLowerCase();
|
||
if (type.includes('需求')) {
|
||
dateMap[dateStr].demands++;
|
||
}
|
||
if (type.includes('问题') || type.includes('功能问题')) {
|
||
dateMap[dateStr].issues++;
|
||
}
|
||
});
|
||
|
||
// Sort dates
|
||
const sortedDates = Object.keys(dateMap).sort();
|
||
|
||
// Prepare datasets based on selected trend type
|
||
const datasets = [];
|
||
|
||
if (trendType === 'customer') {
|
||
datasets.push({
|
||
label: '客户数',
|
||
data: sortedDates.map(date => dateMap[date].customers.size),
|
||
borderColor: '#FF6B35',
|
||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#FF6B35',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: true
|
||
});
|
||
} else if (trendType === 'demand') {
|
||
datasets.push({
|
||
label: '需求数',
|
||
data: sortedDates.map(date => dateMap[date].demands),
|
||
borderColor: '#4CAF50',
|
||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#4CAF50',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: true
|
||
});
|
||
} else if (trendType === 'issue') {
|
||
datasets.push({
|
||
label: '问题数',
|
||
data: sortedDates.map(date => dateMap[date].issues),
|
||
borderColor: '#F28C28',
|
||
backgroundColor: 'rgba(242, 140, 40, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#F28C28',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: true
|
||
});
|
||
} else if (trendType === 'all') {
|
||
datasets.push(
|
||
{
|
||
label: '客户数',
|
||
data: sortedDates.map(date => dateMap[date].customers.size),
|
||
borderColor: '#FF6B35',
|
||
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#FF6B35',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: false
|
||
},
|
||
{
|
||
label: '需求数',
|
||
data: sortedDates.map(date => dateMap[date].demands),
|
||
borderColor: '#4CAF50',
|
||
backgroundColor: 'rgba(76, 175, 80, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#4CAF50',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: false
|
||
},
|
||
{
|
||
label: '问题数',
|
||
data: sortedDates.map(date => dateMap[date].issues),
|
||
borderColor: '#F28C28',
|
||
backgroundColor: 'rgba(242, 140, 40, 0.1)',
|
||
borderWidth: 3,
|
||
pointRadius: 5,
|
||
pointHoverRadius: 7,
|
||
pointBackgroundColor: '#F28C28',
|
||
pointBorderColor: '#fff',
|
||
pointBorderWidth: 2,
|
||
tension: 0.4,
|
||
fill: false
|
||
}
|
||
);
|
||
}
|
||
|
||
trendChartInstance = new Chart(ctx, {
|
||
type: 'line',
|
||
data: {
|
||
labels: sortedDates,
|
||
datasets: datasets
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: true,
|
||
plugins: {
|
||
legend: {
|
||
position: 'top'
|
||
},
|
||
title: {
|
||
display: true,
|
||
text: '时间趋势分析',
|
||
font: {
|
||
size: 16,
|
||
weight: 'bold'
|
||
}
|
||
},
|
||
tooltip: {
|
||
mode: 'index',
|
||
intersect: false
|
||
},
|
||
datalabels: {
|
||
display: true,
|
||
align: 'top',
|
||
anchor: 'end',
|
||
formatter: (value) => value,
|
||
color: '#333',
|
||
font: {
|
||
weight: 'bold',
|
||
size: 11
|
||
},
|
||
backgroundColor: 'rgba(255, 255, 255, 0.8)',
|
||
borderRadius: 4,
|
||
padding: 4
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
display: true,
|
||
title: {
|
||
display: true,
|
||
text: '日期'
|
||
},
|
||
ticks: {
|
||
maxRotation: 45,
|
||
minRotation: 45
|
||
}
|
||
},
|
||
y: {
|
||
display: true,
|
||
title: {
|
||
display: true,
|
||
text: '数量'
|
||
},
|
||
beginAtZero: true,
|
||
ticks: {
|
||
stepSize: 1
|
||
}
|
||
}
|
||
},
|
||
interaction: {
|
||
mode: 'nearest',
|
||
axis: 'x',
|
||
intersect: false
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('Trend chart created successfully');
|
||
}
|
||
|
||
// Initialize the app
|
||
loadCustomers().then(() => {
|
||
loadAllCustomers();
|
||
});
|
||
|
||
// Pagination event listeners
|
||
document.getElementById('firstPage').addEventListener('click', () => {
|
||
if (currentPage > 1) {
|
||
currentPage = 1;
|
||
applyCustomerFilter();
|
||
}
|
||
});
|
||
|
||
document.getElementById('prevPage').addEventListener('click', () => {
|
||
if (currentPage > 1) {
|
||
currentPage--;
|
||
applyCustomerFilter();
|
||
}
|
||
});
|
||
|
||
document.getElementById('nextPage').addEventListener('click', () => {
|
||
if (currentPage < totalPages) {
|
||
currentPage++;
|
||
applyCustomerFilter();
|
||
}
|
||
});
|
||
|
||
document.getElementById('lastPage').addEventListener('click', () => {
|
||
if (currentPage < totalPages) {
|
||
currentPage = totalPages;
|
||
applyCustomerFilter();
|
||
}
|
||
});
|
||
|
||
document.getElementById('pageSizeSelect').addEventListener('change', (e) => {
|
||
pageSize = parseInt(e.target.value);
|
||
currentPage = 1;
|
||
loadCustomers();
|
||
});
|
||
|
||
// Chart field select event listener
|
||
document.getElementById('chartFieldSelect').addEventListener('change', function () {
|
||
const startDate = document.getElementById('startDate').value;
|
||
const endDate = document.getElementById('endDate').value;
|
||
applyDateFilter(startDate, endDate);
|
||
});
|
||
|
||
// Type chart field select event listener
|
||
document.getElementById('typeChartFieldSelect').addEventListener('change', function () {
|
||
const startDate = document.getElementById('startDate').value;
|
||
const endDate = document.getElementById('endDate').value;
|
||
applyDateFilter(startDate, endDate);
|
||
});
|
||
|
||
// Trend type select event listener
|
||
const trendTypeSelect = document.getElementById('trendTypeSelect');
|
||
if (trendTypeSelect) {
|
||
trendTypeSelect.addEventListener('change', function () {
|
||
const startDate = document.getElementById('startDate').value;
|
||
const endDate = document.getElementById('endDate').value;
|
||
applyDateFilter(startDate, endDate);
|
||
});
|
||
}
|
||
|
||
// 登出功能
|
||
const logoutBtn = document.createElement('button');
|
||
logoutBtn.className = 'icon-btn';
|
||
logoutBtn.innerHTML = '<i class="fas fa-sign-out-alt"></i>';
|
||
logoutBtn.title = '登出';
|
||
logoutBtn.addEventListener('click', () => {
|
||
if (confirm('确定要退出登录吗?')) {
|
||
localStorage.removeItem('crmToken');
|
||
window.location.href = '/static/login.html';
|
||
}
|
||
});
|
||
|
||
const headerRight = document.querySelector('.header-right');
|
||
if (headerRight) {
|
||
headerRight.appendChild(logoutBtn);
|
||
}
|
||
|
||
// ========== Follow-up Management ==========
|
||
const followupSection = document.getElementById('followupSection');
|
||
const addFollowUpBtn = document.getElementById('addFollowUpBtn');
|
||
const followupFormCard = document.getElementById('followupFormCard');
|
||
const followupForm = document.getElementById('followupForm');
|
||
const cancelFollowupBtn = document.getElementById('cancelFollowupBtn');
|
||
const followupTableBody = document.getElementById('followupTableBody');
|
||
const refreshFollowupsBtn = document.getElementById('refreshFollowupsBtn');
|
||
const followupCustomerNameSelect = document.getElementById('followupCustomerName');
|
||
|
||
let allFollowUps = [];
|
||
let followupCurrentPage = 1;
|
||
let followupPageSize = 10;
|
||
let followupTotalPages = 1;
|
||
let followupTotalItems = 0;
|
||
|
||
// Show/hide follow-up form
|
||
if (addFollowUpBtn) {
|
||
addFollowUpBtn.addEventListener('click', async function () {
|
||
followupFormCard.style.display = 'block';
|
||
await loadCustomerListForFollowup();
|
||
});
|
||
}
|
||
|
||
if (cancelFollowupBtn) {
|
||
cancelFollowupBtn.addEventListener('click', function () {
|
||
followupFormCard.style.display = 'none';
|
||
followupForm.reset();
|
||
});
|
||
}
|
||
|
||
// Load customer list for follow-up dropdown
|
||
async function loadCustomerListForFollowup() {
|
||
try {
|
||
const response = await authenticatedFetch('/api/customers/list');
|
||
const data = await response.json();
|
||
|
||
followupCustomerNameSelect.innerHTML = '<option value="">请选择客户</option>';
|
||
if (data.customers && data.customers.length > 0) {
|
||
data.customers.forEach(customer => {
|
||
const option = document.createElement('option');
|
||
option.value = customer.id;
|
||
option.textContent = customer.customerName;
|
||
followupCustomerNameSelect.appendChild(option);
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading customer list:', error);
|
||
}
|
||
}
|
||
|
||
// Create follow-up
|
||
if (followupForm) {
|
||
followupForm.addEventListener('submit', async function (e) {
|
||
e.preventDefault();
|
||
|
||
const followUpTimeValue = document.getElementById('followupTime').value;
|
||
const followUpTimeISO = new Date(followUpTimeValue).toISOString();
|
||
|
||
// Get customer name from the selected option's text
|
||
const customerSelect = document.getElementById('followupCustomerName');
|
||
const selectedOption = customerSelect.options[customerSelect.selectedIndex];
|
||
const customerName = selectedOption ? selectedOption.textContent : '';
|
||
|
||
const formData = {
|
||
customerName: customerName,
|
||
dealStatus: document.getElementById('followupDealStatus').value,
|
||
customerLevel: document.getElementById('followupCustomerLevel').value,
|
||
industry: document.getElementById('followupIndustry').value,
|
||
followUpTime: followUpTimeISO
|
||
};
|
||
|
||
try {
|
||
const response = await authenticatedFetch('/api/followups', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json'
|
||
},
|
||
body: JSON.stringify(formData)
|
||
});
|
||
|
||
if (response.ok) {
|
||
followupForm.reset();
|
||
followupFormCard.style.display = 'none';
|
||
loadFollowUps();
|
||
alert('跟进记录创建成功!');
|
||
} else {
|
||
alert('创建跟进记录时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error creating follow-up:', error);
|
||
alert('创建跟进记录时出错');
|
||
}
|
||
});
|
||
}
|
||
|
||
// Load follow-ups
|
||
async function loadFollowUps() {
|
||
try {
|
||
const response = await authenticatedFetch(`/api/followups?page=${followupCurrentPage}&pageSize=${followupPageSize}`);
|
||
const data = await response.json();
|
||
|
||
if (data.followUps) {
|
||
allFollowUps = data.followUps;
|
||
followupTotalItems = data.total || 0;
|
||
followupTotalPages = data.totalPages || 1;
|
||
renderFollowUpTable(allFollowUps);
|
||
updateFollowupPaginationControls();
|
||
}
|
||
} catch (error) {
|
||
console.error('Error loading follow-ups:', error);
|
||
}
|
||
}
|
||
|
||
// Render follow-up table
|
||
function renderFollowUpTable(followUps) {
|
||
followupTableBody.innerHTML = '';
|
||
|
||
followUps.forEach(followUp => {
|
||
const row = document.createElement('tr');
|
||
|
||
// Format follow-up time
|
||
const followUpTime = new Date(followUp.followUpTime);
|
||
const formattedTime = followUpTime.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
|
||
// Notification status
|
||
const notificationStatus = followUp.notificationSent ?
|
||
'<span style="color: green;">已通知</span>' :
|
||
'<span style="color: orange;">待通知</span>';
|
||
|
||
// Customer level display
|
||
let levelDisplay = followUp.customerLevel;
|
||
if (followUp.customerLevel === 'A') {
|
||
levelDisplay = 'A级 (重点客户)';
|
||
} else if (followUp.customerLevel === 'B') {
|
||
levelDisplay = 'B级 (潜在客户)';
|
||
} else if (followUp.customerLevel === 'C') {
|
||
levelDisplay = 'C级 (一般客户)';
|
||
}
|
||
|
||
row.innerHTML = `
|
||
<td>${followUp.customerName || ''}</td>
|
||
<td>${followUp.dealStatus || ''}</td>
|
||
<td>${levelDisplay}</td>
|
||
<td>${followUp.industry || ''}</td>
|
||
<td>${formattedTime}</td>
|
||
<td>${notificationStatus}</td>
|
||
<td>
|
||
<button class="action-btn delete-followup-btn" data-id="${followUp.id}">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</td>
|
||
`;
|
||
|
||
followupTableBody.appendChild(row);
|
||
});
|
||
|
||
// Add delete event listeners
|
||
document.querySelectorAll('.delete-followup-btn').forEach(btn => {
|
||
btn.addEventListener('click', function () {
|
||
const followUpId = this.getAttribute('data-id');
|
||
deleteFollowUp(followUpId);
|
||
});
|
||
});
|
||
}
|
||
|
||
// Delete follow-up
|
||
async function deleteFollowUp(followUpId) {
|
||
if (!confirm('确定要删除这条跟进记录吗?')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await authenticatedFetch(`/api/followups/${followUpId}`, {
|
||
method: 'DELETE'
|
||
});
|
||
|
||
if (response.ok) {
|
||
loadFollowUps();
|
||
alert('跟进记录删除成功!');
|
||
} else {
|
||
alert('删除跟进记录时出错');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting follow-up:', error);
|
||
alert('删除跟进记录时出错');
|
||
}
|
||
}
|
||
|
||
// Update follow-up pagination controls
|
||
function updateFollowupPaginationControls() {
|
||
const startItem = followupTotalItems === 0 ? 0 : (followupCurrentPage - 1) * followupPageSize + 1;
|
||
const endItem = Math.min(followupCurrentPage * followupPageSize, followupTotalItems);
|
||
|
||
document.getElementById('followupPaginationInfo').textContent =
|
||
`显示 ${startItem}-${endItem} 共 ${followupTotalItems} 条`;
|
||
|
||
document.getElementById('followupFirstPage').disabled = followupCurrentPage === 1;
|
||
document.getElementById('followupPrevPage').disabled = followupCurrentPage === 1;
|
||
document.getElementById('followupNextPage').disabled = followupCurrentPage === followupTotalPages;
|
||
document.getElementById('followupLastPage').disabled = followupCurrentPage === followupTotalPages;
|
||
|
||
const pageNumbers = document.getElementById('followupPageNumbers');
|
||
pageNumbers.innerHTML = '';
|
||
|
||
const maxVisiblePages = 5;
|
||
let startPage = Math.max(1, followupCurrentPage - Math.floor(maxVisiblePages / 2));
|
||
let endPage = Math.min(followupTotalPages, startPage + maxVisiblePages - 1);
|
||
|
||
if (endPage - startPage + 1 < maxVisiblePages) {
|
||
startPage = Math.max(1, endPage - maxVisiblePages + 1);
|
||
}
|
||
|
||
for (let i = startPage; i <= endPage; i++) {
|
||
const pageBtn = document.createElement('button');
|
||
pageBtn.className = `page-number ${i === followupCurrentPage ? 'active' : ''}`;
|
||
pageBtn.textContent = i;
|
||
pageBtn.addEventListener('click', () => {
|
||
followupCurrentPage = i;
|
||
loadFollowUps();
|
||
});
|
||
pageNumbers.appendChild(pageBtn);
|
||
}
|
||
}
|
||
|
||
// Follow-up pagination event listeners
|
||
if (document.getElementById('followupFirstPage')) {
|
||
document.getElementById('followupFirstPage').addEventListener('click', () => {
|
||
if (followupCurrentPage > 1) {
|
||
followupCurrentPage = 1;
|
||
loadFollowUps();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('followupPrevPage')) {
|
||
document.getElementById('followupPrevPage').addEventListener('click', () => {
|
||
if (followupCurrentPage > 1) {
|
||
followupCurrentPage--;
|
||
loadFollowUps();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('followupNextPage')) {
|
||
document.getElementById('followupNextPage').addEventListener('click', () => {
|
||
if (followupCurrentPage < followupTotalPages) {
|
||
followupCurrentPage++;
|
||
loadFollowUps();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('followupLastPage')) {
|
||
document.getElementById('followupLastPage').addEventListener('click', () => {
|
||
if (followupCurrentPage < followupTotalPages) {
|
||
followupCurrentPage = followupTotalPages;
|
||
loadFollowUps();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (document.getElementById('followupPageSizeSelect')) {
|
||
document.getElementById('followupPageSizeSelect').addEventListener('change', (e) => {
|
||
followupPageSize = parseInt(e.target.value);
|
||
followupCurrentPage = 1;
|
||
loadFollowUps();
|
||
});
|
||
}
|
||
|
||
// Refresh follow-ups button
|
||
if (refreshFollowupsBtn) {
|
||
refreshFollowupsBtn.addEventListener('click', () => {
|
||
loadFollowUps();
|
||
});
|
||
}
|
||
});
|