crm/frontend/js/main.js
2026-01-13 18:02:43 +08:00

1833 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 封装带 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';
console.log('loadCustomersForDropdown exists?', typeof loadCustomersForDropdown);
if (typeof loadCustomersForDropdown === 'function') {
console.log('Calling loadCustomersForDropdown');
loadCustomersForDropdown();
} else {
console.error('loadCustomersForDropdown is not a function!');
}
console.log('loadAllTrialPeriods exists?', typeof loadAllTrialPeriods);
if (typeof loadAllTrialPeriods === 'function') {
console.log('Calling loadAllTrialPeriods');
loadAllTrialPeriods();
} else {
console.error('loadAllTrialPeriods is not a function!');
}
} 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;
const products = new Set(customers.map(c => c.version)).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 = products;
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: 'bottom'
},
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 percentage + '%';
},
color: '#fff',
font: {
weight: 'bold',
size: 14
}
}
}
}
});
}
// 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: 'bottom'
},
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 percentage + '%';
},
color: '#fff',
font: {
weight: 'bold',
size: 14
}
}
}
}
});
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();
});
}
});