const records = [];
async function fetchWithAuth(url, options = {}) {
const token = localStorage.getItem('authToken');
const headers = { ...options.headers };
if (token) headers['Authorization'] = `Bearer ${token}`;
if (options.body && typeof options.body === 'string' && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
const res = await fetch(url, { ...options, headers });
if (res.status === 401) {
location.href = './login.html';
}
return res;
}
const payRecords = [];
let payEditingId = null;
const partners = [];
const contactsData = {
customers: [
{ name:'客户A', contact:'张三', phone:'13800000001', city:'上海', remark:'重要客户', owner:'客户', created:'2026/01/01 10:00:00' },
{ name:'客户B', contact:'李四', phone:'13800000002', city:'杭州', remark:'', owner:'客户', created:'2026/01/02 11:20:00' },
{ name:'客户C', contact:'王五', phone:'13800000003', city:'苏州', remark:'', owner:'客户', created:'2026/01/03 09:10:00' }
],
merchants: [
{ name:'商家A', contact:'刘一', phone:'13900000001', city:'上海', remark:'', owner:'商家', created:'2026/01/01 10:00:00' },
{ name:'商家B', contact:'陈二', phone:'13900000002', city:'杭州', remark:'', owner:'商家', created:'2026/01/02 11:20:00' },
{ name:'商家C', contact:'周三', phone:'13900000003', city:'苏州', remark:'', owner:'商家', created:'2026/01/03 09:10:00' }
],
others: [
{ name:'单位A', contact:'赵一', phone:'13700000001', city:'上海', remark:'', owner:'其它', created:'2026/01/01 10:00:00' },
{ name:'单位B', contact:'钱二', phone:'13700000002', city:'杭州', remark:'', owner:'其它', created:'2026/01/02 11:20:00' },
{ name:'单位C', contact:'孙三', phone:'13700000003', city:'苏州', remark:'', owner:'其它', created:'2026/01/03 09:10:00' }
]
};
const entryType = document.getElementById('entry-type');
const entryCategory = document.getElementById('entry-category');
const entryClient = document.getElementById('entry-client');
const entryAmount = document.getElementById('entry-amount');
const entryMethod = document.getElementById('entry-method');
const entryFile = document.getElementById('entry-file');
const entryNotes = document.getElementById('entry-notes');
const entryForm = document.getElementById('entry-form');
const entrySubmitBtn = entryForm?.querySelector('button[type="submit"]');
const rows = document.getElementById('rows');
const homeChartRows = document.getElementById('home-chart-rows');
const homePeriodSel = document.getElementById('home-period');
const filterType = document.getElementById('filter-type');
const filterKey = document.getElementById('filter-key');
const filterStart = document.getElementById('filter-start');
const filterEnd = document.getElementById('filter-end');
const ledgerPager = document.getElementById('global-pager-controls') || document.getElementById('ledger-pager');
const ledgerTableWrap = document.getElementById('ledger-table-wrap');
const ldType = document.getElementById('ld-type');
const ldTypeDD = document.getElementById('ld-type-dd');
const ldTypeList = document.getElementById('ld-type-list');
const ldTypeLabel = document.getElementById('ld-type-label');
const ldCat = document.getElementById('ld-cat');
const ldCatDD = document.getElementById('ld-cat-dd');
const ldCatList = document.getElementById('ld-cat-list');
const ldCatLabel = document.getElementById('ld-cat-label');
const ldOwner = document.getElementById('ld-owner');
const ldOwnerDD = document.getElementById('ld-owner-dd');
const ldOwnerList = document.getElementById('ld-owner-list');
const ldOwnerLabel = document.getElementById('ld-owner-label');
let ledgerPage = 1;
const ledgerPageSize = 100;
function updateLedgerHeaderCover() {}
let ledgerHdrType = 'all';
let ledgerHdrCat = '';
let ledgerHdrOwner = '';
function clientOwner(name) {
const all = [...contactsData.customers, ...contactsData.merchants, ...contactsData.others];
const obj = all.find(x => (x.name||'') === (name||''));
return obj ? (obj.owner || '') : '';
}
function openLedgerTypeFilter() {
ldTypeDD.style.display = 'block';
ldTypeList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => {
ledgerHdrType = val;
ldTypeDD.style.display='none';
setLabel(ldTypeLabel, '类型', val!=='all');
ledgerPage = 1;
applyFilters();
ldCatLabel && setLabel(ldCatLabel, '子类目', !!ledgerHdrCat);
});
ldTypeList.appendChild(row);
};
addItem('全部', 'all');
addItem('收入', '收入');
addItem('开支', '开支');
}
function openLedgerCatFilter() {
ldCatDD.style.display = 'block';
ldCatList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => {
ledgerHdrCat = val;
ldCatDD.style.display='none';
setLabel(ldCatLabel, '子类目', !!val);
ledgerPage = 1;
applyFilters();
});
ldCatList.appendChild(row);
};
addItem('全部', '');
const types = ledgerHdrType==='all' ? categoriesData.map(c=>c.name) : [ledgerHdrType];
types.forEach(t => {
const children = (categoriesData.find(c=>c.name===t)?.children) || [];
children.forEach(n => addItem(n, n));
});
}
function openLedgerOwnerFilter() {
ldOwnerDD.style.display = 'block';
ldOwnerList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => {
ledgerHdrOwner = val;
ldOwnerDD.style.display='none';
setLabel(ldOwnerLabel, '往来单位', !!val);
ledgerPage = 1;
applyFilters();
});
ldOwnerList.appendChild(row);
};
addItem('全部', '');
addItem('客户', '客户');
addItem('商家', '商家');
addItem('其它往来单位', '其它');
}
ldType?.addEventListener('click', (e) => { e.stopPropagation(); openLedgerTypeFilter(); });
ldCat?.addEventListener('click', (e) => { e.stopPropagation(); openLedgerCatFilter(); });
ldOwner?.addEventListener('click', (e) => { e.stopPropagation(); openLedgerOwnerFilter(); });
const accRows = document.getElementById('acc-rows');
const accAdd = document.getElementById('acc-add');
const accountsData = [
{ name:'现金账户', balance:0, desc:'系统预置账户', created:'2026/02/08 00:00:00', initialSet:false },
{ name:'银行账户 BBVA', balance:0, desc:'系统预置账户', created:'2026/02/08 00:00:00', initialSet:false },
{ name:'银行账户 Santander', balance:0, desc:'系统预置账户', created:'2026/02/08 00:00:00', initialSet:false },
{ name:'人民币账号1', balance:0, desc:'系统预置账户', created:'2026/02/08 00:00:00', initialSet:false },
{ name:'人民币账户 中智', balance:0, desc:'系统预置账户', created:'2026/02/08 00:00:00', initialSet:false }
];
function loadJSON(key, def) {
try { const v = JSON.parse(localStorage.getItem(key) || ''); return v ?? def; } catch { return def; }
}
function saveJSON(key, val) {
localStorage.setItem(key, JSON.stringify(val));
}
function initPersist() {
const recs = loadJSON('records', []);
if (Array.isArray(recs)) {
recs.forEach(r => { if (r && typeof r.fileUrl === 'string' && /^blob:/i.test(r.fileUrl)) delete r.fileUrl; });
recs.forEach(r => {
if (r && !r.createdAt) {
const ts = Date.parse(r.dateTime || r.date || '');
if (!isNaN(ts)) r.createdAt = ts;
}
});
records.splice(0, records.length, ...recs);
}
const pays = loadJSON('payRecords', []);
if (Array.isArray(pays)) {
pays.forEach(r => {
if (r && !r.createdAt) {
const h0 = (r.history && r.history[0] && (r.history[0].date || r.history[0].dateTime)) || null;
const ts = Date.parse(h0 || r.date || '');
if (!isNaN(ts)) r.createdAt = ts;
}
});
payRecords.splice(0, payRecords.length, ...pays);
}
const contactsSaved = loadJSON('contactsData', null);
if (contactsSaved && typeof contactsSaved === 'object') {
['customers','merchants','others'].forEach(k => { if (Array.isArray(contactsSaved[k])) contactsData[k] = contactsSaved[k]; });
}
const accs = loadJSON('accountsData', null);
if (Array.isArray(accs)) { accountsData.splice(0, accountsData.length, ...accs); }
const cats = loadJSON('categoriesData', null);
if (Array.isArray(cats)) { categoriesData.splice(0, categoriesData.length, ...cats); }
const roles = loadJSON('rolesData', null);
if (Array.isArray(roles)) { rolesData.splice(0, rolesData.length, ...roles); }
const ensureRole = (name, desc) => {
if (!rolesData.some(r => r.name === name)) {
const maxId = rolesData.reduce((m,r)=>Math.max(m, r.id||0), 0);
const now = new Date();
const created = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
rolesData.push({ id:maxId+1, name, desc, created, immutable:true });
}
};
ensureRole('财务','系统预置角色');
ensureRole('股东','系统预置角色');
ensureRole('后台管理人员','系统预置角色');
saveJSON('rolesData', rolesData);
const sales = loadJSON('salesData', null);
if (Array.isArray(sales)) { salesData.splice(0, salesData.length, ...sales); }
}
function refreshAccountOptions() {
entryMethod.innerHTML = '请选择 ';
accountsData.slice().reverse().forEach(a => {
const opt = document.createElement('option');
opt.value = a.name; opt.textContent = a.name;
entryMethod.appendChild(opt);
});
}
function renderAccounts() {
accRows.innerHTML = '';
const list = accountsData.map((a, i) => ({ a, i }));
list.slice().reverse().forEach(({ a, i }) => {
const tr = document.createElement('tr');
const ops = document.createElement('td');
ops.className = 'actions';
const edit = document.createElement('a'); edit.href='#'; edit.textContent='编辑'; edit.className='link-blue';
const del = document.createElement('a'); del.href='#'; del.textContent='删除'; del.className='link-red';
ops.append(edit, document.createTextNode(' '), del);
if (!a.initialSet) {
const initBtn = document.createElement('a');
initBtn.href='#'; initBtn.textContent='初始设置'; initBtn.className='link-orange';
ops.append(document.createTextNode(' '), initBtn);
initBtn.addEventListener('click', e => {
e.preventDefault();
pendingAccInitIndex = i;
accInitAmount.value = '';
accInitModal.style.display = 'flex';
});
}
[a.name, a.balance.toFixed(2), a.desc || '', a.created].forEach(v => { const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); });
tr.appendChild(ops);
accRows.appendChild(tr);
edit.addEventListener('click', e => {
e.preventDefault();
pendingAccEditIndex = i;
accEditName.value = a.name || '';
accEditDesc.value = a.desc || '';
accEditModal.style.display = 'flex';
});
del.addEventListener('click', e => {
e.preventDefault();
const used = records.some(r => r.method === a.name);
if (used) { alert('该账户有相关信息正在使用中无法被删除'); return; }
pendingAccDeleteIndex = i;
accDeleteModal.style.display = 'flex';
});
});
}
const accInitModal = document.getElementById('acc-init-modal');
const accInitAmount = document.getElementById('acc-init-amount');
const accInitCancel = document.getElementById('acc-init-cancel');
const accInitOk = document.getElementById('acc-init-ok');
let pendingAccInitIndex = null;
const accCreateModal = document.getElementById('acc-create-modal');
const accCreateForm = document.getElementById('acc-create-form');
const accCreateCancel = document.getElementById('acc-create-cancel');
const accCreateName = document.getElementById('acc-create-name');
const accCreateDesc = document.getElementById('acc-create-desc');
const accDeleteModal = document.getElementById('acc-delete-modal');
const accDeleteCancel = document.getElementById('acc-delete-cancel');
const accDeleteOk = document.getElementById('acc-delete-ok');
let pendingAccDeleteIndex = null;
const accEditModal = document.getElementById('acc-edit-modal');
const accEditForm = document.getElementById('acc-edit-form');
const accEditCancel = document.getElementById('acc-edit-cancel');
const accEditName = document.getElementById('acc-edit-name');
const accEditDesc = document.getElementById('acc-edit-desc');
let pendingAccEditIndex = null;
accInitCancel?.addEventListener('click', () => {
accInitModal.style.display = 'none';
pendingAccInitIndex = null;
});
accInitOk?.addEventListener('click', async () => {
if (pendingAccInitIndex != null) {
const amt = parseFloat(accInitAmount.value || '0');
if (isNaN(amt)) return;
accountsData[pendingAccInitIndex].balance = amt;
accountsData[pendingAccInitIndex].initialSet = true;
accInitModal.style.display = 'none';
await apiAccountInit(accountsData[pendingAccInitIndex]?.name || '', amt);
await apiAccountsList();
refreshAccountOptions();
renderAccounts();
saveJSON('accountsData', accountsData);
pendingAccInitIndex = null;
}
});
accCreateCancel?.addEventListener('click', () => {
accCreateModal.style.display = 'none';
});
accCreateForm?.addEventListener('submit', async e => {
e.preventDefault();
const name = (accCreateName?.value || '').trim();
const desc = (accCreateDesc?.value || '').trim();
if (!name) return;
if (accountsData.some(a => a.name === name)) { alert('账户名称已存在'); return; }
const now = new Date();
const created = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
accountsData.push({ name, balance:0, desc, created, initialSet:false });
accCreateModal.style.display = 'none';
await apiAccountCreate({ name, balance:0, desc, created, initialSet:false });
await apiAccountsList();
refreshAccountOptions();
renderAccounts();
saveJSON('accountsData', accountsData);
});
accEditCancel?.addEventListener('click', () => {
accEditModal.style.display = 'none';
pendingAccEditIndex = null;
});
accEditForm?.addEventListener('submit', async e => {
e.preventDefault();
if (pendingAccEditIndex == null) return;
const name = (accEditName?.value || '').trim();
const desc = (accEditDesc?.value || '').trim();
if (!name) return;
const oldName = accountsData[pendingAccEditIndex].name;
if (name !== oldName && accountsData.some((x, i) => x.name === name && i !== pendingAccEditIndex)) { alert('账户名称已存在'); return; }
accountsData[pendingAccEditIndex].name = name;
accountsData[pendingAccEditIndex].desc = desc;
records.forEach(r => { if (r.method === oldName) r.method = name; });
accEditModal.style.display = 'none';
pendingAccEditIndex = null;
await apiAccountUpdateByName({ name: oldName, newName: name, desc });
await apiAccountsList();
refreshAccountOptions();
renderAccounts();
saveJSON('accountsData', accountsData);
saveJSON('records', records);
});
accDeleteCancel?.addEventListener('click', () => {
accDeleteModal.style.display = 'none';
pendingAccDeleteIndex = null;
});
accDeleteOk?.addEventListener('click', async () => {
if (pendingAccDeleteIndex != null) {
const name = accountsData[pendingAccDeleteIndex]?.name || '';
const ok = await apiAccountDeleteByName(name);
if (ok) accountsData.splice(pendingAccDeleteIndex,1);
await apiAccountsList();
refreshAccountOptions();
renderAccounts();
saveJSON('accountsData', accountsData);
}
accDeleteModal.style.display = 'none';
pendingAccDeleteIndex = null;
});
const clientDD = document.getElementById('client-dd');
const clientSearch = document.getElementById('client-search');
const clientList = document.getElementById('client-list');
const clientPlus = document.getElementById('client-plus');
const clientWrap = document.getElementById('client-wrap');
const clientModal = document.getElementById('client-modal');
const clientModalForm = document.getElementById('client-modal-form');
const clientCancel = document.getElementById('client-cancel');
let clientModalTab = 'customers';
const fileViewer = document.getElementById('file-viewer');
const fileViewerBox = document.getElementById('file-viewer-box');
function allContacts() {
return [...contactsData.customers, ...contactsData.merchants, ...contactsData.others];
}
function renderClientDropdown() {
const q = (clientSearch.value || '').trim();
const data = allContacts().filter(x => {
if (!q) return true;
return [x.name,x.contact,x.phone,x.city,(x.remark||'')].some(v => (v||'').includes(q));
});
clientList.innerHTML = '';
data.forEach(item => {
const row = document.createElement('div');
row.className = 'dd-item';
const left = document.createElement('div');
left.textContent = item.name;
const right = document.createElement('div');
right.style.color = '#94a3b8';
right.textContent = `${item.contact || ''} ${item.phone || ''} ${item.city || ''}`.trim();
row.append(left, right);
row.addEventListener('click', () => {
entryClient.value = item.name;
clientDD.style.display = 'none';
});
clientList.appendChild(row);
});
}
function openClientDropdown() {
clientDD.style.display = 'block';
clientSearch.value = '';
renderClientDropdown();
const entryCard = document.getElementById('entry-form')?.closest('.card');
if (entryCard) {
const cr = entryCard.getBoundingClientRect();
const gap = 16;
clientDD.style.position = 'fixed';
clientDD.style.left = `${Math.max(0, cr.left - cr.width - gap)}px`;
clientDD.style.top = `${cr.top}px`;
clientDD.style.width = `${cr.width}px`;
clientDD.style.height = `${cr.height}px`;
clientDD.style.zIndex = '90';
const head = clientDD.querySelector('.dd-head');
const list = clientDD.querySelector('.dd-list');
const headH = head ? head.getBoundingClientRect().height : 48;
if (list) { list.style.maxHeight = `${cr.height - headH - 24}px`; list.style.overflow = 'auto'; }
}
clientSearch.focus();
}
entryClient.addEventListener('focus', openClientDropdown);
entryClient.addEventListener('click', openClientDropdown);
clientSearch.addEventListener('input', renderClientDropdown);
document.addEventListener('click', (e) => {
if (!clientWrap.contains(e.target) && !clientDD.contains(e.target)) clientDD.style.display = 'none';
});
clientPlus?.addEventListener('click', () => {
clientDD.style.display = 'none';
clientModal.style.display = 'flex';
clientModalTab = 'customers';
document.querySelectorAll('.pill').forEach(p => p.classList.toggle('active', p.getAttribute('data-target') === clientModalTab));
});
document.querySelectorAll('.pill[data-target]').forEach(p => {
p.addEventListener('click', () => {
clientModalTab = p.getAttribute('data-target');
document.querySelectorAll('.pill').forEach(x => x.classList.remove('active'));
p.classList.add('active');
});
});
clientCancel?.addEventListener('click', () => {
clientModal.style.display = 'none';
});
clientModalForm?.addEventListener('submit', e => {
e.preventDefault();
const name = document.getElementById('m-name').value.trim();
const company = document.getElementById('m-company').value.trim();
const code = document.getElementById('m-code').value.trim();
const contact = document.getElementById('m-contact').value.trim();
const phone = document.getElementById('m-phone').value.trim();
const country = document.getElementById('m-country').value.trim();
const address = document.getElementById('m-address').value.trim();
const zip = document.getElementById('m-zip').value.trim();
const city = document.getElementById('m-city').value.trim();
const remark = document.getElementById('m-remark').value.trim();
if (!name) return;
const now = new Date();
const created = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const ownerLabel = clientModalTab==='customers'?'客户':clientModalTab==='merchants'?'商家':'其它';
contactsData[clientModalTab].push({ name, contact, phone, city, remark, owner: ownerLabel, created, company, code, country, address, zip });
['m-name','m-company','m-code','m-contact','m-phone','m-country','m-address','m-zip','m-city','m-remark'].forEach(id => document.getElementById(id).value='');
clientModal.style.display = 'none';
entryClient.value = name;
renderClientDropdown();
saveJSON('contactsData', contactsData);
});
fileViewer.addEventListener('click', (e) => {
if (e.target === fileViewer) fileViewer.style.display = 'none';
});
function getCatChildrenByName(name) {
const cat = categoriesData.find(c => c.name === name);
return cat ? cat.children : [];
}
function refreshLedgerTypeOptions() {
// Deprecated: entryType is now hidden input controlled by buttons
// But we might need to set initial state?
// Let's ensure default is '收入' if empty
if (!entryType.value) {
entryType.value = '收入';
const switchEl = document.getElementById('entry-type-switch');
if (switchEl) {
const btn = switchEl.querySelector('[data-value="收入"]');
if (btn) btn.classList.add('active');
}
setCategories();
}
}
// Entry Type Switch Logic
const entryTypeSwitch = document.getElementById('entry-type-switch');
if (entryTypeSwitch) {
const btns = entryTypeSwitch.querySelectorAll('.type-item');
btns.forEach(btn => {
btn.addEventListener('click', () => {
// Remove active from all
btns.forEach(b => b.classList.remove('active'));
// Add active to clicked
btn.classList.add('active');
// Set hidden input value
entryType.value = btn.dataset.value;
// Trigger change event or call setCategories directly
setCategories();
});
});
}
function setCategories() {
const t = entryType.value;
const list = getCatChildrenByName(t);
const prev = entryCategory.value;
entryCategory.innerHTML = '请选择子类目 ';
list.forEach(c => {
const opt = document.createElement('option');
opt.value = c; opt.textContent = c;
entryCategory.appendChild(opt);
});
if (list.includes(prev)) entryCategory.value = prev;
}
// entryType.addEventListener('change', setCategories); // No longer needed as hidden input doesn't fire change on programmatic update usually
const entryDoc = document.getElementById('entry-doc');
entryCategory.addEventListener('change', () => { entryDoc?.focus(); });
function linkDocToPayable() {
const doc = (entryDoc?.value || '').trim();
const type = entryType.value;
if (!doc) return;
const targetType = type === '收入' ? '应收账款' : (type === '支出' ? '应付账款' : null);
if (!targetType) return;
const rec = payRecords.find(r => (r.doc||'') === doc && r.type === targetType);
if (rec) {
entryClient.value = rec.partner || '';
const remaining = Math.max(0, (rec.amount||0) - (rec.paid||0));
entryAmount.value = String(remaining || rec.amount || 0);
if (typeof clientDD !== 'undefined' && clientDD) clientDD.style.display = 'none';
entryMethod?.focus();
}
}
entryDoc?.addEventListener('blur', linkDocToPayable);
entryDoc?.addEventListener('keyup', (e) => {
if (e.key === 'Enter') {
linkDocToPayable();
entryMethod?.focus();
}
});
function adjustSelect(sel, delta) {
if (!sel) return;
const len = sel.options.length;
let idx = sel.selectedIndex;
idx = Math.max(0, Math.min(len-1, idx + delta));
sel.selectedIndex = idx;
sel.dispatchEvent(new Event('change', { bubbles: true }));
}
entryMethod?.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); adjustSelect(entryMethod, 1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); adjustSelect(entryMethod, -1); }
else if (e.key === 'Enter') { e.preventDefault(); entryMethod.blur(); }
});
let ledgerEditingId = null;
function setLedgerEdit(rec) {
ledgerEditingId = rec.id || null;
if (entryType) entryType.value = rec.type || '';
setCategories();
if (entryCategory) entryCategory.value = rec.category || '';
if (entryDoc) entryDoc.value = rec.doc || '';
if (entryClient) entryClient.value = rec.client || '';
if (entryAmount) entryAmount.value = String(rec.amount || 0);
if (entryMethod) entryMethod.value = rec.method || '';
if (entryNotes) entryNotes.value = rec.notes || '';
if (entryFile) entryFile.value = '';
if (entrySubmitBtn) entrySubmitBtn.textContent = '保存修改';
entryForm?.scrollIntoView({ behavior:'smooth', block:'start' });
}
function clearLedgerEdit() {
ledgerEditingId = null;
if (entrySubmitBtn) entrySubmitBtn.textContent = '提交';
}
function render(data) {
rows.innerHTML = '';
if (!data.length) {
const tr = document.createElement('tr');
tr.className = 'empty';
const td = document.createElement('td');
td.colSpan = 11;
td.textContent = '暂无流水记录';
tr.appendChild(td);
rows.appendChild(tr);
return;
}
for (const r of data) {
const tr = document.createElement('tr');
const canEdit = r.confirmed === false && r.id;
const amt = (r.type === '开支' || r.type === '支出') ? (-r.amount).toFixed(2) : r.amount.toFixed(2);
const makeTd = (text) => {
const td = document.createElement('td');
td.textContent = text;
return td;
};
tr.appendChild(makeTd(r.type || ''));
tr.appendChild(makeTd(r.category || ''));
tr.appendChild(makeTd(r.doc || ''));
tr.appendChild(makeTd(r.client || ''));
tr.appendChild(makeTd(amt));
tr.appendChild(makeTd(r.method || ''));
const tdFile = document.createElement('td');
if (r.fileUrl) {
if ((r.fileType || '').includes('pdf') || /\.pdf$/i.test(r.fileName||'')) {
const span = document.createElement('span');
span.className = 'thumb-pdf';
span.textContent = 'PDF';
span.addEventListener('click', () => {
fileViewerBox.innerHTML = '';
const emb = document.createElement('embed');
emb.src = r.fileUrl;
emb.type = 'application/pdf';
fileViewerBox.appendChild(emb);
fileViewer.style.display = 'flex';
});
tdFile.appendChild(span);
} else {
const img = document.createElement('img');
img.className = 'thumb-img';
img.src = r.fileUrl;
img.alt = r.fileName || '附件';
img.addEventListener('click', () => {
fileViewerBox.innerHTML = '';
const full = document.createElement('img');
full.src = r.fileUrl;
fileViewerBox.appendChild(full);
fileViewer.style.display = 'flex';
});
tdFile.appendChild(img);
}
} else {
tdFile.textContent = r.file ? r.file : '-';
}
tr.appendChild(tdFile);
tr.appendChild(makeTd(r.entry || ''));
tr.appendChild(makeTd(r.notes || ''));
tr.appendChild(makeTd(r.date || ''));
const tdOps = document.createElement('td');
if (canEdit) {
const editBtn = document.createElement('a'); editBtn.href = '#'; editBtn.textContent = '修改'; editBtn.className = 'link-blue';
const okBtn = document.createElement('a'); okBtn.href = '#'; okBtn.textContent = '确认'; okBtn.className = 'link-green';
tdOps.append(editBtn, document.createTextNode(' '), okBtn);
editBtn.addEventListener('click', e => {
e.preventDefault();
setLedgerEdit(r);
});
okBtn.addEventListener('click', async e => {
e.preventDefault();
try {
await apiFetchJSON('/api/ledger/' + String(r.id) + '/confirm', { method:'PUT' });
clearLedgerEdit();
loadLedgerFromServer();
loadPayablesFromServer();
apiAccountsList().then(() => { refreshAccountOptions(); renderAccounts(); });
} catch {}
});
}
tr.appendChild(tdOps);
if (r.type === '收入') tr.classList.add('row-income');
if (r.type === '开支' || r.type === '支出') tr.classList.add('row-expense');
rows.appendChild(tr);
}
}
function getFilters() {
const t = filterType.value;
const key = filterKey.value.trim();
const s = filterStart.value ? new Date(filterStart.value) : null;
const e = filterEnd.value ? new Date(filterEnd.value) : null;
return { t, key, s, e };
}
function applyFilters() {
const { t, key, s, e } = getFilters();
const outAll = records.filter(r => {
if (t !== 'all' && r.type !== t) return false;
if (ledgerHdrType !== 'all') {
if (ledgerHdrType === '开支') { if (!(r.type === '开支' || r.type === '支出')) return false; }
else if (r.type !== ledgerHdrType) return false;
}
if (ledgerHdrCat && r.category !== ledgerHdrCat) return false;
if (ledgerHdrOwner) {
const owner = clientOwner(r.client || '');
if (owner !== ledgerHdrOwner) return false;
}
if (key && !((r.client||'').includes(key) || (r.notes||'').includes(key))) return false;
const d = new Date(r.date);
if (s && d < s) return false;
if (e && d > e) return false;
return true;
});
function ts(r) {
const t1 = r.createdAt || 0;
const t2 = r.dateTime ? Date.parse(r.dateTime) : 0;
const t3 = r.date ? Date.parse(r.date) : 0;
return t1 || t2 || t3 || 0;
}
outAll.sort((a,b) => ts(b) - ts(a));
const total = outAll.length;
const totalPages = Math.max(1, Math.ceil(total / ledgerPageSize));
if (ledgerPage > totalPages) ledgerPage = totalPages;
const startIdx = (ledgerPage - 1) * ledgerPageSize;
const out = outAll.slice(startIdx, startIdx + ledgerPageSize);
render(out);
if (ledgerTableWrap) ledgerTableWrap.scrollTop = 0;
updateLedgerHeaderCover();
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
if (ledgerPager) {
ledgerPager.innerHTML = '';
ledgerPager.style.display = 'flex';
const makeBtn = (label, page, disabled=false, active=false) => {
const b = document.createElement('a');
b.href = '#'; b.textContent = label;
b.style.padding = '4px 8px';
b.style.border = '1px solid #334155';
b.style.borderRadius = '4px';
b.style.color = active ? '#000' : '#cbd5e1';
b.style.background = active ? '#cbd5e1' : 'transparent';
b.style.pointerEvents = disabled ? 'none' : 'auto';
b.style.opacity = disabled ? '0.4' : '1';
b.addEventListener('click', e => { e.preventDefault(); ledgerPage = page; applyFilters(); });
ledgerPager.appendChild(b);
};
makeBtn('«', Math.max(1, ledgerPage-1), ledgerPage<=1);
const maxButtons = 9;
let start = Math.max(1, ledgerPage - Math.floor(maxButtons/2));
let end = Math.min(totalPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
for (let p = start; p <= end; p++) makeBtn(String(p), p, false, p===ledgerPage);
makeBtn('»', Math.min(totalPages, ledgerPage+1), ledgerPage>=totalPages);
}
const infoEl = document.getElementById('pay-footer-info');
if (infoEl) {
const totalCount = (records || []).length;
const todayStr = (() => { const d = new Date(); const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${dd}`; })();
const toTs = r => r.createdAt || (r.dateTime ? Date.parse(r.dateTime) : 0) || (r.date ? Date.parse(r.date) : 0) || 0;
const todayCount = (records || []).filter(r => {
const t = toTs(r); if (!t) return false;
const d = new Date(t); const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${dd}` === todayStr;
}).length;
const latestTs = Math.max(0, ...((records||[]).map(toTs)));
const latestCount = latestTs ? (records || []).filter(r => toTs(r) === latestTs).length : 0;
const mk = (text) => { const s = document.createElement('span'); s.className = 'info-pill'; s.textContent = text; return s; };
infoEl.innerHTML = '';
infoEl.appendChild(mk(`共 ${totalCount} 条记录`));
infoEl.appendChild(mk(`今日上传 ${todayCount} 条`));
infoEl.appendChild(mk(`最后次上传 ${latestCount || 1} 条`));
}
}
filterKey.addEventListener('input', applyFilters);
filterType.addEventListener('change', () => { ledgerPage = 1; applyFilters(); });
filterStart.addEventListener('change', () => { ledgerPage = 1; applyFilters(); });
filterEnd.addEventListener('change', () => { ledgerPage = 1; applyFilters(); });
document.getElementById('system-clear-ledger')?.addEventListener('click', async () => {
if (!confirm('确认清空收支记账的所有数据?此操作不可撤销。')) return;
try {
await apiFetchJSON('/api/ledger', { method:'DELETE' });
records.splice(0, records.length);
saveJSON('records', records);
loadLedgerFromServer();
loadPayablesFromServer();
apiAccountsList().then(() => { refreshAccountOptions(); renderAccounts(); });
renderContacts();
} catch {}
});
document.getElementById('system-clear-pay')?.addEventListener('click', async () => {
if (!confirm('确定清空应收/应付所有记录?此操作不可恢复')) return;
try {
await apiFetchJSON('/api/payables', { method:'DELETE' });
payRecords.splice(0, payRecords.length);
saveJSON('payRecords', payRecords);
payPage = 1;
loadPayablesFromServer();
renderContacts();
} catch {}
});
document.getElementById('entry-form').addEventListener('submit', async e => {
e.preventDefault();
[document.getElementById('entry-doc'), entryClient, entryAmount, entryMethod].forEach(el => el?.classList.remove('invalid'));
const u = getAuthUser(); const roleName = u?.role || '';
if (roleName !== '超级管理员') {
const role = rolesData.find(r => r.name === roleName);
const allowed = !!(role && role.perms && role.perms.ledger && role.perms.ledger.create);
if (!allowed) { alert('当前角色无“收支记账新增”权限'); return; }
}
const type = entryType.value;
const category = entryCategory.value;
const doc = (document.getElementById('entry-doc')?.value || '').trim();
const clientVal = (entryClient.value || '').trim();
const amountStr = (entryAmount.value || '').trim();
const method = entryMethod.value;
if (!type || !category) return;
const invalidEls = [];
if (!doc) invalidEls.push(document.getElementById('entry-doc'));
if (!clientVal) invalidEls.push(entryClient);
if (!amountStr) invalidEls.push(entryAmount);
if (!method) invalidEls.push(entryMethod);
if (invalidEls.length) {
invalidEls.forEach(el => {
if (!el) return;
el.classList.add('invalid');
const clear = () => el.classList.remove('invalid');
el.addEventListener('input', clear, { once: true });
el.addEventListener('change', clear, { once: true });
});
invalidEls[0]?.focus();
return;
}
const amount = parseFloat(amountStr || '0');
const fileObj = entryFile.files[0] || null;
const file = fileObj ? fileObj.name : '';
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const dateTime = `${date} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const rec = { type, category, doc, client: clientVal, amount, method, file, entry:'手动', notes: entryNotes.value.trim(), date, dateTime, createdAt: Date.now(), confirmed: false };
if (fileObj) {
const extOk = /(\.jpe?g|\.pdf)$/i.test(fileObj.name);
if (!extOk) { alert('仅支持 JPG 或 PDF 文件'); return; }
rec.fileType = fileObj.type || '';
rec.fileName = fileObj.name;
rec.fileUrl = URL.createObjectURL(fileObj);
}
try {
if (ledgerEditingId) {
await apiFetchJSON('/api/ledger/' + String(ledgerEditingId), {
method:'PUT',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ type, category, doc, client: clientVal, amount, method, file:'', notes: rec.notes || '', date, dateTime, createdBy: (getAuthUser()?.name || '') })
});
clearLedgerEdit();
} else {
await apiFetchJSON('/api/ledger', {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ type, category, doc, client: clientVal, amount, method, file:'', notes: rec.notes || '', date, dateTime, createdBy: (getAuthUser()?.name || ''), confirmed:false })
});
}
} catch {}
loadLedgerFromServer();
document.getElementById('entry-doc').value = '';
entryClient.value = '';
entryAmount.value = '';
entryMethod.value = '';
entryFile.value = '';
entryNotes.value = '';
[document.getElementById('entry-doc'), entryClient, entryAmount, entryMethod].forEach(el => el?.classList.remove('invalid'));
ledgerPage = 1;
applyFilters();
saveJSON('records', records.map(r => {
const { fileUrl, ...rest } = r;
return rest;
}));
saveJSON('accountsData', accountsData);
if (document.getElementById('page-home')?.style.display === 'block') renderHomeChart('month');
});
const payRows = document.getElementById('pay-rows');
const payType = document.getElementById('pay-type');
const payPartner = document.getElementById('pay-partner');
const partnerAdd = document.getElementById('partner-add');
const payDoc = document.getElementById('pay-doc');
const paySales = document.getElementById('pay-sales');
const payAmount = document.getElementById('pay-amount');
const payTrust = document.getElementById('pay-trust');
const payNotes = document.getElementById('pay-notes');
const payForm = document.getElementById('pay-form');
const paySubmitBtn = payForm?.querySelector('button[type="submit"]');
function payDocExists(doc, recType, excludeId) {
const d = String(doc || '').trim();
if (!d) return false;
return payRecords.some(r => String(r.doc||'').trim() === d && r.type === recType && (!excludeId || r.id !== excludeId));
}
function setPayDocInvalid(flag) {
if (!payDoc) return;
payDoc.style.color = flag ? 'var(--red)' : '';
const lbl = document.getElementById('pay-label-doc');
if (lbl) {
if (flag) lbl.classList.add('invalid-label'); else lbl.classList.remove('invalid-label');
}
}
function validatePayDoc(showAlert) {
const type = payType?.value || '';
const doc = (payDoc?.value || '').trim();
if (!type || !doc) { setPayDocInvalid(false); return; }
const exists = payDocExists(doc, type, payEditingId);
setPayDocInvalid(exists);
if (exists && showAlert) alert('凭证号已存在');
}
payDoc?.addEventListener('input', () => validatePayDoc(false));
payDoc?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
validatePayDoc(true);
if (payDoc.style.color) { e.preventDefault(); e.stopPropagation(); }
}
});
payType?.addEventListener('change', () => validatePayDoc(false));
const payImportFile = document.getElementById('pay-import-file');
payImportFile?.addEventListener('click', () => { try { payImportFile.value = ''; } catch {} });
const payImportHint = document.getElementById('pay-import-hint');
const payImportModal = document.getElementById('pay-import-modal');
const payImportRows = document.getElementById('pay-import-rows');
const payImportSummary = document.getElementById('pay-import-summary');
const payImportCancel = document.getElementById('pay-import-cancel');
const payImportCommit = document.getElementById('pay-import-commit');
const sumRecvEl = document.getElementById('sum-recv');
const sumPayEl = document.getElementById('sum-pay');
const payFilterKey = document.getElementById('pay-filter-key');
const payExportBtn = document.getElementById('pay-export');
const payClearBtn = document.getElementById('pay-clear');
let paySubmitLock = false;
let payLastPageData = [];
const payPager = document.getElementById('global-pager-controls') || document.getElementById('pay-pager');
const payFooterInfo = document.getElementById('pay-footer-info');
const payTableWrap = document.getElementById('pay-table-wrap');
const thType = document.getElementById('th-type');
const thTypeDD = document.getElementById('th-type-dd');
const thTypeList = document.getElementById('th-type-list');
const thTypeLabel = document.getElementById('th-type-label');
const thSales = document.getElementById('th-sales');
const thSalesDD = document.getElementById('th-sales-dd');
const thSalesList = document.getElementById('th-sales-list');
const thSalesLabel = document.getElementById('th-sales-label');
const thArrears = document.getElementById('th-arrears');
const thArrearsDD = document.getElementById('th-arrears-dd');
const thArrearsList = document.getElementById('th-arrears-list');
const thArrearsLabel = document.getElementById('th-arrears-label');
const thTrust = document.getElementById('th-trust');
const thTrustDD = document.getElementById('th-trust-dd');
const thTrustList = document.getElementById('th-trust-list');
const thTrustLabel = document.getElementById('th-trust-label');
let payFilterSalesName = '';
let payFilterStatus = 'all';
let payFilterType = 'all';
let payFilterOverdue = 'all';
let payPage = 1;
const payPageSize = 100;
function setLabel(el, text, active) {
if (!el) return;
el.textContent = text + ' ▾';
el.style.color = active ? '#ef4444' : '';
}
function openTypeFilter() {
thTypeDD.style.display = 'block';
thTypeList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => { payFilterType = val; thTypeDD.style.display='none'; setLabel(thTypeLabel, val==='all'?'款项类型':'应'+(val==='recv'?'收':'付'), val!=='all'); payPage = 1; renderPayables(); });
thTypeList.appendChild(row);
};
addItem('全部', 'all');
addItem('应收', 'recv');
addItem('应付', 'pay');
}
function openSalesFilter() {
thSalesDD.style.display = 'block';
thSalesList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => { payFilterSalesName = val; thSalesDD.style.display='none'; setLabel(thSalesLabel, val ? (val==='__none__'?'无业务员':val) : '业务员', !!val); payPage = 1; renderPayables(); });
thSalesList.appendChild(row);
};
addItem('全部', '');
addItem('无业务员', '__none__');
(salesData || []).forEach(s => addItem(s.name, s.name));
}
function openArrearsFilter() {
thArrearsDD.style.display = 'block';
thArrearsList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => { payFilterStatus = val; thArrearsDD.style.display='none'; setLabel(thArrearsLabel, val==='all'?'欠款':(val==='arrears'?'欠款订单':'订单完成'), val!=='all'); payPage = 1; renderPayables(); });
thArrearsList.appendChild(row);
};
addItem('全部订单', 'all');
addItem('欠款订单', 'arrears');
addItem('订单完成', 'done');
}
function openTrustFilter() {
thTrustDD.style.display = 'block';
thTrustList.innerHTML = '';
const addItem = (label, val) => {
const row = document.createElement('div'); row.className='dd-item'; row.textContent = label;
row.addEventListener('click', () => { payFilterOverdue = val; thTrustDD.style.display='none'; setLabel(thTrustLabel, val==='all'?'信任天数':(val==='overdue'?'已逾期':'未逾期'), val!=='all'); payPage = 1; renderPayables(); });
thTrustList.appendChild(row);
};
addItem('全部', 'all');
addItem('已逾期', 'overdue');
addItem('未逾期', 'not');
}
thType?.addEventListener('click', (e) => { e.stopPropagation(); openTypeFilter(); });
thSales?.addEventListener('click', (e) => { e.stopPropagation(); openSalesFilter(); });
thArrears?.addEventListener('click', (e) => { e.stopPropagation(); openArrearsFilter(); });
thTrust?.addEventListener('click', (e) => { e.stopPropagation(); openTrustFilter(); });
const typeSwitch = document.getElementById('type-switch');
const typeItems = typeSwitch ? typeSwitch.querySelectorAll('.type-item') : [];
function setPayType(val) {
payType.value = val;
typeItems.forEach(btn => {
const is = btn.getAttribute('data-type') === val;
btn.classList.toggle('active', is);
btn.classList.toggle('recv', is && val === '应收账款');
btn.classList.toggle('pay', is && val === '应付账款');
});
}
typeItems.forEach(btn => {
btn.addEventListener('click', () => setPayType(btn.getAttribute('data-type')));
});
setPayType('应收账款');
const payWrap = document.getElementById('pay-wrap');
const payDD = document.getElementById('pay-dd');
const paySearch = document.getElementById('pay-search');
const payList = document.getElementById('pay-list');
partnerAdd?.addEventListener('click', () => {
const name = payPartner.value.trim();
if (!name) return;
if (!partners.includes(name)) partners.push(name);
});
function renderPayDropdown() {
const q = (paySearch?.value || payPartner.value || '').trim();
const data = allContacts().filter(x => {
if (!q) return true;
return [x.name,x.contact,x.phone,x.city,(x.remark||'')].some(v => (v||'').includes(q));
});
payList.innerHTML = '';
data.forEach(item => {
const row = document.createElement('div');
row.className = 'dd-item';
const left = document.createElement('div');
left.textContent = item.name;
const right = document.createElement('div');
right.style.color = '#94a3b8';
right.textContent = `${item.contact || ''} ${item.phone || ''} ${item.city || ''}`.trim();
row.append(left, right);
row.addEventListener('click', () => {
payPartner.value = item.name;
if (paySales) {
const bound = (item.sales || '').trim();
paySales.value = bound && [...paySales.options].some(o => o.value === bound) ? bound : '';
}
payDD.style.display = 'none';
document.getElementById('pay-label-partner')?.classList.remove('invalid-label');
});
payList.appendChild(row);
});
}
function openPayDropdown() {
const card = document.getElementById('pay-form')?.closest('.card') || document.querySelector('#page-payables .row .card:nth-child(2)');
const cr = card?.getBoundingClientRect();
const ddHead = payDD.querySelector('.dd-head');
if (ddHead) ddHead.style.display = 'block';
payDD.style.display = 'block';
payDD.style.position = 'fixed';
if (cr) {
const gap = 16;
payDD.style.left = `${Math.max(0, cr.left - cr.width - gap)}px`;
payDD.style.top = `${cr.top}px`;
payDD.style.width = `${cr.width}px`;
payDD.style.height = `${cr.height}px`;
const head = payDD.querySelector('.dd-head');
const list = payDD.querySelector('.dd-list');
const headH = head ? head.getBoundingClientRect().height : 48;
if (list) { list.style.maxHeight = `${cr.height - headH - 24}px`; list.style.overflow = 'auto'; }
}
payDD.style.zIndex = '90';
renderPayDropdown();
}
payPartner.addEventListener('focus', openPayDropdown);
payPartner.addEventListener('click', openPayDropdown);
payPartner.addEventListener('input', renderPayDropdown);
payPartner.addEventListener('input', () => {
if ((payPartner.value || '').trim()) document.getElementById('pay-label-partner')?.classList.remove('invalid-label');
});
paySearch?.addEventListener('input', renderPayDropdown);
document.addEventListener('click', (e) => {
if (!payWrap?.contains(e.target) && !payDD?.contains(e.target)) payDD.style.display = 'none';
if (!thType?.contains(e.target)) thTypeDD.style.display = 'none';
if (!thSales?.contains(e.target)) thSalesDD.style.display = 'none';
if (!thArrears?.contains(e.target)) thArrearsDD.style.display = 'none';
if (!thTrust?.contains(e.target)) thTrustDD.style.display = 'none';
if (!ldType?.contains(e.target)) ldTypeDD.style.display = 'none';
if (!ldCat?.contains(e.target)) ldCatDD.style.display = 'none';
if (!ldOwner?.contains(e.target)) ldOwnerDD.style.display = 'none';
});
const payHistoryModal = document.getElementById('pay-history-modal');
const payHistoryHead = document.getElementById('pay-history-head');
const payHistoryList = document.getElementById('pay-history-list');
const payHistoryClose = document.getElementById('pay-history-close');
const payHistoryNotesText = document.getElementById('pay-history-notes-text');
const payHistoryNotesInput = document.getElementById('pay-history-notes-input');
const payHistoryNotesAdd = document.getElementById('pay-history-notes-add');
let payHistoryCurrentRec = null;
function openPayHistory(rec) {
const total = (rec.amount || 0);
const paid = (rec.paid || 0);
const arrears = Math.max(0, total - paid);
payHistoryHead.innerHTML = `
单据号:${rec.doc || ''} 往来单位:${rec.partner || ''} 金额:${total.toFixed(2)} 已付:${paid.toFixed(2)} 欠款:${arrears.toFixed(2)}
`;
payHistoryNotesText.textContent = rec.notes || '';
if (payHistoryNotesInput) payHistoryNotesInput.value = '';
payHistoryCurrentRec = rec;
const hist = rec.history || [];
payHistoryList.innerHTML = '';
if (!hist.length) {
const div = document.createElement('div'); div.textContent = '暂无历史记录';
payHistoryList.appendChild(div);
} else {
hist.forEach(h => {
const row = document.createElement('div');
const amt = typeof h.amount === 'number' ? h.amount.toFixed(2) : (h.amount || '');
row.textContent = `${h.date || ''} 操作人员:${h.user || ''} 操作:${h.kind || ''}${amt ? ' 金额:'+amt : ''}${h.method ? ' 方式:'+h.method : ''}${h.notes ? ' 备注:'+h.notes : ''}`;
payHistoryList.appendChild(row);
});
}
payHistoryModal.style.display = 'flex';
}
payHistoryClose?.addEventListener('click', () => { payHistoryModal.style.display = 'none'; });
payHistoryNotesAdd?.addEventListener('click', () => {
const rec = payHistoryCurrentRec;
const text = (payHistoryNotesInput?.value || '').trim();
if (!rec || !text || rec.confirmed === true) return;
const now = new Date();
const dt = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const user = (getAuthUser()?.name) || '';
rec.history = rec.history || [];
rec.history.push({ date: dt, user, kind: '备注', amount: '', partner: rec.partner, doc: rec.doc, notes: text });
rec.notes = [rec.notes || '', text].filter(Boolean).join('\n');
payHistoryNotesText.textContent = rec.notes || '';
const row = document.createElement('div');
row.textContent = `${dt} 操作人员:${user} 操作:备注 备注:${text}`;
payHistoryList.appendChild(row);
if (payHistoryNotesInput) payHistoryNotesInput.value = '';
renderPayables();
saveJSON('payRecords', payRecords);
if (rec.id && rec.confirmed === false) {
apiFetchJSON('/api/payables/' + String(rec.id), { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(rec) })
.then(() => loadPayablesFromServer())
.catch(() => {});
}
});
const invoiceModal = document.getElementById('invoice-modal');
const invoiceForm = document.getElementById('invoice-form');
const invoiceNoEl = document.getElementById('invoice-no');
const invoiceDateEl = document.getElementById('invoice-date');
const invoiceAmountEl = document.getElementById('invoice-amount');
const invoiceCancel = document.getElementById('invoice-cancel');
let invoiceCurrentRec = null;
function openInvoiceModal(rec) {
if (rec.confirmed === true) return;
invoiceCurrentRec = rec;
if (invoiceNoEl) invoiceNoEl.value = rec.invoiceNo || '';
if (invoiceDateEl) invoiceDateEl.value = rec.invoiceDate || '';
if (invoiceAmountEl) invoiceAmountEl.value = ((rec.invoiceAmount||0) > 0) ? String(rec.invoiceAmount) : '';
if (invoiceModal) invoiceModal.style.display = 'flex';
}
invoiceCancel?.addEventListener('click', () => { if (invoiceModal) invoiceModal.style.display = 'none'; });
invoiceForm?.addEventListener('submit', e => {
e.preventDefault();
const rec = invoiceCurrentRec; if (!rec) return;
const no = (invoiceNoEl?.value || '').trim();
const date = invoiceDateEl?.value || '';
const amt = parseFloat(invoiceAmountEl?.value || '');
if (!no || !date || !Number.isFinite(amt)) return;
rec.invoiceNo = no;
rec.invoiceDate = date;
rec.invoiceAmount = Math.max(0, amt);
rec.history = rec.history || [];
const now = new Date();
const dt = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
rec.history.push({ date: dt, user: (getAuthUser()?.name)||'', kind: '改为发票', notes: `发票号:${no} 发票日期:${date} 发票金额:${rec.invoiceAmount.toFixed(2)}` });
saveJSON('payRecords', payRecords);
renderPayables();
if (rec.id && rec.confirmed === false) {
apiFetchJSON('/api/payables/' + String(rec.id), { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(rec) })
.then(() => loadPayablesFromServer())
.catch(() => {});
}
if (invoiceModal) invoiceModal.style.display = 'none';
});
function trustLabelDisplay(rec) {
if (rec.settled) return { label: '-', overdue: false };
const dValRaw = rec.trustDays;
if (dValRaw == null || isNaN(dValRaw)) return { label: '', overdue: false };
if (dValRaw === 0) return { label: '立即', overdue: false };
return { label: `${dValRaw}天`, overdue: false };
}
function summarizeNotes(text, perLine, maxLines) {
const s = String(text || '');
if (!s) return '';
const lines = s.split(/\r?\n/);
const out = [];
let overflow = false;
const take = Math.min(maxLines, lines.length);
for (let i = 0; i < take; i++) {
const chs = Array.from(lines[i] || '');
if (chs.length > perLine) { out.push(chs.slice(0, perLine).join('')); overflow = true; }
else { out.push(lines[i]); }
}
if (lines.length > maxLines) overflow = true;
if (overflow && out.length) out[out.length - 1] = out[out.length - 1] + '…';
return out.join('\n');
}
function setPayEdit(rec) {
payEditingId = rec.id || null;
setPayType(rec.type || '应收账款');
if (payPartner) payPartner.value = rec.partner || '';
if (payDoc) payDoc.value = rec.doc || '';
if (paySales) {
const sv = rec.sales || '';
paySales.value = [...paySales.options].some(o => o.value === sv) ? sv : '';
}
if (payAmount) payAmount.value = String(rec.amount || 0);
if (payTrust) payTrust.value = (rec.trustDays ?? '').toString();
if (payNotes) payNotes.value = rec.notes || '';
if (paySubmitBtn) paySubmitBtn.textContent = '保存修改';
payForm?.scrollIntoView({ behavior:'smooth', block:'start' });
}
function clearPayEdit() {
payEditingId = null;
if (paySubmitBtn) paySubmitBtn.textContent = '提交';
}
function renderPayables() {
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
if (paySales) {
const prev = paySales.value;
paySales.innerHTML = '请选择业务员 ';
(salesData || []).forEach(s => {
const opt = document.createElement('option'); opt.value = s.name; opt.textContent = s.name;
paySales.appendChild(opt);
});
if ([...paySales.options].some(o => o.value === prev)) paySales.value = prev;
}
let recv = 0, pay = 0;
for (const r of payRecords) {
if (r.settled) continue;
if (/应收/.test(r.type)) recv += r.amount || 0;
else if (/应付/.test(r.type)) pay += r.amount || 0;
}
if (sumRecvEl) sumRecvEl.textContent = recv.toFixed(2);
if (sumPayEl) sumPayEl.textContent = pay.toFixed(2);
const key = (payFilterKey?.value || '').trim();
let listAll = payRecords.filter(r => {
if (!key) return true;
return [r.partner||'', r.doc||'', r.notes||''].some(v => v.includes(key));
});
if (payFilterType !== 'all') {
listAll = listAll.filter(r => (payFilterType === 'recv' ? /应收/.test(r.type) : /应付/.test(r.type)));
}
if (payFilterSalesName) {
listAll = listAll.filter(r => {
if (payFilterSalesName === '__none__') return !(r.sales);
return (r.sales || '') === payFilterSalesName;
});
}
if (payFilterOverdue !== 'all') {
listAll = listAll.filter(r => {
const trustDaysVal = r.trustDays ?? null;
let isOverdue = false;
if (!r.settled && trustDaysVal != null && trustDaysVal > 0) {
const start = new Date(r.date);
const now = new Date();
const diffDays = Math.floor((now - start) / (1000*60*60*24));
const overdueDays = diffDays - trustDaysVal;
if (overdueDays > 0) isOverdue = true;
}
return payFilterOverdue === 'overdue' ? isOverdue : !isOverdue;
});
}
if (payFilterStatus !== 'all') {
listAll = listAll.filter(r => {
const arrears = Math.max(0, (r.amount || 0) - (r.paid || 0));
if (payFilterStatus === 'arrears') return arrears > 0;
if (payFilterStatus === 'done') return arrears === 0;
return true;
});
}
const hasBatch = listAll.some(r => r.batchAt);
const listSorted = hasBatch ? listAll.slice().sort((a,b) => {
const byBatch = (b.batchAt || 0) - (a.batchAt || 0);
if (byBatch !== 0) return byBatch;
const ao = (a.batchOrder != null) ? a.batchOrder : (a.createdAt || 0);
const bo = (b.batchOrder != null) ? b.batchOrder : (b.createdAt || 0);
return ao - bo;
}) : listAll;
const total = listSorted.length;
const totalPages = Math.max(1, Math.ceil(total / payPageSize));
if (payPage > totalPages) payPage = totalPages;
const startIdx = (payPage - 1) * payPageSize;
const list = listSorted.slice(startIdx, startIdx + payPageSize);
payLastPageData = list.slice();
payRows.innerHTML = '';
if (!list.length) {
const tr = document.createElement('tr');
tr.className = 'empty';
const td = document.createElement('td'); td.colSpan = 12; td.textContent = '暂无记录';
tr.appendChild(td); payRows.appendChild(tr);
return;
}
for (const r of list) {
const tr = document.createElement('tr');
const typeDisplay = /应收/.test(r.type) ? '应收' : '应付';
const tl = trustLabelDisplay(r);
const trustLabel = tl.label;
const isOverdue = tl.overdue;
const paid = r.paid || 0;
const arrears = Math.max(0, (r.amount || 0) - paid);
const canEdit = r.confirmed === false && r.id;
const tdType = document.createElement('td'); tdType.textContent = typeDisplay; tr.appendChild(tdType);
const tdPartner = document.createElement('td'); tdPartner.textContent = r.partner || ''; tr.appendChild(tdPartner);
const tdDoc = document.createElement('td');
const docUp = document.createElement('div'); docUp.textContent = (r.doc || '');
const docDown = document.createElement('div'); docDown.textContent = r.source === 'import' ? parseDateCN(r.date || '') : ''; docDown.style.color = '#9ca3af'; docDown.style.fontSize = '12px';
tdDoc.appendChild(docUp); if (docDown.textContent) tdDoc.appendChild(docDown);
tr.appendChild(tdDoc);
const tdAmount = document.createElement('td'); tdAmount.textContent = (r.amount||0).toFixed(2); tr.appendChild(tdAmount);
const tdInv = document.createElement('td');
const invUp = document.createElement('div');
const invNo = (r.invoiceNo || '');
if (invNo) {
invUp.textContent = invNo;
} else if (r.confirmed === false) {
const a = document.createElement('a'); a.href='#'; a.textContent='-'; a.className='link-blue';
a.addEventListener('click', e => { e.preventDefault(); openInvoiceModal(r); });
invUp.appendChild(a);
} else {
invUp.textContent = '-';
}
const invDown = document.createElement('div'); invDown.textContent = parseDateCN(r.invoiceDate || ''); invDown.style.color = '#9ca3af'; invDown.style.fontSize = '12px';
tdInv.appendChild(invUp); tdInv.appendChild(invDown);
tr.appendChild(tdInv);
const tdInvAmt = document.createElement('td');
const invAmtNum = Number(r.invoiceAmount || 0);
if (invAmtNum > 0 && isFinite(invAmtNum)) {
tdInvAmt.textContent = invAmtNum.toFixed(2);
} else if (r.confirmed === false) {
const a = document.createElement('a'); a.href='#'; a.textContent='-'; a.className='link-blue';
a.addEventListener('click', e => { e.preventDefault(); openInvoiceModal(r); });
tdInvAmt.appendChild(a);
} else {
tdInvAmt.textContent = '-';
}
tr.appendChild(tdInvAmt);
const tdAr = document.createElement('td'); tdAr.textContent = arrears.toFixed(2); tr.appendChild(tdAr);
const tdTrust = document.createElement('td'); tdTrust.textContent = trustLabel; if (isOverdue) tdTrust.classList.add('overdue'); tr.appendChild(tdTrust);
const tdNotes = document.createElement('td');
tdNotes.textContent = summarizeNotes(r.notes, 10, 2);
tdNotes.style.whiteSpace = 'pre-wrap';
tdNotes.style.wordBreak = 'break-all';
tr.appendChild(tdNotes);
const tdSales = document.createElement('td'); tdSales.textContent = r.sales || '-'; tr.appendChild(tdSales);
const tdDate = document.createElement('td'); tdDate.textContent = safePayDate(r); tr.appendChild(tdDate);
const ops = document.createElement('td');
if (canEdit) {
const editBtn = document.createElement('a'); editBtn.href='#'; editBtn.textContent='修改'; editBtn.className='link-blue';
const okBtn = document.createElement('a'); okBtn.href='#'; okBtn.textContent='确认'; okBtn.className='link-green';
ops.append(editBtn, document.createTextNode(' '), okBtn, document.createTextNode(' '));
editBtn.addEventListener('click', e => {
e.preventDefault();
setPayEdit(r);
});
okBtn.addEventListener('click', async e => {
e.preventDefault();
try {
await apiFetchJSON('/api/payables/' + String(r.id) + '/confirm', { method:'PUT' });
clearPayEdit();
loadPayablesFromServer();
renderContacts();
} catch {}
});
}
const btn = document.createElement('a'); btn.href='#'; btn.textContent='详情'; btn.className='link-blue';
ops.appendChild(btn);
tr.appendChild(ops);
btn.addEventListener('click', e => {
e.preventDefault();
openPayHistory(r);
});
if (r.settled) tr.classList.add('pay-row-settled');
else if (typeDisplay === '应收') tr.classList.add('pay-row-recv');
else tr.classList.add('pay-row-pay');
payRows.appendChild(tr);
}
if (payTableWrap) payTableWrap.scrollTop = 0;
if (payPager) {
payPager.innerHTML = '';
payPager.style.display = 'flex';
const makeBtn = (label, page, disabled=false, active=false) => {
const b = document.createElement('a');
b.href = '#'; b.textContent = label;
b.style.padding = '4px 8px';
b.style.border = '1px solid #334155';
b.style.borderRadius = '4px';
b.style.color = active ? '#000' : '#cbd5e1';
b.style.background = active ? '#cbd5e1' : 'transparent';
b.style.pointerEvents = disabled ? 'none' : 'auto';
b.style.opacity = disabled ? '0.4' : '1';
b.addEventListener('click', e => { e.preventDefault(); payPage = page; renderPayables(); });
payPager.appendChild(b);
};
makeBtn('«', Math.max(1, payPage-1), payPage<=1);
const maxButtons = 9;
let start = Math.max(1, payPage - Math.floor(maxButtons/2));
let end = Math.min(totalPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
for (let p = start; p <= end; p++) makeBtn(String(p), p, false, p===payPage);
makeBtn('»', Math.min(totalPages, payPage+1), payPage>=totalPages);
}
if (payFooterInfo) {
const totalCount = payRecords.length;
const today = formatDateFromTs(Date.now());
const todayCount = payRecords.filter(r => formatDateFromTs(r.createdAt) === today).length;
const latestBatch = Math.max(0, ...payRecords.map(r => r.batchAt || 0));
const latestBatchCount = latestBatch ? payRecords.filter(r => (r.batchAt||0) === latestBatch).length : (payRecords.length ? 1 : 0);
payFooterInfo.innerHTML = '';
const mk = (text) => { const s = document.createElement('span'); s.className = 'info-pill'; s.textContent = text; return s; };
payFooterInfo.appendChild(mk(`共 ${totalCount} 条记录`));
payFooterInfo.appendChild(mk(`今日上传 ${todayCount} 条`));
payFooterInfo.appendChild(mk(`最后次上传 ${latestBatchCount} 条`));
}
}
payFilterKey?.addEventListener('input', () => { payPage = 1; renderPayables(); });
payExportBtn?.addEventListener('click', () => {
const data = payLastPageData || [];
if (!data.length) { alert('当前页面无记录可导出'); return; }
const rows = [];
rows.push(['款项类型','往来单位','单据/凭证号','业务员','金额','发票号','发票日期','发票金额','欠款','信任天数','备注','日期']);
data.forEach(r => {
const typeDisplay = /应收/.test(r.type) ? '应收' : '应付';
const tl = trustLabelDisplay(r);
const trustLabel = tl.label;
const paid = r.paid || 0;
const arrears = Math.max(0, (r.amount || 0) - paid);
const invAmtCell = (Number(r.invoiceAmount||0) > 0) ? (r.invoiceAmount||0).toFixed(2) : '-';
const outDate = (r.source === 'manual') ? (safePayDate(r) || '') : (r.date || '');
rows.push([typeDisplay, r.partner || '', r.doc || '', r.sales || '', (r.amount||0).toFixed(2), r.invoiceNo || '', r.invoiceDate || '', invAmtCell, arrears.toFixed(2), trustLabel, r.notes || '', outDate]);
});
let html = '';
rows.forEach(r => { html += '' + r.map(c => `${String(c).replace(/&/g,'&').replace(//g,'>')} `).join('') + ' '; });
html += '
';
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=UTF-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const now = new Date();
const ts = `${now.getFullYear()}${String(now.getMonth()+1).padStart(2,'0')}${String(now.getDate()).padStart(2,'0')}_${String(now.getHours()).padStart(2,'0')}${String(now.getMinutes()).padStart(2,'0')}${String(now.getSeconds()).padStart(2,'0')}`;
a.download = `应收应付账款_${ts}.xls`;
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
payClearBtn?.addEventListener('click', async () => {
if (!confirm('确定清空应收/应付所有记录?此操作不可恢复')) return;
try {
await apiFetchJSON('/api/payables', { method:'DELETE' });
payRecords.splice(0, payRecords.length);
saveJSON('payRecords', payRecords);
payPage = 1;
loadPayablesFromServer();
renderContacts();
} catch {}
});
const handlePayFormSubmit = async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (paySubmitLock) return;
paySubmitLock = true;
document.getElementById('pay-label-partner')?.classList.remove('invalid-label');
document.getElementById('pay-label-doc')?.classList.remove('invalid-label');
document.getElementById('pay-label-amount')?.classList.remove('invalid-label');
if (payImportParsed.length) {
let covered = 0, inserted = 0, createdCustomers = 0, createdMerchants = 0;
const batchAt = Date.now();
let batchOrder = 0;
payImportParsed.forEach(rec => {
rec.confirmed = false;
rec.batchAt = batchAt;
rec.batchOrder = batchOrder++;
rec.createdAt = batchAt;
const existedBefore = [...contactsData.customers, ...contactsData.merchants, ...contactsData.others]
.some(x => (x.name||'') === (rec.partner||''));
ensureContactForPartner(rec.partner, rec.type, rec.sales);
if (!existedBefore && (rec.partner||'').trim()) {
if (/应付/.test(rec.type)) createdMerchants++; else createdCustomers++;
}
const hasKey = (rec.partner||'').trim() && (rec.doc||'').trim();
if (hasKey) {
const ex = findExistingPayRecord(rec);
if (ex) { mergePayRecord(ex, rec); ex.batchAt = batchAt; ex.batchOrder = rec.batchOrder; ex.createdAt = batchAt; covered++; return; }
}
payRecords.push(rec); inserted++;
});
const uploadList = payImportParsed.slice();
payImportParsed = [];
if (payImportFile) payImportFile.value = '';
if (payImportHint) payImportHint.textContent = '批量导入完成';
payPage = 1;
apiFetchJSON('/api/payables/import', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ records: uploadList }) })
.then(() => loadPayablesFromServer())
.catch(() => {});
renderPayables();
if (contactsSearch) contactsSearch.value = '';
renderContacts();
saveJSON('payRecords', payRecords);
saveJSON('contactsData', contactsData);
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
renderPayables();
alert(`导入完成:新增记录 ${inserted} 条,覆盖更新 ${covered} 条,新增客户 ${createdCustomers} 条,新增商家 ${createdMerchants} 条。`);
return;
}
const type = payType.value;
const partner = payPartner.value.trim();
const doc = payDoc.value.trim();
const sales = (paySales?.value || '').trim();
const amountStr = (payAmount.value || '').trim();
const amount = parseFloat(amountStr || '');
const trustDays = parseInt(payTrust.value || '0', 10);
const notes = payNotes.value.trim();
const invalidLabels = [];
if (!type) return;
if (!partner) invalidLabels.push('pay-label-partner');
if (!doc) invalidLabels.push('pay-label-doc');
if (!amountStr || !amount) invalidLabels.push('pay-label-amount');
if (invalidLabels.length) {
invalidLabels.forEach(id => {
const el = document.getElementById(id);
el?.classList.add('invalid-label');
});
const focusEl = !partner ? payPartner : (!doc ? payDoc : payAmount);
focusEl?.focus();
const clearPartner = () => document.getElementById('pay-label-partner')?.classList.remove('invalid-label');
const clearDoc = () => document.getElementById('pay-label-doc')?.classList.remove('invalid-label');
const clearAmount = () => document.getElementById('pay-label-amount')?.classList.remove('invalid-label');
payPartner.addEventListener('input', clearPartner, { once: true });
payDoc.addEventListener('input', clearDoc, { once: true });
payAmount.addEventListener('input', clearAmount, { once: true });
alert('请补全必填项');
paySubmitLock = false;
return;
}
if (payDocExists(doc, type, payEditingId)) {
setPayDocInvalid(true);
alert('凭证号已存在');
paySubmitLock = false;
return;
}
const now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const dateTime = `${date} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const creator = (getAuthUser()?.name) || '';
if (payEditingId) {
const origin = payRecords.find(r => r.id === payEditingId) || {};
const paidVal = Number(origin.paid || 0);
const settledVal = amount > 0 && paidVal >= amount;
const payload = {
type, partner, doc, sales, amount,
paid: paidVal,
trustDays,
notes,
date,
settled: settledVal,
history: origin.history || [],
createdAt: origin.createdAt || Date.now(),
invoiceNo: origin.invoiceNo || '',
invoiceDate: origin.invoiceDate || '',
invoiceAmount: Number(origin.invoiceAmount || 0),
source: origin.source || 'manual',
batchAt: origin.batchAt || 0,
batchOrder: origin.batchOrder ?? 0,
confirmed: false
};
try {
await apiFetchJSON('/api/payables/' + String(payEditingId), { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
clearPayEdit();
loadPayablesFromServer();
renderContacts();
} catch {}
} else {
const rec = { type, partner, doc, sales, amount, paid: 0, trustDays, notes, date, settled:false, history: [], createdAt: Date.now(), invoiceNo:'', invoiceDate:'', invoiceAmount:0, source:'manual', confirmed:false };
rec.batchAt = Date.now();
rec.batchOrder = 0;
rec.history.push({ date: dateTime, user: creator, kind: '创建', amount, partner, doc, notes });
apiFetchJSON('/api/payables', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(rec) })
.then((res) => { if (res && res.id) rec.id = res.id; loadPayablesFromServer(); })
.catch(() => { payRecords.push(rec); });
}
payPartner.value = '';
payDoc.value = '';
if (paySales) paySales.value = '';
payAmount.value = '';
payTrust.value = '30';
payNotes.value = '';
document.getElementById('pay-label-partner')?.classList.remove('invalid-label');
document.getElementById('pay-label-doc')?.classList.remove('invalid-label');
document.getElementById('pay-label-amount')?.classList.remove('invalid-label');
payPage = 1;
renderPayables();
renderContacts();
saveJSON('payRecords', payRecords);
saveJSON('contactsData', contactsData);
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
renderPayables();
paySubmitLock = false;
};
payForm?.addEventListener('submit', handlePayFormSubmit);
paySubmitBtn?.addEventListener('click', handlePayFormSubmit);
let payImportParsed = [];
let payImportInvalidCount = 0;
let payImportRequiredMissingCount = 0;
function parseCSV(text) {
const rows = [];
let i = 0, cur = '', inQ = false, row = [];
while (i < text.length) {
const ch = text[i];
if (inQ) {
if (ch === '"') {
if (text[i+1] === '"') { cur += '"'; i += 2; continue; }
inQ = false; i++; continue;
}
cur += ch; i++; continue;
}
if (ch === '"') { inQ = true; i++; continue; }
if (ch === ',') { row.push(cur.trim()); cur = ''; i++; continue; }
if (ch === '\n') { row.push(cur.trim()); rows.push(row); row = []; cur = ''; i++; continue; }
if (ch === '\r') { i++; continue; }
cur += ch; i++;
}
if (cur.length || row.length) { row.push(cur.trim()); rows.push(row); }
return rows;
}
function parseXLS(html) {
const doc = new DOMParser().parseFromString(html, 'text/html');
const table = doc.querySelector('table');
const rows = [];
if (!table) return rows;
table.querySelectorAll('tr').forEach(tr => {
const row = [];
tr.querySelectorAll('td,th').forEach(td => row.push(td.textContent.trim()));
if (row.length) rows.push(row);
});
return rows;
}
function parseXLSX(buffer) {
try {
const wb = XLSX.read(buffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const rows = XLSX.utils.sheet_to_json(ws, { header: 1, raw: true });
return rows || [];
} catch (e) {
alert('解析 .xlsx 文件失败');
return [];
}
}
function parseTrustDays(val) {
const s = String(val || '').trim();
if (!s) return NaN;
if (s.includes('立即')) return 0;
const m = new RegExp('(\\d+)').exec(s);
return m ? parseInt(m[1], 10) : NaN;
}
function parseDateCN(text) {
const s = String(text || '').trim();
if (!s) return '';
if (/^\d{4}[-\/\.]\d{1,2}[-\/\.]\d{1,2}$/.test(s)) {
const parts = s.split(/[-\/\.]/);
const y = parts[0];
const mm = String(parseInt(parts[1],10)).padStart(2,'0');
const dd = String(parseInt(parts[2],10)).padStart(2,'0');
return `${y}-${mm}-${dd}`;
}
const mCNFull = new RegExp('(\\d{4})\\s*年\\s*(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*日').exec(s);
if (mCNFull) {
const y = mCNFull[1];
const mm = String(parseInt(mCNFull[2],10)).padStart(2,'0');
const dd = String(parseInt(mCNFull[3],10)).padStart(2,'0');
return `${y}-${mm}-${dd}`;
}
const mCN = new RegExp('(\\d{1,2})\\s*月\\s*(\\d{1,2})\\s*日').exec(s);
if (mCN) {
const y = new Date().getFullYear();
const mm = String(parseInt(mCN[1],10)).padStart(2,'0');
const dd = String(parseInt(mCN[2],10)).padStart(2,'0');
return `${y}-${mm}-${dd}`;
}
const mMD = new RegExp('(\\d{1,2})[-\\/\\.]?(\\d{1,2})$').exec(s);
if (mMD) {
const y = new Date().getFullYear();
const mm = String(parseInt(mMD[1],10)).padStart(2,'0');
const dd = String(parseInt(mMD[2],10)).padStart(2,'0');
return `${y}-${mm}-${dd}`;
}
return s;
}
function formatDateFromTs(ts) {
if (!ts) return '';
const d = new Date(ts);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const dd = String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${dd}`;
}
function safePayDate(rec) {
const t0 = Number(rec.createdAt);
if (Number.isFinite(t0) && t0 > 0) return formatDateFromTs(t0);
const t1 = Date.parse(rec.date || rec.invoiceDate || '');
if (!isNaN(t1)) return formatDateFromTs(t1);
return formatDateFromTs(Date.now());
}
function rowToRecord(cols) {
const [typeCol, partner, doc, date, amountCol, invoiceNo, invoiceDate, invoiceAmountCol, trustCol, notes, sales, paidCol] = cols;
const type = /应付/.test(String(typeCol)) ? '应付账款' : '应收账款';
const amount = parseFloat(String(amountCol).replace(/,/g,'')) || 0;
const paidFromSheet = parseFloat(String(paidCol||'').replace(/,/g,'')) || 0;
const trustDaysIn = parseTrustDays(trustCol);
const trustDays = Number.isFinite(trustDaysIn) ? trustDaysIn : parseInt(document.getElementById('pay-trust')?.value || '30', 10);
const now = new Date();
const creator = (getAuthUser()?.name) || '';
const createdAt = Date.now();
const dIn = parseDateCN(date);
const d = (dIn && new RegExp('^\\d{4}-\\d{2}-\\d{2}$').test(dIn)) ? dIn :
`${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`;
const dt = `${d} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const invoiceAmount = parseFloat(String(invoiceAmountCol||'').replace(/,/g,'')) || 0;
const paid = Math.min(paidFromSheet, amount);
const rec = { type, partner: String(partner||'').trim(), doc: String(doc||'').trim(), sales, amount, paid, trustDays, notes, date: d, settled:(paid>=amount && amount>0), history: [], createdAt, invoiceNo: (invoiceNo||''), invoiceDate: parseDateCN(invoiceDate||''), invoiceAmount, source: 'import', confirmed:false };
rec.history.push({ date: dt, user: creator, kind: '创建', amount, partner, doc, notes });
if (invoiceNo || invoiceAmount) {
rec.history.push({ date: dt, user: creator, kind: '发票', amount: invoiceAmount, partner, doc, notes: `发票号:${invoiceNo||'-'} 发票日期:${rec.invoiceDate||'-'}` });
}
if (paid > 0) {
rec.history.push({ date: dt, user: creator, kind: '银行付款', amount: paid, partner, doc, notes: '' });
}
return rec;
}
function previewImport(rows) {
const headerRow = rows[0] ? rows[0].map(x => String(x).trim()) : [];
const hasHeader = headerRow.some(x => /类型|款项|往来单位|凭证|发票|日期/.test(x));
const dataRows = hasHeader ? rows.slice(1) : rows;
function idxOf(names) {
for (const n of names) {
const i = headerRow.findIndex(h => h && h.includes(n));
if (i >= 0) return i;
}
return -1;
}
const idx = {
type: hasHeader ? idxOf(['应收应付']) : 0,
partner: hasHeader ? idxOf(['往来单位']) : 1,
doc: hasHeader ? idxOf(['单据凭证号']) : 2,
date: hasHeader ? idxOf(['出单日期']) : 3,
amount: hasHeader ? idxOf(['订单金额']) : 4,
invoiceNo: hasHeader ? idxOf(['发票号']) : 5,
invoiceDate: hasHeader ? idxOf(['发票日期']) : 6,
invoiceAmount: hasHeader ? idxOf(['发票金额']) : 7,
trustDays: hasHeader ? idxOf(['信任天数']) : 8,
notes: hasHeader ? idxOf(['备注']) : 9,
sales: hasHeader ? idxOf(['业务员']) : 10,
paid: hasHeader ? idxOf(['支付情况','已支付','支付金额','支付']) : -1,
};
const selectedType = payType?.value || '';
const selectedFlag = /应付/.test(selectedType) ? '应付' : '应收';
payImportParsed = [];
payImportInvalidCount = 0;
payImportRows.innerHTML = '';
let cntTypeMismatch = 0;
let cntUpdatedEst = 0;
let cntNewCustomersEst = 0;
let cntNewMerchantsEst = 0;
let parsedVisibleCount = 0;
let requiredMissingCount = 0;
const existCustomers = new Set((contactsData.customers||[]).map(x => String(x.name||'').trim()));
const existMerchants = new Set((contactsData.merchants||[]).map(x => String(x.name||'').trim()));
dataRows.forEach(row => {
let rowType = String(row[idx.type] ?? '').trim();
const originalTypeEmpty = !rowType;
if (!rowType) rowType = selectedFlag;
const partnerName = String(row[idx.partner] ?? '').trim();
const docVal = String(row[idx.doc] ?? '').trim();
const amtVal = String(row[idx.amount] ?? '').trim();
if (![rowType, partnerName, docVal, amtVal].some(v => String(v||'').trim())) return;
const colsX = [
row[idx.type] ?? '',
partnerName,
row[idx.doc] ?? '',
row[idx.date] ?? '',
row[idx.amount] ?? '',
row[idx.invoiceNo] ?? '',
row[idx.invoiceDate] ?? '',
row[idx.invoiceAmount] ?? '',
row[idx.trustDays] ?? '',
row[idx.notes] ?? '',
row[idx.sales] ?? '',
(idx.paid >= 0 ? row[idx.paid] : ''),
];
const tr = document.createElement('tr');
let isErrorRow = false;
let errorReason = '';
if (rowType && !rowType.includes(selectedFlag)) { isErrorRow = true; errorReason = '性质不匹配:页面与A列不一致'; cntTypeMismatch++; colsX[0] = selectedFlag; }
const missType = originalTypeEmpty;
const missPartner = !partnerName;
const missDoc = !docVal;
const missAmount = !amtVal;
if (missPartner) { isErrorRow = true; errorReason = errorReason ? (errorReason + ';店名为空') : '店名为空'; payImportInvalidCount++; }
const isRequiredMissing = missType || missPartner || missDoc || missAmount;
if (isRequiredMissing) requiredMissingCount++;
const previewCells = [
row[idx.type] ?? '',
partnerName,
row[idx.doc] ?? '',
row[idx.date] ?? '',
row[idx.amount] ?? '',
row[idx.invoiceNo] ?? '',
row[idx.invoiceDate] ?? '',
row[idx.invoiceAmount] ?? '',
row[idx.trustDays] ?? '',
(idx.paid >= 0 ? row[idx.paid] : ''),
row[idx.notes] ?? '',
row[idx.sales] ?? '',
];
previewCells.forEach((v, ci) => {
const td = document.createElement('td');
td.textContent = String(v ?? '');
const needHighlight = (ci === 0 && missType) || (ci === 1 && missPartner) || (ci === 2 && missDoc) || (ci === 4 && missAmount);
if (needHighlight || (isErrorRow && (ci === 0 || ci === 1))) { td.className = 'error-cell'; td.title = errorReason || '必填项为空'; }
tr.appendChild(td);
});
payImportRows.appendChild(tr);
if (isRequiredMissing) return;
parsedVisibleCount++;
const rec = rowToRecord(colsX);
payImportParsed.push(rec);
const existsRec = findExistingPayRecord(rec);
if (existsRec) cntUpdatedEst++;
const isRecv = /应收/.test(rec.type);
if (isRecv) {
if (!existCustomers.has(rec.partner.trim())) cntNewCustomersEst++;
} else {
if (!existMerchants.has(rec.partner.trim())) cntNewMerchantsEst++;
}
});
const summary = [
`已解析 ${parsedVisibleCount} 条`,
(cntUpdatedEst ? `预计覆盖 ${cntUpdatedEst} 条` : ''),
((cntNewCustomersEst+cntNewMerchantsEst) ? `预计新增客户/商家 ${cntNewCustomersEst+cntNewMerchantsEst} 条` : ''),
(payImportInvalidCount ? `已跳过店名为空 ${payImportInvalidCount} 条` : ''),
(cntTypeMismatch ? `性质不匹配 ${cntTypeMismatch} 条(A列与页面不一致,不导入)` : ''),
(requiredMissingCount ? `存在必填项为空 ${requiredMissingCount} 条(应收应付/往来单位/凭证号/订单金额),请修正后再入库` : ''),
].filter(Boolean).join(' | ');
payImportSummary.textContent = summary;
if (payImportHint) {
if (requiredMissingCount) payImportHint.textContent = `存在必填项为空 ${requiredMissingCount} 条,无法入库`;
else payImportHint.textContent = `已选择 ${parsedVisibleCount} 条,点击下方提交完成入库`;
}
payImportRequiredMissingCount = requiredMissingCount;
if (payImportCommit) payImportCommit.disabled = false;
}
payImportFile?.addEventListener('change', () => {
const file = payImportFile?.files?.[0];
if (!file) { return; }
const name = (file.name || '').toLowerCase();
const reader = new FileReader();
try { payImportModal.style.display = 'flex'; setImportModalWidth(); } catch {}
if (name.endsWith('.csv')) {
reader.onload = () => { try { previewImport(parseCSV(reader.result)); setImportModalWidth(); } catch {} };
reader.readAsText(file, 'utf-8');
} else if (name.endsWith('.xls')) {
reader.onload = () => { try { previewImport(parseXLS(reader.result)); setImportModalWidth(); } catch {} };
reader.readAsText(file, 'utf-8');
} else if (name.endsWith('.xlsx')) {
reader.onload = () => { try { previewImport(parseXLSX(reader.result)); setImportModalWidth(); } catch {} };
reader.readAsArrayBuffer(file);
} else {
alert('不支持的文件类型');
}
});
function setImportModalWidth() {
const card = document.querySelector('#page-payables .card');
const modalBox = document.querySelector('#pay-import-modal .modal');
if (card && modalBox) {
const w = Math.floor(card.getBoundingClientRect().width);
modalBox.style.width = w + 'px';
modalBox.style.maxWidth = 'none';
}
}
payImportCancel?.addEventListener('click', () => { payImportModal.style.display = 'none'; });
function ensureContactForPartner(name, type, salesName) {
const pname = String(name || '').trim();
if (!pname) return;
const tab = /应付/.test(type) ? 'merchants' : 'customers';
const ownerLabel = tab === 'merchants' ? '商家' : '客户';
const existsInTab = (contactsData[tab] || []).some(x => (String(x.name||'').trim()) === pname);
if (existsInTab) return;
const now = new Date();
const created = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
contactsData[tab].push({ name: pname, contact:'', phone:'', city:'', remark:'', owner: ownerLabel, created, company:'', code:'', country:'', address:'', zip:'', sales: (salesName||'').trim() });
}
function findExistingPayRecord(rec) {
const p = String(rec.partner || '').trim();
const d = String(rec.doc || '').trim();
return payRecords.find(r =>
r.type === rec.type &&
String(r.partner||'').trim() === p &&
String(r.doc||'').trim() === d
);
}
function mergePayRecord(target, src) {
const now = new Date();
const dateTime = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
const user = (getAuthUser()?.name) || '';
target.sales = src.sales || target.sales || '';
if (!isNaN(src.amount)) target.amount = src.amount;
if (!isNaN(src.paid)) {
const newPaid = src.paid;
target.paid = Math.min(newPaid, target.amount || 0);
}
if (!isNaN(src.trustDays)) target.trustDays = src.trustDays;
target.notes = src.notes || target.notes || '';
target.date = src.date || target.date;
target.invoiceNo = src.invoiceNo || target.invoiceNo || '';
target.invoiceDate = src.invoiceDate || target.invoiceDate || '';
if (!isNaN(src.invoiceAmount)) target.invoiceAmount = src.invoiceAmount;
target.settled = (target.paid || 0) >= (target.amount || 0) && (target.amount || 0) > 0;
target.history = target.history || [];
target.history.push({ date: dateTime, user, kind: '导入覆盖', amount: src.amount, partner: target.partner, doc: target.doc, notes: '批量导入覆盖现有记录' });
if (src.invoiceNo || src.invoiceAmount) {
target.history.push({ date: dateTime, user, kind: '发票', amount: src.invoiceAmount, partner: target.partner, doc: target.doc, notes: `发票号:${src.invoiceNo||'-'} 发票日期:${src.invoiceDate||'-'}` });
}
if (src.paid) {
target.history.push({ date: dateTime, user, kind: '银行付款', amount: src.paid, partner: target.partner, doc: target.doc, notes: '' });
}
}
let payImportCommitLock = false;
const handlePayImportCommit = async (e) => {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (payImportCommitLock) return;
payImportCommitLock = true;
if (payImportRequiredMissingCount) { alert(`存在必填项为空 ${payImportRequiredMissingCount} 条,无法入库`); payImportCommitLock = false; return; }
let createdCustomers = 0, createdMerchants = 0;
let covered = 0, inserted = 0;
const batchAt = Date.now();
let batchOrder = 0;
const beforeCustomers = new Set((contactsData.customers||[]).map(x => String(x.name||'').trim()));
const beforeMerchants = new Set((contactsData.merchants||[]).map(x => String(x.name||'').trim()));
payImportParsed.forEach(rec => {
rec.batchAt = batchAt;
rec.batchOrder = batchOrder++;
rec.createdAt = batchAt;
const existedBefore = [...contactsData.customers, ...contactsData.merchants, ...contactsData.others]
.some(x => (x.name||'') === (rec.partner||''));
ensureContactForPartner(rec.partner, rec.type, rec.sales);
if (!existedBefore && (rec.partner||'').trim()) {
if (/应付/.test(rec.type)) createdMerchants++; else createdCustomers++;
}
const hasKey = (rec.partner||'').trim() && (rec.doc||'').trim();
if (hasKey) {
const ex = findExistingPayRecord(rec);
if (ex) { mergePayRecord(ex, rec); ex.batchAt = batchAt; ex.batchOrder = rec.batchOrder; ex.createdAt = batchAt; ex.source = 'import'; covered++; return; }
}
payRecords.push(rec); inserted++;
});
payImportParsed = [];
payImportInvalidCount = 0;
payImportModal.style.display = 'none';
payPage = 1;
renderPayables();
renderContacts?.();
saveJSON('payRecords', payRecords);
saveJSON('contactsData', contactsData);
apiFetchJSON('/api/payables/import', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ records: payRecords.filter(r => r.batchAt === batchAt) }) })
.then(() => loadPayablesFromServer())
.catch(() => {});
const totalChanged = inserted + covered + createdCustomers + createdMerchants;
if (totalChanged > 0) {
alert(`导入完成:新增记录 ${inserted} 条,覆盖更新 ${covered} 条,新增客户 ${createdCustomers} 条,新增商家 ${createdMerchants} 条。`);
}
payImportCommitLock = false;
};
if (payImportCommit) {
payImportCommit.addEventListener('click', handlePayImportCommit);
payImportCommit.onclick = handlePayImportCommit;
}
let contactsTab = 'customers';
const contactsRows = document.getElementById('contacts-rows');
const contactsSearch = document.getElementById('contacts-search');
const confirmModal = document.getElementById('confirm-modal');
const confirmCancel = document.getElementById('confirm-cancel');
const confirmOk = document.getElementById('confirm-ok');
const partnerOrdersRows = document.getElementById('partner-orders-rows');
const partnerOrdersHead = document.getElementById('partner-orders-head');
let contactsPage = 1;
const contactsPageSize = 100;
let pendingDeleteIndex = null;
let pendingDeleteTab = null;
function partnerTotal(name) {
let sum = 0;
payRecords.forEach(r => { if ((r.partner||'') === name) sum += r.amount || 0; });
return sum.toFixed(2);
}
function partnerArrears(name, ownerLabel) {
let sum = 0;
payRecords.forEach(r => {
if ((r.partner||'') !== name) return;
const isRecv = /应收/.test(r.type||'');
const isPay = /应付/.test(r.type||'');
if (ownerLabel === '客户' && !isRecv) return;
if (ownerLabel === '商家' && !isPay) return;
const arrears = Math.max(0, (r.amount||0) - (r.paid||0));
sum += arrears;
});
return sum.toFixed(2);
}
function formatDateTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const dd = String(d.getDate()).padStart(2,'0');
const hh = String(d.getHours()).padStart(2,'0');
const mm = String(d.getMinutes()).padStart(2,'0');
return `${y}-${m}-${dd} ${hh}:${mm}`;
}
function openAmountHistory(partnerName, ownerLabel) {
const modal = document.getElementById('amount-history-modal');
const head = document.getElementById('amount-history-head');
const rowsEl = document.getElementById('amount-history-rows');
const list = [];
payRecords.forEach(r => {
if ((r.partner||'') !== partnerName) return;
const ts = r.createdAt || (r.date ? Date.parse(r.date) : 0);
const amt = Number(r.amount||0);
const isRecv = /应收/.test(r.type||'');
const change = isRecv ? amt : -amt;
const label = isRecv ? `应收账款记录 + ${amt.toFixed(2)}` : `应付账款记录 - ${amt.toFixed(2)}`;
const user = (r.history && r.history[0] && r.history[0].user) || (getAuthUser()?.name || '');
list.push({ ts, doc: r.doc || '', change, label, user });
});
records.forEach(rec => {
if ((rec.client||'') !== partnerName) return;
if (rec.type === '收入') {
const ts = rec.createdAt || (rec.dateTime ? Date.parse(rec.dateTime) : (rec.date ? Date.parse(rec.date) : 0));
const amt = Number(rec.amount||0);
const change = -amt;
const label = `收支记账收入 - ${amt.toFixed(2)}`;
const user = getAuthUser()?.name || '';
list.push({ ts, doc: rec.doc || '', change, label, user });
}
});
list.sort((a,b) => a.ts - b.ts);
let cum = 0;
const withCum = list.map(x => { cum += x.change; return { ...x, cum }; });
rowsEl.innerHTML = '';
const render = [...withCum].reverse();
render.forEach((x, idx) => {
const tr = document.createElement('tr');
const seq = document.createElement('td'); seq.textContent = String(render.length - idx); tr.appendChild(seq);
const dt = document.createElement('td'); dt.textContent = formatDateTime(x.ts); tr.appendChild(dt);
const doc = document.createElement('td'); doc.textContent = x.doc || '-'; tr.appendChild(doc);
const change = document.createElement('td'); change.textContent = x.label; tr.appendChild(change);
const arrears = document.createElement('td'); arrears.textContent = Number.isFinite(x.cum) ? x.cum.toFixed(2) : '-'; tr.appendChild(arrears);
const user = document.createElement('td'); user.textContent = x.user || '-'; tr.appendChild(user);
rowsEl.appendChild(tr);
});
head.textContent = `往来单位:${partnerName}`;
modal.style.display = 'flex';
document.getElementById('amount-history-close')?.addEventListener('click', () => { modal.style.display = 'none'; });
}
function openPartnerOrders(name) {
const modal = document.getElementById('partner-orders-modal');
const head = document.getElementById('partner-orders-head');
const rowsEl = document.getElementById('partner-orders-rows');
head.textContent = '往来单位:' + (name || '');
const list = payRecords.filter(r => (r.partner || '') === (name || ''));
rowsEl.innerHTML = '';
list.forEach(r => {
const tr = document.createElement('tr');
const paid = r.paid || 0;
const arrears = Math.max(0, (r.amount || 0) - paid);
[r.type, r.partner || '', r.doc || '', (r.amount||0).toFixed(2), paid.toFixed(2), arrears.toFixed(2), r.date || ''].forEach((v,i) => {
const td = document.createElement('td');
td.textContent = String(v);
if (i===5 && arrears>0) td.style.color = '#ef4444';
tr.appendChild(td);
});
rowsEl.appendChild(tr);
});
modal.style.display = 'flex';
document.getElementById('partner-orders-close')?.addEventListener('click', () => { modal.style.display = 'none'; });
}
async function renderContacts() {
const list = contactsData[contactsTab] || [];
const key = (contactsSearch?.value || '').trim();
await apiContactsList(contactsTab, key, contactsPage, contactsPageSize);
await seedDefaultContacts(contactsTab);
await apiContactsList(contactsTab, key, contactsPage, contactsPageSize);
const fresh = contactsData[contactsTab] || [];
const filtered = fresh.filter(x => {
if (!key) return true;
const k = String(key).toLowerCase();
return [x.name, x.company, x.code, x.contact, x.phone, x.sales]
.some(v => String(v||'').toLowerCase().includes(k));
});
const ordered = filtered.slice(); // Removed reverse()
const total = ordered.length;
const totalPages = Math.max(1, Math.ceil(total / contactsPageSize));
if (contactsPage > totalPages) contactsPage = totalPages;
const startIdx = (contactsPage - 1) * contactsPageSize;
const data = ordered.slice(startIdx, startIdx + contactsPageSize);
const gp = document.getElementById('global-pager');
const sel = document.getElementById('ct-sales');
if (sel) {
const prev = sel.value;
sel.innerHTML = '请选择业务员 ';
(salesData || []).forEach(s => {
const opt = document.createElement('option'); opt.value = s.name; opt.textContent = s.name;
sel.appendChild(opt);
});
if ([...sel.options].some(o => o.value === prev)) sel.value = prev;
}
contactsRows.innerHTML = '';
for (let i = 0; i < data.length; i++) {
const r = data[i];
const tr = document.createElement('tr');
// Serial Number Calculation: Total - Global Index
// Global Index = startIdx + i
// Example: Total 100. Page 1 (startIdx 0). Item 0 -> 100 - 0 = 100.
const serialNum = total - (startIdx + i);
const tdSerial = document.createElement('td');
tdSerial.textContent = serialNum;
tdSerial.style.color = '#94a3b8';
tdSerial.style.fontWeight = '500';
tr.appendChild(tdSerial);
const ops = document.createElement('td');
ops.className = 'actions';
ops.innerHTML = '编辑 删除 订单记录 金额记录 ';
const cells = [r.name, r.company || '', r.code || '', r.contact, r.phone, r.city, r.remark || '', r.sales || '-', partnerTotal(r.name), partnerArrears(r.name, r.owner || ''), r.created];
cells.forEach((v, idx) => {
const td = document.createElement('td');
if (idx === 9) {
const num = parseFloat(String(v));
if (isFinite(num) && num <= 0) {
td.textContent = '-';
} else {
td.textContent = Number.isFinite(num) ? num.toFixed(2) : String(v||'');
td.style.color = '#ef4444';
}
} else {
td.textContent = v;
}
tr.appendChild(td);
});
tr.appendChild(ops);
contactsRows.appendChild(tr);
const del = ops.querySelector('.link-red');
del.addEventListener('click', e => {
e.preventDefault();
const name = r.name || '';
const ownerLabel = r.owner || '';
const inUse = payRecords.some(x => (x.partner||'') === name) || records.some(x => (x.client||'') === name) || (parseFloat(partnerArrears(name, ownerLabel)) > 0);
if (inUse) { alert('该客户正在使用中,无法被删除'); return; }
pendingDeleteIndex = contactsData[contactsTab].indexOf(r);
pendingDeleteTab = contactsTab;
confirmModal.style.display = 'flex';
});
const edit = ops.querySelector('.link-blue');
edit.addEventListener('click', e => {
e.preventDefault();
const i = contactsData[contactsTab].indexOf(r);
if (i>=0) {
editingIndex = i;
editingTab = contactsTab;
fillContactsForm(r);
if (ctModalTitle) ctModalTitle.textContent = '编辑' + (contactsTab==='customers'?'客户':contactsTab==='merchants'?'商家':'往来单位');
if (ctSubmitBtn) ctSubmitBtn.textContent = '保存';
if (ctModal) ctModal.style.display = 'flex';
}
});
const ordersLink = ops.querySelector('.link-green');
ordersLink.addEventListener('click', e => {
e.preventDefault();
const n = encodeURIComponent(r.name || '');
location.hash = '#partner-orders:' + n;
});
const amountLink = ops.querySelector('.link-orange');
amountLink.addEventListener('click', e => {
e.preventDefault();
openAmountHistory(r.name, r.owner || '');
});
}
const pager = document.getElementById('global-pager-controls');
const isContactsVisible = (document.getElementById('page-contacts')?.style.display === 'block');
if (pager && isContactsVisible) {
pager.innerHTML = '';
const makeBtn = (label, page, disabled=false, active=false) => {
const b = document.createElement('a');
b.href = '#'; b.textContent = label;
b.style.padding = '4px 8px';
b.style.border = '1px solid #334155';
b.style.borderRadius = '4px';
b.style.color = active ? '#000' : '#cbd5e1';
b.style.background = active ? '#cbd5e1' : 'transparent';
b.style.pointerEvents = disabled ? 'none' : 'auto';
b.style.opacity = disabled ? '0.4' : '1';
b.addEventListener('click', e => { e.preventDefault(); contactsPage = page; renderContacts(); });
pager.appendChild(b);
};
makeBtn('«', Math.max(1, contactsPage-1), contactsPage<=1);
const maxButtons = 9;
let start = Math.max(1, contactsPage - Math.floor(maxButtons/2));
let end = Math.min(totalPages, start + maxButtons - 1);
start = Math.max(1, end - maxButtons + 1);
for (let p = start; p <= end; p++) makeBtn(String(p), p, false, p===contactsPage);
makeBtn('»', Math.min(totalPages, contactsPage+1), contactsPage>=totalPages);
}
const infoEl = document.getElementById('pay-footer-info');
if (infoEl) {
const todayStr = (() => { const d = new Date(); const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0'); return `${y}-${m}-${dd}`; })();
const toTs = x => { const t = Date.parse(x.created || ''); return Number.isFinite(t) ? t : 0; };
const listAll = list || [];
const totalCount = listAll.length;
const todayCount = listAll.filter(x => {
const t = toTs(x); if (!t) return false;
const d = new Date(t); const y=d.getFullYear(); const m=String(d.getMonth()+1).padStart(2,'0'); const dd=String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${dd}` === todayStr;
}).length;
const latestTs = Math.max(0, ...(listAll.map(toTs)));
const latestCount = latestTs ? listAll.filter(x => toTs(x) === latestTs).length : (totalCount ? 1 : 0);
const mk = (text) => { const s = document.createElement('span'); s.className = 'info-pill'; s.textContent = text; return s; };
infoEl.innerHTML = '';
infoEl.appendChild(mk(`共 ${totalCount} 条记录`));
infoEl.appendChild(mk(`今日上传 ${todayCount} 条`));
infoEl.appendChild(mk(`最后次上传 ${latestCount} 条`));
}
}
confirmCancel?.addEventListener('click', () => {
confirmModal.style.display = 'none';
pendingDeleteIndex = null;
pendingDeleteTab = null;
});
confirmOk?.addEventListener('click', async () => {
if (pendingDeleteIndex !== null && pendingDeleteTab) {
const target = contactsData[pendingDeleteTab][pendingDeleteIndex];
const name = target?.name || '';
const ownerLabel = target?.owner || '';
const inUse = payRecords.some(x => (x.partner||'') === name) || records.some(x => (x.client||'') === name) || (parseFloat(partnerArrears(name, ownerLabel)) > 0);
if (inUse) {
alert('该客户正在使用中,无法被删除');
} else {
const ok = await apiContactsDeleteByName(ownerLabel || (pendingDeleteTab==='customers'?'客户':pendingDeleteTab==='merchants'?'商家':'其它'), name);
if (ok) contactsData[pendingDeleteTab].splice(pendingDeleteIndex,1);
await apiContactsList(pendingDeleteTab, contactsSearch?.value || '', contactsPage, contactsPageSize);
renderContacts();
saveJSON('contactsData', contactsData);
}
}
confirmModal.style.display = 'none';
pendingDeleteIndex = null;
pendingDeleteTab = null;
});
document.querySelectorAll('.tab[data-tab]').forEach(b => {
b.addEventListener('click', () => {
document.querySelectorAll('.tab').forEach(x => x.classList.remove('active'));
b.classList.add('active');
contactsTab = b.getAttribute('data-tab');
contactsPage = 1;
renderContacts();
});
});
contactsSearch?.addEventListener('input', () => { contactsPage = 1; renderContacts(); });
const ctForm = document.getElementById('contacts-form');
const ctSubmitBtn = document.getElementById('contacts-submit');
const ctSubmitTop = document.getElementById('contacts-submit-top');
const ctModal = document.getElementById('contacts-modal');
const ctModalClose = document.getElementById('ct-modal-close');
const ctModalTitle = document.getElementById('ct-modal-title');
let tempContactNotes = [];
let editingNoteId = null;
if (ctModalClose) ctModalClose.addEventListener('click', () => { if (ctModal) ctModal.style.display = 'none'; });
let editingIndex = null;
let editingTab = null;
function fillContactsForm(r) {
tempContactNotes = [];
editingNoteId = null;
document.getElementById('ct-id').value = r.id || '';
document.getElementById('ct-name').value = r.name || '';
document.getElementById('ct-company').value = r.company || '';
document.getElementById('ct-code').value = r.code || '';
document.getElementById('ct-contact').value = r.contact || '';
document.getElementById('ct-phone').value = r.phone || '';
document.getElementById('ct-country').value = r.country || '';
document.getElementById('ct-address').value = r.address || '';
document.getElementById('ct-zip').value = r.zip || '';
document.getElementById('ct-city').value = r.city || '';
document.getElementById('ct-remark').value = r.remark || '';
const ctSales = document.getElementById('ct-sales'); if (ctSales) ctSales.value = r.sales || '';
const ctPrice = document.getElementById('ct-price'); if (ctPrice) ctPrice.value = r.use_price || 'price1';
// Default is_iva to true if undefined
const ctIva = document.getElementById('ct-iva');
if (ctIva) {
if (r.is_iva === false || String(r.is_iva) === 'false') {
ctIva.value = 'false';
} else {
ctIva.value = 'true';
}
}
loadContactNotes(r.id);
}
function clearContactsForm() {
tempContactNotes = [];
editingNoteId = null;
document.getElementById('ct-id').value = '';
['ct-name','ct-company','ct-code','ct-contact','ct-phone','ct-country','ct-address','ct-zip','ct-city','ct-remark'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
const ctSales = document.getElementById('ct-sales'); if (ctSales) ctSales.value = '';
const ctPrice = document.getElementById('ct-price'); if (ctPrice) ctPrice.value = 'price1';
const ctIva = document.getElementById('ct-iva'); if (ctIva) ctIva.value = 'true';
document.querySelectorAll('.group.error').forEach(el => el.classList.remove('error'));
document.querySelectorAll('.shake').forEach(el => el.classList.remove('shake'));
loadContactNotes(null);
document.getElementById('ct-note-input-area').style.display = 'none';
}
ctSubmitTop?.addEventListener('click', () => {
clearContactsForm();
editingIndex = null;
editingTab = null;
if (ctModalTitle) ctModalTitle.textContent = '新增' + (contactsTab==='customers'?'客户':contactsTab==='merchants'?'商家':'往来单位');
if (ctSubmitBtn) ctSubmitBtn.textContent = '保存';
if (ctModal) ctModal.style.display = 'flex';
});
ctForm?.addEventListener('submit', async e => {
e.preventDefault();
// Clear previous errors
document.querySelectorAll('.group.error').forEach(el => el.classList.remove('error'));
document.querySelectorAll('.shake').forEach(el => el.classList.remove('shake'));
const name = document.getElementById('ct-name').value.trim();
const company = document.getElementById('ct-company').value.trim();
const code = document.getElementById('ct-code').value.trim();
const contact = document.getElementById('ct-contact').value.trim();
const phone = document.getElementById('ct-phone').value.trim();
const country = document.getElementById('ct-country').value.trim();
const address = document.getElementById('ct-address').value.trim();
const zip = document.getElementById('ct-zip').value.trim();
const city = document.getElementById('ct-city').value.trim();
const remark = document.getElementById('ct-remark').value.trim();
const sales = (document.getElementById('ct-sales')?.value || '').trim();
const use_price = (document.getElementById('ct-price')?.value || 'price1').trim();
const is_iva = (document.getElementById('ct-iva')?.value === 'true');
// Validation
let isValid = true;
const requiredFields = [
{ id: 'ct-name', val: name },
{ id: 'ct-company', val: company },
{ id: 'ct-code', val: code },
{ id: 'ct-phone', val: phone },
{ id: 'ct-address', val: address },
{ id: 'ct-zip', val: zip },
{ id: 'ct-city', val: city },
{ id: 'ct-price', val: use_price },
{ id: 'ct-iva', val: String(is_iva) }
];
requiredFields.forEach(f => {
if (!f.val) {
isValid = false;
const el = document.getElementById(f.id);
if (el) {
const group = el.closest('.group');
if (group) {
group.classList.add('error');
// Trigger shake reflow
void group.offsetWidth;
group.classList.add('shake');
}
}
}
});
if (!isValid) return;
if (editingIndex !== null) {
const target = contactsData[editingTab][editingIndex];
target.name = name;
target.company = company;
target.code = code;
target.contact = contact;
target.phone = phone;
target.country = country;
target.address = address;
target.zip = zip;
target.city = city;
target.remark = remark;
target.sales = sales || '';
target.use_price = use_price;
target.is_iva = is_iva;
editingIndex = null;
editingTab = null;
ctSubmitBtn.textContent = '保存';
await apiContactsUpdateByName({ name, company, code, contact, phone, city, remark, owner: contactsTab==='customers'?'客户':contactsTab==='merchants'?'商家':'其它', country, address, zip, sales: sales||'', use_price, is_iva });
} else {
const now = new Date();
const created = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
contactsData[contactsTab].push({ name, contact, phone, city, remark, owner: contactsTab==='customers'?'客户':contactsTab==='merchants'?'商家':'其它', created, company, code, country, address, zip, sales: sales || '', use_price, is_iva });
const newId = await apiContactsCreate({ name, contact, phone, city, remark, owner: contactsTab==='customers'?'客户':contactsTab==='merchants'?'商家':'其它', created, company, code, country, address, zip, sales: sales || '', use_price, is_iva });
if (newId && tempContactNotes.length > 0) {
for (const n of tempContactNotes) {
await apiFetchJSON(`/api/contacts/${newId}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note: n.note })
});
}
}
}
clearContactsForm();
if (ctModal) ctModal.style.display = 'none';
await apiContactsList(contactsTab, contactsSearch?.value || '', contactsPage, contactsPageSize);
renderContacts();
saveJSON('contactsData', contactsData);
});
const catList = document.getElementById('cat-list');
const addCatBtn = document.getElementById('add-cat');
const categoriesData = [
{ name:'收入', children:['服务收入(现金)','服务收入(银行)','银行储蓄','现金借贷','订单收入','其它收入'] },
{ name:'开支', children:['现金开支','员工工资','出差补贴','人工开支','其它开支'] }
];
function renderCats() {
catList.innerHTML = '';
categoriesData.forEach((cat, idx) => {
const panel = document.createElement('div');
panel.className = 'cat-panel';
const header = document.createElement('div');
header.className = 'cat-header';
const title = document.createElement('div');
title.className = 'cat-title';
title.textContent = '— ' + cat.name;
const actions = document.createElement('div');
actions.className = 'cat-actions';
const addBtn = document.createElement('button'); addBtn.className = 'btn-icon btn-green'; addBtn.textContent = '+'; addBtn.title = '新增二级类目';
const editBtn = document.createElement('button'); editBtn.className = 'btn-icon btn-blue'; editBtn.textContent = '✎'; editBtn.title = '编辑一级类目';
const delBlocked = (cat.name === '收入' || cat.name === '开支');
actions.append(addBtn, editBtn);
let delBtn = null;
if (!delBlocked) {
delBtn = document.createElement('button'); delBtn.className = 'btn-icon btn-red'; delBtn.textContent = '🗑'; delBtn.title = '删除一级类目';
actions.append(delBtn);
}
header.append(title, actions);
const items = document.createElement('div');
items.className = 'cat-items';
cat.children.forEach((name, j) => {
const row = document.createElement('div');
row.className = 'cat-item';
const nm = document.createElement('div'); nm.className = 'cat-name'; nm.textContent = name;
const ops = document.createElement('div'); ops.className = 'cat-actions';
const e = document.createElement('button'); e.className = 'btn-icon btn-blue'; e.textContent = '✎'; e.title = '编辑';
const d = document.createElement('button'); d.className = 'btn-icon btn-red'; d.textContent = '🗑'; d.title = '删除';
ops.append(e, d);
row.append(nm, ops);
items.appendChild(row);
e.addEventListener('click', () => {
const val = prompt('编辑名称', name);
if (val && val.trim()) { categoriesData[idx].children[j] = val.trim(); renderCats(); saveJSON('categoriesData', categoriesData); apiCategoriesSave(); }
});
d.addEventListener('click', () => {
categoriesData[idx].children.splice(j,1);
renderCats();
saveJSON('categoriesData', categoriesData);
apiCategoriesSave();
});
});
panel.append(header, items);
catList.appendChild(panel);
addBtn.addEventListener('click', () => {
const val = prompt('新增二级类目名称');
if (val && val.trim()) { categoriesData[idx].children.push(val.trim()); renderCats(); saveJSON('categoriesData', categoriesData); apiCategoriesSave(); }
});
editBtn.addEventListener('click', () => {
const val = prompt('编辑一级类目名称', cat.name);
if (val && val.trim()) { categoriesData[idx].name = val.trim(); renderCats(); saveJSON('categoriesData', categoriesData); apiCategoriesSave(); }
});
if (delBtn) {
delBtn.addEventListener('click', () => {
if (confirm('确定删除该一级类目?')) { categoriesData.splice(idx,1); renderCats(); saveJSON('categoriesData', categoriesData); apiCategoriesSave(); }
});
}
});
refreshLedgerTypeOptions();
setCategories();
}
addCatBtn?.addEventListener('click', () => {
const val = prompt('新增一级类目名称');
if (val && val.trim()) { categoriesData.push({ name: val.trim(), children: [] }); renderCats(); saveJSON('categoriesData', categoriesData); apiCategoriesSave(); }
});
const roleRows = document.getElementById('role-rows');
const roleSearch = document.getElementById('role-search');
const rolePageSize = document.getElementById('role-page-size');
const rolePrev = document.getElementById('role-prev');
const roleNext = document.getElementById('role-next');
const rolePageEl = document.getElementById('role-page');
const roleSummary = document.getElementById('role-summary');
const roleCreate = document.getElementById('role-create');
const roleModal = document.getElementById('role-modal');
const roleForm = document.getElementById('role-form');
const roleCancel = document.getElementById('role-cancel');
const rolesData = [];
const permSchema = {
home: { label:'首页', actions:{ view:'进入' } },
ledger: { label:'收支记账', actions:{ view:'进入', create:'新增', edit:'编辑', delete:'删除', export:'导出' } },
payables: { label:'应收/应付账款', actions:{ view:'进入', create:'新增', edit:'编辑', delete:'删除', import:'批量导入', export:'导出' } },
contacts: { label:'往来单位', actions:{ view:'进入', create:'新增', edit:'编辑', delete:'删除' } },
sales_order: { label:'出单系统', actions:{ view:'进入' } },
sales_invoice: { label:'发票', actions:{ view:'进入' } },
sales_products: { label:'商品列表', actions:{ view:'进入' } },
categories: { label:'分类管理', actions:{ view:'进入', manage:'维护类目' } },
accounts: { label:'账户管理', actions:{ view:'进入', create_account:'新增账户', edit_account:'编辑账户', delete_account:'删除账户', init_account:'初始金额' } },
user_accounts: { label:'帐号管理', actions:{ view:'进入', create_user:'创建账号', reset_password:'重置密码', enable_user:'启用/停用' } },
role_accounts: { label:'角色管理', actions:{ view:'进入', create_role:'创建角色', edit_role:'编辑角色', delete_role:'删除角色' } },
sales_accounts: { label:'业务员管理', actions:{ view:'进入', create_sales:'新增', edit_sales:'编辑', delete_sales:'删除' } }
};
function allTruePerms() {
const p = {}; Object.keys(permSchema).forEach(m => { p[m]={}; Object.keys(permSchema[m].actions).forEach(a => p[m][a]=true); }); return p;
}
function getRoleByName(name) { return rolesData.find(r => r.name === name); }
function currentUserRole() {
const u = getAuthUser();
if (!u) return null;
const roleName = u.role || (u.name==='aaaaaa'?'超级管理员':'');
return getRoleByName(roleName) || null;
}
function currentPerms() {
const r = currentUserRole();
if (!r || r.name==='超级管理员') return allTruePerms();
return r.perms || {};
}
function can(module, action) {
const u = getAuthUser();
const roleName = (u?.role) || getUserRoleName(u?.name || '');
if (roleName === '超级管理员') return true;
const role = rolesData.find(r => r.name === roleName);
const perms = role?.perms || {};
const m = perms[module] || {};
return action ? !!m[action] : !!m.view;
}
const rolePermsModal = document.getElementById('role-perms-modal');
const rolePermsForm = document.getElementById('role-perms-form');
const rolePermsCancel = document.getElementById('role-perms-cancel');
const permsWrap = document.getElementById('perms-wrap');
const rolePermsPageEl = document.getElementById('page-role-perms');
const rolePermsBack = document.getElementById('role-perms-back');
const rolePermsFormPage = document.getElementById('role-perms-form-page');
const permsPageWrap = document.getElementById('perms-page-wrap');
let editingPermRole = null;
function openPermsEditor(role) {
editingPermRole = role;
permsPageWrap.innerHTML = '';
const perms = role.perms || {};
Object.keys(permSchema).forEach(mod => {
const box = document.createElement('div'); box.className='cat-panel';
const top = document.createElement('div'); top.className='cat-header'; top.textContent = permSchema[mod].label;
const cont = document.createElement('div'); cont.style.padding='12px 16px';
Object.entries(permSchema[mod].actions).forEach(([act,label]) => {
const row = document.createElement('label'); row.style.display='block'; row.style.margin='6px 0'; row.style.cursor='pointer';
const cb = document.createElement('input'); cb.type='checkbox'; cb.dataset.mod=mod; cb.dataset.act=act; cb.checked = !!(perms[mod] && perms[mod][act]);
cb.style.marginRight='8px';
row.append(cb, document.createTextNode(label));
cont.appendChild(row);
});
box.append(top, cont);
permsPageWrap.appendChild(box);
});
location.hash = '#role-perms';
}
rolePermsCancel?.addEventListener('click', () => { rolePermsModal.style.display='none'; editingPermRole=null; });
rolePermsForm?.addEventListener('submit', async e => {
e.preventDefault();
if (!editingPermRole) return;
const newPerms = {};
Object.keys(permSchema).forEach(m => { newPerms[m] = {}; });
permsWrap.querySelectorAll('input[type=checkbox]').forEach(cb => {
const mod = cb.dataset.mod; const act = cb.dataset.act;
if (cb.checked) newPerms[mod][act] = true;
});
editingPermRole.perms = newPerms;
await apiRoleUpdatePerms(editingPermRole.id, newPerms);
rolePermsModal.style.display='none';
editingPermRole = null;
renderRoles();
});
rolePermsBack?.addEventListener('click', () => { location.hash = '#role-accounts'; });
rolePermsFormPage?.addEventListener('submit', async e => {
e.preventDefault();
if (!editingPermRole) return;
const newPerms = {};
Object.keys(permSchema).forEach(m => { newPerms[m] = {}; });
permsPageWrap.querySelectorAll('input[type=checkbox]').forEach(cb => {
const mod = cb.dataset.mod; const act = cb.dataset.act;
if (cb.checked) newPerms[mod][act] = true;
});
editingPermRole.perms = newPerms;
await apiRoleUpdatePerms(editingPermRole.id, newPerms);
editingPermRole = null;
location.hash = '#role-accounts';
renderRoles();
});
let rolePage = 1;
async function apiRolesList() {
try {
const list = await apiFetchJSON('/api/roles');
if (Array.isArray(list)) rolesData.splice(0, rolesData.length, ...list.map(r => ({ id:r.id, name:r.name, desc:r.desc||'', created:r.created||'', immutable: !!r.immutable, perms: r.perms || {} })));
} catch {}
}
async function apiRoleCreate(obj) {
try { const r = await apiFetchJSON('/api/roles', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); return r?.id; } catch { return null; }
}
async function apiRoleDelete(id) {
try { const r = await fetch(API_BASE + '/api/roles/'+String(id), { method:'DELETE' }); return r.ok; } catch { return false; }
}
async function apiRoleUpdatePerms(id, perms) {
try { await apiFetchJSON('/api/roles/'+String(id)+'/perms', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ perms }) }); } catch {}
}
function renderRoles() {
const key = (roleSearch?.value || '').trim();
const size = parseInt(rolePageSize?.value || '10', 10);
const data = rolesData.filter(r => {
if (!key) return true;
return [r.name, r.desc, String(r.id)].some(v => (v||'').includes(key));
}).sort((a,b)=>b.id-a.id);
const total = data.length;
const totalPages = Math.max(1, Math.ceil(total/size));
if (rolePage > totalPages) rolePage = totalPages;
const start = (rolePage-1)*size;
const pageData = data.slice(start, start+size);
roleRows.innerHTML = '';
pageData.forEach(r => {
const tr = document.createElement('tr');
[r.id, r.name, r.desc, r.created].forEach(v => { const td = document.createElement('td'); td.textContent = v; tr.appendChild(td); });
const ops = document.createElement('td'); ops.className='actions';
if ((r.name || '') === '超级管理员') {
const tip = document.createElement('span'); tip.className='tag'; tip.textContent='不可编辑/删除';
ops.append(tip);
} else {
const edit = document.createElement('a'); edit.href='#'; edit.textContent='编辑'; edit.className='link-blue';
ops.append(edit);
edit.addEventListener('click', e => { e.preventDefault(); openPermsEditor(r); });
}
tr.appendChild(ops);
roleRows.appendChild(tr);
});
roleSummary.textContent = `显示 ${Math.min(total,start+1)} 到 ${Math.min(total,start+pageData.length)} 项,共 ${total} 项`;
rolePageEl.textContent = String(rolePage);
}
roleSearch?.addEventListener('input', () => { rolePage = 1; renderRoles(); });
rolePageSize?.addEventListener('change', () => { rolePage = 1; renderRoles(); });
rolePrev?.addEventListener('click', () => { if (rolePage > 1) { rolePage--; renderRoles(); } });
roleNext?.addEventListener('click', () => {
const size = parseInt(rolePageSize?.value || '10',10);
const totalPages = Math.max(1, Math.ceil(rolesData.filter(r => (roleSearch?.value||'') ? [r.name,r.desc,String(r.id)].some(v => v.includes(roleSearch.value)) : true).length/size));
if (rolePage < totalPages) { rolePage++; renderRoles(); }
});
roleCreate?.addEventListener('click', () => {
roleModal.style.display = 'flex';
document.getElementById('r-name').value='';
document.getElementById('r-desc').value='';
});
roleCancel?.addEventListener('click', () => { roleModal.style.display = 'none'; });
roleForm?.addEventListener('submit', async e => {
e.preventDefault();
const name = document.getElementById('r-name').value.trim();
const desc = document.getElementById('r-desc').value.trim();
if (!name) return;
const now = new Date();
const created = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
await apiRoleCreate({ name, desc, created, perms:{} });
await apiRolesList();
roleModal.style.display = 'none';
rolePage = 1;
renderRoles();
});
const userRows = document.getElementById('user-rows');
const userSearch = document.getElementById('user-search');
const userPageSize = document.getElementById('user-page-size');
const userPrev = document.getElementById('user-prev');
const userNext = document.getElementById('user-next');
const userPageEl = document.getElementById('user-page');
const userSummary = document.getElementById('user-summary');
const userCreate = document.getElementById('user-create');
const userModal = document.getElementById('user-modal');
const userForm = document.getElementById('user-form');
const userCancel = document.getElementById('user-cancel');
const userAccounts = [];
(function syncInitialPasswords(){
const m = getPwdMap();
userAccounts.forEach(u => { if (u.password) m[u.name] = u.password; });
setPwdMap(m);
})();
let userPage = 1;
function renderUserAccounts() {
const key = (userSearch?.value || '').trim();
const size = parseInt(userPageSize?.value || '10', 10);
const data = userAccounts.filter(u => {
if (!key) return true;
return [u.name, u.role, String(u.id)].some(v => (v||'').includes(key));
}).sort((a,b)=>b.id-a.id);
const total = data.length;
const totalPages = Math.max(1, Math.ceil(total/size));
if (userPage > totalPages) userPage = totalPages;
const start = (userPage-1)*size;
const pageData = data.slice(start, start+size);
if (!userRows) return;
userRows.innerHTML = '';
pageData.forEach(u => {
const tr = document.createElement('tr');
const tdId = document.createElement('td'); tdId.textContent = String(u.id); tr.appendChild(tdId);
const tdName = document.createElement('td'); tdName.textContent = u.name; tr.appendChild(tdName);
const tdRole = document.createElement('td');
if (u.name === 'aaaaaa') {
tdRole.textContent = '超级管理员';
} else {
const sel = document.createElement('select');
rolesData.forEach(r => {
const opt = document.createElement('option'); opt.value = r.name; opt.textContent = r.name;
sel.appendChild(opt);
});
sel.value = u.role || '';
sel.addEventListener('change', () => {
u.role = sel.value || '';
saveJSON('userAccounts', userAccounts);
const au = getAuthUser();
if (au && au.name === u.name) setAuthUser({ ...au, role: u.role });
renderUserAccounts();
});
tdRole.appendChild(sel);
}
tr.appendChild(tdRole);
const tdCreated = document.createElement('td'); tdCreated.textContent = u.created || ''; tr.appendChild(tdCreated);
const tdStatus = document.createElement('td');
const sw = document.createElement('div'); sw.className = 'switch' + (u.enabled ? '' : ' off');
const btn = document.createElement('button'); btn.textContent = u.enabled ? 'ON' : 'OFF';
btn.addEventListener('click', async () => { u.enabled = !u.enabled; await apiUserUpdate(u.id, { role: u.role, enabled: u.enabled }); renderUserAccounts(); saveJSON('userAccounts', userAccounts); });
sw.appendChild(btn); tdStatus.appendChild(sw); tr.appendChild(tdStatus);
const tdOps = document.createElement('td');
const reset = document.createElement('a'); reset.href='#'; reset.textContent='重置密码'; reset.className='link-blue';
reset.addEventListener('click', e => {
e.preventDefault();
pendingResetUser = u;
const np = u.name === 'aaaaaa' ? '999000' : '111111';
resetMsg.textContent = `已经将帐号 ${u.name} 密码重置,重置后密码为“${np}”`;
resetModal.style.display = 'flex';
});
tdOps.appendChild(reset); tr.appendChild(tdOps);
userRows.appendChild(tr);
});
if (userSummary) userSummary.textContent = `显示 ${Math.min(total,start+1)} 到 ${Math.min(total,start+pageData.length)} 项,共 ${total} 项`;
if (userPageEl) userPageEl.textContent = String(userPage);
}
userSearch?.addEventListener('input', () => { userPage = 1; renderUserAccounts(); });
userPageSize?.addEventListener('change', () => { userPage = 1; renderUserAccounts(); });
userPrev?.addEventListener('click', () => { if (userPage > 1) { userPage--; renderUserAccounts(); } });
userNext?.addEventListener('click', () => {
const size = parseInt(userPageSize?.value || '10',10);
const totalPages = Math.max(1, Math.ceil(userAccounts.filter(u => (userSearch?.value||'') ? [u.name,u.role,String(u.id)].some(v => v.includes(userSearch.value)) : true).length/size));
if (userPage < totalPages) { userPage++; renderUserAccounts(); }
});
userCreate?.addEventListener('click', () => { userModal.style.display = 'flex'; document.getElementById('u-name').value=''; document.getElementById('u-role').value=''; });
userCancel?.addEventListener('click', () => { userModal.style.display = 'none'; });
userForm?.addEventListener('submit', async e => {
e.preventDefault();
const name = document.getElementById('u-name').value.trim();
const role = document.getElementById('u-role').value.trim() || '普通用户';
if (!name) return;
const now = new Date();
const created = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
await apiUserCreate({ name, role, created, password:'111111' });
await apiUsersList();
userModal.style.display = 'none';
userPage = 1;
renderUserAccounts();
});
const salesRows = document.getElementById('sales-rows');
const salesSearch = document.getElementById('sales-search');
const salesCreate = document.getElementById('sales-create');
const salesModal = document.getElementById('sales-modal');
const salesForm = document.getElementById('sales-form');
const salesCancel = document.getElementById('sales-cancel');
const sName = document.getElementById('s-name');
const sRegion = document.getElementById('s-region');
const sPhone = document.getElementById('s-phone');
const sBase = document.getElementById('s-base');
const sRate = document.getElementById('s-rate');
const sCommission = document.getElementById('s-commission');
const salesData = [];
let editingSalesId = null;
function renderSales() {
const key = (salesSearch?.value || '').trim();
const data = salesData.filter(x => {
if (!key) return true;
return [x.name, x.region, x.phone].some(v => (v||'').includes(key));
}).sort((a,b)=>b.id-a.id);
if (!salesRows) return;
salesRows.innerHTML = '';
data.forEach(s => {
const tr = document.createElement('tr');
const related = payRecords.filter(r => (r.sales || '') === (s.name || ''));
const docs = Array.from(new Set(related.map(r => (r.doc || '')).filter(Boolean)));
const ordersCount = docs.length;
const totalAmount = related.reduce((sum, r) => sum + (r.amount || 0), 0);
const arrearsAmount = related.reduce((sum, r) => sum + Math.max(0, (r.amount || 0) - (r.paid || 0)), 0);
const cells = [s.id, s.name, s.region || '', s.phone || '', (s.base||0).toFixed(2), (s.rate||0), (s.commission||0).toFixed(2)];
cells.forEach(v => { const td = document.createElement('td'); td.textContent = String(v); tr.appendChild(td); });
const tdOrders = document.createElement('td');
const aOrders = document.createElement('a'); aOrders.href='#'; aOrders.textContent=String(ordersCount); aOrders.className='link-blue';
tdOrders.appendChild(aOrders); tr.appendChild(tdOrders);
const tdTotal = document.createElement('td'); tdTotal.textContent = totalAmount.toFixed(2); tr.appendChild(tdTotal);
const tdArrears = document.createElement('td'); tdArrears.textContent = arrearsAmount.toFixed(2); tdArrears.style.color = '#ef4444'; tr.appendChild(tdArrears);
const tdCreated = document.createElement('td'); tdCreated.textContent = s.created || ''; tr.appendChild(tdCreated);
const ops = document.createElement('td'); ops.className='actions';
const edit = document.createElement('a'); edit.href='#'; edit.textContent='编辑'; edit.className='link-blue';
const del = document.createElement('a'); del.href='#'; del.textContent='删除'; del.className='link-red';
ops.append(edit, document.createTextNode(' '), del);
tr.appendChild(ops);
salesRows.appendChild(tr);
aOrders.addEventListener('click', e => { e.preventDefault(); openSalesOrders(s.name); });
edit.addEventListener('click', e => {
e.preventDefault();
editingSalesId = s.id;
sName.value = s.name || '';
sRegion.value = s.region || '';
sPhone.value = s.phone || '';
sBase.value = s.base != null ? s.base : '';
sRate.value = s.rate != null ? s.rate : '';
sCommission.value = s.commission != null ? s.commission : '';
salesModal.style.display = 'flex';
});
del.addEventListener('click', async e => {
e.preventDefault();
if (!confirm('确定删除该业务员?')) return;
const i = salesData.findIndex(x => x.id === s.id);
if (i>=0) {
const ok = await apiSalesDelete(s.id);
if (ok) salesData.splice(i,1);
await apiSalesList(salesSearch?.value || '');
renderSales();
saveJSON('salesData', salesData);
}
});
tdArrears.addEventListener('click', e => { e.preventDefault(); openSalesArrears(s.name); });
});
const sel = document.getElementById('ct-sales');
if (sel) {
const prev = sel.value;
sel.innerHTML = '请选择业务员 ';
salesData.forEach(s => {
const opt = document.createElement('option'); opt.value = s.name; opt.textContent = s.name;
sel.appendChild(opt);
});
if ([...sel.options].some(o => o.value === prev)) sel.value = prev;
}
}
salesCreate?.addEventListener('click', () => {
editingSalesId = null;
sName.value=''; sRegion.value=''; sPhone.value=''; sBase.value=''; sRate.value=''; sCommission.value='';
salesModal.style.display = 'flex';
});
salesCancel?.addEventListener('click', () => { salesModal.style.display = 'none'; editingSalesId = null; });
salesForm?.addEventListener('submit', async e => {
e.preventDefault();
const name = sName.value.trim();
const region = sRegion.value.trim();
const phone = sPhone.value.trim();
const base = parseFloat(sBase.value || '0');
const rate = parseFloat(sRate.value || '0');
const commission = parseFloat(sCommission.value || '0');
if (!name || !phone) return;
if (editingSalesId != null) {
const s = salesData.find(x => x.id === editingSalesId);
if (s) { s.name=name; s.region=region; s.phone=phone; s.base=isNaN(base)?0:base; s.rate=isNaN(rate)?0:rate; s.commission=isNaN(commission)?0:commission; }
await apiSalesUpdate(editingSalesId, { name, region, phone, base:isNaN(base)?0:base, rate:isNaN(rate)?0:rate, commission:isNaN(commission)?0:commission });
} else {
const maxId = salesData.reduce((m,x)=>Math.max(m,x.id||0),0);
const now = new Date();
const created = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
salesData.push({ id:maxId+1, name, region, phone, base:isNaN(base)?0:base, rate:isNaN(rate)?0:rate, commission:isNaN(commission)?0:commission, created });
await apiSalesCreate({ name, region, phone, base:isNaN(base)?0:base, rate:isNaN(rate)?0:rate, commission:isNaN(commission)?0:commission, created });
}
salesModal.style.display = 'none';
editingSalesId = null;
await apiSalesList(salesSearch?.value || '');
renderSales();
saveJSON('salesData', salesData);
});
salesSearch?.addEventListener('input', async () => { await apiSalesList(salesSearch?.value || ''); renderSales(); });
const salesOrdersModal = document.getElementById('sales-orders-modal');
const salesOrdersRows = document.getElementById('sales-orders-rows');
const salesOrdersHead = document.getElementById('sales-orders-head');
const salesOrdersClose = document.getElementById('sales-orders-close');
const salesArrearsModal = document.getElementById('sales-arrears-modal');
const salesArrearsRows = document.getElementById('sales-arrears-rows');
const salesArrearsHead = document.getElementById('sales-arrears-head');
const salesArrearsClose = document.getElementById('sales-arrears-close');
function openSalesOrders(name) {
salesOrdersHead.textContent = '业务员:' + (name || '');
const list = payRecords.filter(r => (r.sales || '') === (name || ''));
salesOrdersRows.innerHTML = '';
list.forEach(r => {
const tr = document.createElement('tr');
const paid = r.paid || 0;
const arrears = Math.max(0, (r.amount || 0) - paid);
[r.type, r.partner || '', r.doc || '', (r.amount||0).toFixed(2), paid.toFixed(2), arrears.toFixed(2), r.date || ''].forEach((v,i) => {
const td = document.createElement('td');
td.textContent = String(v);
if (i===5 && arrears>0) td.style.color = '#ef4444';
tr.appendChild(td);
});
salesOrdersRows.appendChild(tr);
});
salesOrdersModal.style.display = 'flex';
}
function openSalesArrears(name) {
salesArrearsHead.textContent = '业务员:' + (name || '');
const list = payRecords.filter(r => (r.sales || '') === (name || '') && Math.max(0,(r.amount||0) - (r.paid||0)) > 0);
salesArrearsRows.innerHTML = '';
list.forEach(r => {
const tr = document.createElement('tr');
const paid = r.paid || 0;
const remain = Math.max(0, (r.amount || 0) - paid);
[r.type, r.partner || '', r.doc || '', (r.amount||0).toFixed(2), paid.toFixed(2), remain.toFixed(2), r.date || ''].forEach((v,i) => {
const td = document.createElement('td');
td.textContent = String(v);
if (i===5) td.style.color = '#ef4444';
tr.appendChild(td);
});
salesArrearsRows.appendChild(tr);
});
salesArrearsModal.style.display = 'flex';
}
salesOrdersClose?.addEventListener('click', () => { salesOrdersModal.style.display = 'none'; });
salesArrearsClose?.addEventListener('click', () => { salesArrearsModal.style.display = 'none'; });
const logoutBtn = document.getElementById('logout-btn');
const authUserTag = document.getElementById('auth-user-tag');
const loginForm = document.getElementById('login-form');
const loginUser = document.getElementById('login-user');
const loginPass = document.getElementById('login-pass');
const loginMsg = document.getElementById('login-msg');
function getAuthUser() {
try { return JSON.parse(localStorage.getItem('authUser') || 'null'); } catch { return null; }
}
function setAuthUI() {
const u = getAuthUser();
if (u) {
authUserTag.style.display = 'inline-block';
authUserTag.textContent = '当前用户:' + u.name;
logoutBtn.style.display = 'block';
} else {
authUserTag.style.display = 'none';
logoutBtn.style.display = 'none';
}
}
function setAuthUser(u) {
if (u) localStorage.setItem('authUser', JSON.stringify(u));
else localStorage.removeItem('authUser');
setAuthUI();
}
function getUserRoleName(name) {
const au = getAuthUser();
if (au && au.name === name) return au.role || '';
return '';
}
function getPwdMap() {
try { return JSON.parse(localStorage.getItem('userPasswords') || '{}'); } catch { return {}; }
}
function setPwdMap(map) {
localStorage.setItem('userPasswords', JSON.stringify(map || {}));
}
const API_BASE = '';
function getAuthToken() { try { return localStorage.getItem('authToken') || ''; } catch { return ''; } }
async function apiFetchJSON(path, opts) {
const token = getAuthToken();
const headers = Object.assign({}, (opts && opts.headers) || {});
if (token) headers['Authorization'] = 'Bearer ' + token;
const r = await fetch(API_BASE + path, { ...(opts||{}), headers });
if (r.status === 401) { location.hash = '#login'; throw new Error('unauthorized'); }
if (!r.ok) throw new Error('network_error');
return await r.json();
}
async function loadLedgerFromServer() {
try {
const list = await apiFetchJSON('/api/ledger');
if (Array.isArray(list)) {
records.splice(0, records.length, ...list.map(r => {
const createdRaw = Number(r.created_at);
const createdAt = Number.isFinite(createdRaw) && createdRaw > 0 ? createdRaw : (Date.parse(r.date_time || r.date || '') || Date.now());
return {
id: r.id,
type: r.type || '',
category: r.category || '',
doc: r.doc || '',
client: r.client || '',
amount: Number(r.amount || 0),
method: r.method || '',
file: r.file || '',
notes: r.notes || '',
date: r.date || '',
dateTime: r.date_time || '',
createdAt,
createdBy: r.created_by || '',
confirmed: r.confirmed !== false,
entry: '手动'
};
}));
saveJSON('records', records);
applyFilters();
const hm = document.getElementById('page-home');
if (hm && hm.style.display === 'block') renderHomeChart(homePeriodSel?.value || 'month');
}
} catch {}
}
async function loadPayablesFromServer() {
try {
const list = await apiFetchJSON('/api/payables');
if (Array.isArray(list)) {
payRecords.splice(0, payRecords.length, ...list.map(r => {
const createdRaw = Number(r.created_at);
const createdAt = Number.isFinite(createdRaw) && createdRaw > 0 ? createdRaw : (Date.parse(r.date || '') || Date.now());
return {
id: r.id,
type: r.type, partner: r.partner, doc: r.doc, sales: r.sales,
amount: Number(r.amount||0), paid: Number(r.paid||0),
trustDays: r.trust_days ?? null, notes: r.notes || '',
date: r.date || '', settled: !!r.settled, history: r.history || [],
createdAt, invoiceNo: r.invoice_no || '',
invoiceDate: r.invoice_date || '', invoiceAmount: Number(r.invoice_amount||0),
source: r.source || 'import', batchAt: r.batch_at || 0, batchOrder: r.batch_order ?? 0,
confirmed: r.confirmed !== false
};
}));
saveJSON('payRecords', payRecords);
renderPayables();
}
} catch {}
}
async function apiContactsList(tab, q, page, size) {
try {
const params = new URLSearchParams({ tab: tab||'customers', q: q||'', page: String(page||1), size: String(size||100) });
const list = await apiFetchJSON('/api/contacts?' + params.toString());
const key = tab==='merchants'?'merchants':(tab==='others'?'others':'customers');
if (Array.isArray(list)) contactsData[key] = list.map(x => ({
id: x.id,
name:x.name, contact:x.contact, phone:x.phone, city:x.city, remark:x.remark, owner:x.owner, created:x.created,
company:x.company, code:x.code, country:x.country, address:x.address, zip:x.zip, sales:x.sales,
use_price: x.use_price, is_iva: x.is_iva
}));
} catch {}
}
async function apiContactsCreate(obj) {
try {
const r = await apiFetchJSON('/api/contacts', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) });
return r.id;
} catch { return null; }
}
async function apiContactsUpdateByName(obj) {
try {
await apiFetchJSON('/api/contacts/by-name', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) });
} catch {}
}
async function apiContactsDeleteByName(owner, name) {
try {
const params = new URLSearchParams({ owner, name });
const r = await fetch(API_BASE + '/api/contacts/by-name?' + params.toString(), { method:'DELETE' });
return r.ok;
} catch { return false; }
}
function ownerLabelOfTab(tab) {
return tab==='merchants' ? '商家' : (tab==='others' ? '其它' : '客户');
}
async function seedDefaultContacts(tab) {
const owner = ownerLabelOfTab(tab);
const now = new Date();
const created = `${now.getFullYear()}/${String(now.getMonth()+1).padStart(2,'0')}/${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}:${String(now.getSeconds()).padStart(2,'0')}`;
let seeds = [];
if (owner === '商家') {
seeds = [
{ name:'商家A', contact:'刘一', phone:'13900000001', city:'上海', remark:'', owner, created },
{ name:'商家B', contact:'陈二', phone:'13900000002', city:'杭州', remark:'', owner, created },
{ name:'商家C', contact:'周三', phone:'13900000003', city:'苏州', remark:'', owner, created }
];
} else if (owner === '其它') {
seeds = [
{ name:'单位A', contact:'赵一', phone:'13700000001', city:'上海', remark:'', owner, created },
{ name:'单位B', contact:'钱二', phone:'13700000002', city:'杭州', remark:'', owner, created },
{ name:'单位C', contact:'孙三', phone:'13700000003', city:'苏州', remark:'', owner, created }
];
} else {
seeds = [
{ name:'客户A', contact:'张一', phone:'13800000001', city:'上海', remark:'', owner, created },
{ name:'客户B', contact:'李二', phone:'13800000002', city:'杭州', remark:'', owner, created },
{ name:'客户C', contact:'王三', phone:'13800000003', city:'苏州', remark:'', owner, created }
];
}
const existing = (contactsData[tab] || []);
for (const s of seeds) {
const exists = existing.some(x => (x.name === s.name) && (x.owner === owner));
if (!exists) { try { await apiContactsCreate(s); } catch {} }
}
}
async function apiAccountsList() {
try {
const list = await apiFetchJSON('/api/accounts');
if (Array.isArray(list)) {
accountsData.splice(0, accountsData.length, ...list.map(x => ({ name:x.name, balance:Number(x.balance||0), desc:x.desc||'', created:x.created||'', initialSet: !!x.initial_set })));
}
} catch {}
}
async function apiAccountCreate(obj) {
try { await apiFetchJSON('/api/accounts', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); } catch {}
}
async function apiAccountUpdateByName(obj) {
try { await apiFetchJSON('/api/accounts/by-name', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); } catch {}
}
async function apiAccountInit(name, amount) {
try { await apiFetchJSON('/api/accounts/init', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, amount }) }); } catch {}
}
async function apiAccountDeleteByName(name) {
try {
const params = new URLSearchParams({ name });
const r = await fetch(API_BASE + '/api/accounts/by-name?' + params.toString(), { method:'DELETE' });
return r.ok;
} catch { return false; }
}
async function apiCategoriesList() {
try {
const list = await apiFetchJSON('/api/categories');
if (Array.isArray(list)) {
categoriesData.splice(0, categoriesData.length, ...list.map(x => ({ name: x.name, children: Array.isArray(x.children) ? x.children : [] })));
}
} catch {}
}
async function apiCategoriesSave() {
try {
await apiFetchJSON('/api/categories', { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ list: categoriesData }) });
} catch {}
}
async function apiSalesList(q) {
try {
const params = new URLSearchParams({ q: q||'' });
const list = await apiFetchJSON('/api/sales?' + params.toString());
if (Array.isArray(list)) {
salesData.splice(0, salesData.length, ...list.map(x => ({ id:x.id, name:x.name, region:x.region||'', phone:x.phone||'', base:Number(x.base||0), rate:Number(x.rate||0), commission:Number(x.commission||0), created:x.created||'' })));
}
} catch {}
}
async function apiSalesCreate(obj) {
try { const r = await apiFetchJSON('/api/sales', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); return r?.id; } catch { return null; }
}
async function apiSalesUpdate(id, obj) {
try { await apiFetchJSON('/api/sales/' + String(id||0), { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); } catch {}
}
async function apiSalesDelete(id) {
try { const r = await fetch(API_BASE + '/api/sales/' + String(id||0), { method:'DELETE' }); return r.ok; } catch { return false; }
}
async function apiUsersList() {
try {
const list = await apiFetchJSON('/api/users');
if (Array.isArray(list)) userAccounts.splice(0, userAccounts.length, ...list.map(u => ({ id:u.id, name:u.name, role:u.role||'', created:u.created||'', enabled: !!u.enabled })));
} catch {}
}
async function apiUserCreate(obj) {
try { const r = await apiFetchJSON('/api/users', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); return r?.id; } catch { return null; }
}
async function apiUserUpdate(id, obj) {
try { await apiFetchJSON('/api/users/'+String(id), { method:'PUT', headers:{'Content-Type':'application/json'}, body: JSON.stringify(obj) }); } catch {}
}
async function apiUserResetPassword(id, password) {
try { await apiFetchJSON('/api/users/'+String(id)+'/reset-password', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password }) }); } catch {}
}
const logoutModal = document.getElementById('logout-modal');
const logoutCancel = document.getElementById('logout-cancel');
const logoutOk = document.getElementById('logout-ok');
const resetModal = document.getElementById('reset-modal');
const resetMsg = document.getElementById('reset-msg');
const resetCancel = document.getElementById('reset-cancel');
const resetOk = document.getElementById('reset-ok');
let pendingResetUser = null;
logoutBtn?.addEventListener('click', () => {
logoutModal.style.display = 'flex';
});
authUserTag?.addEventListener('click', () => {
const u = getAuthUser();
const cpUser = document.getElementById('cp-user');
const oldEl = document.getElementById('cp-old');
const n1 = document.getElementById('cp-new1');
const n2 = document.getElementById('cp-new2');
if (cpUser) cpUser.textContent = u?.name || '';
if (oldEl) oldEl.value = '';
if (n1) n1.value = '';
if (n2) n2.value = '';
document.getElementById('change-pwd-modal').style.display = 'flex';
});
logoutCancel?.addEventListener('click', () => {
logoutModal.style.display = 'none';
});
logoutOk?.addEventListener('click', () => {
logoutModal.style.display = 'none';
setAuthUser(null);
localStorage.removeItem('authToken');
location.href = './login.html';
});
resetCancel?.addEventListener('click', () => {
resetModal.style.display = 'none';
pendingResetUser = null;
});
resetOk?.addEventListener('click', async () => {
if (pendingResetUser) {
const np = pendingResetUser.name === 'aaaaaa' ? '999000' : '111111';
await apiUserResetPassword(pendingResetUser.id, np);
await apiUsersList();
apiUsersList().then(() => renderUserAccounts());
}
resetModal.style.display = 'none';
pendingResetUser = null;
});
const cpCancel = document.getElementById('cp-cancel');
const cpOk = document.getElementById('cp-ok');
cpCancel?.addEventListener('click', () => {
document.getElementById('change-pwd-modal').style.display = 'none';
});
cpOk?.addEventListener('click', () => {
const u = getAuthUser();
const name = u?.name || '';
const old = document.getElementById('cp-old').value || '';
const n1 = document.getElementById('cp-new1').value || '';
const n2 = document.getElementById('cp-new2').value || '';
if (!name || !old || !n1 || !n2) return;
if (n1 !== n2) { alert('两次输入的新密码不一致'); return; }
const m = getPwdMap();
const current = m[name] || (userAccounts.find(x => x.name === name)?.password || '');
if (String(current) !== String(old)) { alert('当前密码不正确'); return; }
m[name] = n1;
setPwdMap(m);
const idx = userAccounts.findIndex(x => x.name === name);
if (idx >= 0) userAccounts[idx].password = n1;
saveJSON('userAccounts', userAccounts);
document.getElementById('change-pwd-modal').style.display = 'none';
alert('修改密码成功');
});
loginForm?.addEventListener('submit', async e => {
e.preventDefault();
const name = (loginUser.value || '').trim();
const password = loginPass.value || '';
try {
const r = await apiFetchJSON('/api/auth/login', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name, password }) });
if (r && r.token && r.user) {
localStorage.setItem('authToken', r.token);
setAuthUser({ name: r.user.name, role: r.user.role || '' });
loginMsg.style.display = 'none';
location.hash = '#ledger';
} else { loginMsg.style.display = 'inline-block'; }
} catch {
loginMsg.style.display = 'inline-block';
}
});
function tsOf(rec) {
const ts = rec.createdAt || Date.parse(rec.dateTime || rec.date || '');
return isNaN(ts) ? Date.now() : ts;
}
function formatLabel(ts, mode) {
const d = new Date(ts);
const y = d.getFullYear();
const m = String(d.getMonth()+1).padStart(2,'0');
const dd = String(d.getDate()).padStart(2,'0');
if (mode === 'year') return String(y);
if (mode === 'month') return `${y}-${m}`;
return `${m}-${dd}`;
}
function buckets(mode) {
const now = new Date();
const list = [];
if (mode === 'year') {
for (let i=11;i>=0;i--) { const y = now.getFullYear() - i; list.push({ key: String(y), start: new Date(y,0,1).getTime(), end: new Date(y,11,31,23,59,59).getTime() }); }
} else if (mode === 'day') {
for (let i=29;i>=0;i--) { const d = new Date(now.getFullYear(), now.getMonth(), now.getDate()-i); const s=new Date(d.getFullYear(),d.getMonth(),d.getDate()).getTime(); const e=s+24*3600*1000-1; list.push({ key: formatLabel(s,'day'), start:s, end:e }); }
} else {
for (let i=11;i>=0;i--) { const d = new Date(now.getFullYear(), now.getMonth()-i, 1); const s=d.getTime(); const e=new Date(d.getFullYear(), d.getMonth()+1, 1).getTime()-1; list.push({ key: formatLabel(s,'month'), start:s, end:e }); }
}
return list;
}
async function renderHomeChart(mode='month') {
if (!homeChartRows) return;
let data = [];
try {
const range = mode==='day' ? 30 : 12;
data = await apiFetchJSON(`/api/analytics/ledger-summary?period=${mode}&range=${range}`);
} catch {
const bs = buckets(mode);
data = bs.map(b => {
let income = 0, expense = 0;
records.forEach(r => {
const t = tsOf(r);
if (t >= b.start && t <= b.end) {
if (r.type === '收入') income += Number(r.amount||0);
if (r.type === '开支' || r.type === '支出') expense += Number(r.amount||0);
}
});
return { label: b.key, income, expense };
});
}
const maxVal = Math.max(1, ...data.map(x => Math.max(x.income, x.expense)));
const h = 220;
homeChartRows.innerHTML = '';
data.forEach(x => {
const col = document.createElement('div');
col.style.display='flex'; col.style.flexDirection='column'; col.style.alignItems='center'; col.style.gap='8px';
const bars = document.createElement('div');
bars.style.display='flex'; bars.style.gap='0px'; bars.style.alignItems='flex-end';
const mkBar = (val,color) => {
const b = document.createElement('div');
b.style.width='12px'; b.style.height=Math.round(h*val/maxVal)+'px';
b.style.background=color; b.style.border='1px solid #334155'; b.style.borderRadius='4px';
const tag = document.createElement('div');
tag.textContent = (val||0).toFixed(2);
tag.style.color='#cbd5e1'; tag.style.fontSize='12px'; tag.style.textAlign='center';
tag.style.marginBottom='4px';
const wrap = document.createElement('div');
wrap.style.display='flex'; wrap.style.flexDirection='column'; wrap.style.alignItems='center'; wrap.style.margin='0'; wrap.style.padding='0';
wrap.appendChild(tag); wrap.appendChild(b);
return wrap;
};
bars.appendChild(mkBar(x.income,'#16a34a'));
bars.appendChild(mkBar(x.expense,'#f59e0b'));
const label = document.createElement('div'); label.textContent = x.label; label.style.color='#94a3b8'; label.style.fontSize='12px';
col.appendChild(bars); col.appendChild(label);
homeChartRows.appendChild(col);
});
}
homePeriodSel?.addEventListener('change', () => { const v=homePeriodSel.value||'month'; renderHomeChart(v); });
// Sales Order UI Logic
const soCustomer = document.getElementById('so-customer');
const soCustomerDD = document.getElementById('so-customer-dd');
const soCustomerList = document.getElementById('so-customer-list');
let currentSoCustomer = null;
const soCustomerSearch = document.getElementById('so-customer-search');
const soDate = document.getElementById('so-date');
const soTrust = document.getElementById('so-trust');
const soInvoiceNo = document.getElementById('so-invoice-no');
const soNotes = document.getElementById('so-notes');
const soAddItem = document.getElementById('so-add-item');
const soItems = document.getElementById('so-items');
const soTotal = document.getElementById('so-total');
const soSave = document.getElementById('so-save');
const invRows = document.getElementById('inv-rows');
const invSearch = document.getElementById('inv-search');
const invRefresh = document.getElementById('inv-refresh');
const invPager = document.getElementById('inv-pager');
if (soDate && !soDate.value) soDate.valueAsDate = new Date();
// Fetch next invoice number
async function loadNextInvoiceNo() {
if (!soInvoiceNo) return;
try {
const res = await fetchWithAuth('/api/invoices/next-no');
if (res.ok) {
const data = await res.json();
soInvoiceNo.textContent = `Factura:${data.nextNo}`;
soInvoiceNo.dataset.nextNo = data.nextNo;
}
} catch {}
}
function renderSoCustomerDropdown() {
if (!soCustomerList) return;
const q = (soCustomerSearch?.value || soCustomer.value || '').trim().toLowerCase();
const all = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
].filter(x => {
if (!q) return true;
return [x.name, x.contact, x.phone, x.city].some(v => (v||'').toLowerCase().includes(q));
});
soCustomerList.innerHTML = '';
if (all.length === 0) {
const div = document.createElement('div');
div.className = 'dd-item';
div.textContent = '无匹配结果';
div.style.color = '#94a3b8';
soCustomerList.appendChild(div);
return;
}
all.forEach(item => {
const div = document.createElement('div');
div.className = 'dd-item';
const left = document.createElement('div');
left.textContent = item.name;
const right = document.createElement('div');
right.style.color = '#94a3b8';
right.style.fontSize = '12px';
right.textContent = `${item.contact || ''} ${item.phone || ''} ${item.city || ''}`.trim();
div.append(left, right);
div.style.display = 'flex';
div.style.justifyContent = 'space-between';
div.style.alignItems = 'center';
div.addEventListener('click', () => {
soCustomer.value = item.name;
currentSoCustomer = item;
// Update Use Price
if (soUsePrice) {
const p = item.use_price || 'price1';
const map = { price1:'价格1', price2:'价格2', price3:'价格3', price4:'价格4' };
soUsePrice.value = map[p] || p;
}
// New Logic for Salesperson
if (soSales) {
if (item.sales) {
soSales.value = item.sales;
soSales.disabled = true;
} else {
soSales.value = '';
soSales.disabled = false;
}
}
soCustomerDD.style.display = 'none';
});
soCustomerList.appendChild(div);
});
}
function openSoCustomerDropdown() {
if (!soCustomerDD) return;
// Position logic (mimic Payables but adapted for single card)
// Since we are in a single card, popping to the left is risky if sidebar is there.
// But let's try to position it intelligently.
// For now, standard dropdown behavior (below input) but wider?
// The user asked for "left popup".
// If I use fixed position relative to the input, I can place it anywhere.
const rect = soCustomer.getBoundingClientRect();
// If we want it on the left of the input:
// left = rect.left - width - gap
// Check if there is space: rect.left is roughly 240+24+padding.
// If dropdown width is e.g. 280px.
// 240+24 = 264. So there is ~260px space? Tight.
// Let's stick to standard dropdown for now but ensure it works well.
// Or maybe the user means "align left"?
// "左边要弹出一个弹窗" -> "Pop up a window on the left".
soCustomerDD.style.display = 'block';
renderSoCustomerDropdown();
}
if (soCustomer) {
soCustomer.addEventListener('focus', openSoCustomerDropdown);
soCustomer.addEventListener('click', openSoCustomerDropdown);
soCustomer.addEventListener('input', () => {
openSoCustomerDropdown();
renderSoCustomerDropdown();
});
document.addEventListener('click', e => {
if (!soCustomer.contains(e.target) && !soCustomerDD.contains(e.target)) {
soCustomerDD.style.display = 'none';
}
});
}
if (soCustomerSearch) {
soCustomerSearch.addEventListener('input', renderSoCustomerDropdown);
}
function updateSoTotal() {
if (!soItems || !soTotal) return;
let subtotal = 0;
let totalTax = 0;
Array.from(soItems.children).forEach(tr => {
const qty = parseFloat(tr.querySelector('.qty').value)||0;
const price = parseFloat(tr.querySelector('.price').value)||0;
const amt = qty * price;
// Calculate Tax
let taxRate = parseFloat(tr.dataset.taxRate);
if (isNaN(taxRate)) taxRate = 0.10;
if (taxRate >= 1) taxRate = taxRate / 100;
const taxAmt = amt * taxRate;
tr.querySelector('.iva-amt').textContent = taxAmt.toFixed(2);
tr.querySelector('.amt').textContent = amt.toFixed(2);
subtotal += amt;
totalTax += taxAmt;
});
const grandTotal = subtotal + totalTax;
const soSubtotal = document.getElementById('so-subtotal');
const soTax = document.getElementById('so-tax');
if (soSubtotal) soSubtotal.textContent = subtotal.toFixed(2);
if (soTax) soTax.textContent = totalTax.toFixed(2);
soTotal.textContent = grandTotal.toFixed(2) + '€';
}
const soSales = document.getElementById('so-sales');
const soUsePrice = document.getElementById('so-use-price');
const editSales = document.getElementById('edit-sales');
const editUsePrice = document.getElementById('edit-use-price');
const ctSales = document.getElementById('ct-sales');
// paySales already defined above
async function loadSalesPeople() {
await apiSalesList(); // Ensure salesData is loaded
renderSalesPeopleOptions();
}
function renderSalesPeopleOptions() {
const opts = '请选择业务员 ' +
salesData.map(s => `${s.name} `).join('');
[soSales, editSales, ctSales, paySales].forEach(el => {
if (el) {
const val = el.value;
el.innerHTML = opts;
el.value = val; // Restore value if possible
}
});
}
// Product Selector Logic
const prodSelModal = document.getElementById('prod-selector-modal');
const prodSelSearch = document.getElementById('prod-sel-search');
const prodSelList = document.getElementById('prod-sel-list');
const prodSelClose = document.getElementById('prod-sel-close');
async function loadProductSelector() {
if (!prodSelList) return;
const q = (prodSelSearch?.value || '').trim();
const res = await fetchWithAuth(`/api/products?page=1&size=100&q=${encodeURIComponent(q)}`);
if (res.ok) {
const data = await res.json();
renderProductSelector(data.list || []);
}
}
function renderProductSelector(list) {
prodSelList.innerHTML = '';
if (list.length === 0) {
prodSelList.innerHTML = '无匹配商品
';
return;
}
// Sort list by sku (numeric)
list.sort((a,b) => {
const na = parseInt(a.sku||'0', 10);
const nb = parseInt(b.sku||'0', 10);
return na - nb;
});
// Determine customer tier
let usePrice = 'price1';
if (!currentSoCustomer && soCustomer.value) {
const all = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
];
currentSoCustomer = all.find(c => c.name === soCustomer.value);
}
if (currentSoCustomer && currentSoCustomer.use_price) {
usePrice = currentSoCustomer.use_price;
}
// Create Table Structure
const table = document.createElement('table');
table.style.width = '100%';
table.style.borderCollapse = 'collapse';
table.innerHTML = `
图片
编号
名称
规格
价格
库存
操作
`;
const tbody = table.querySelector('tbody');
list.forEach(p => {
// Determine display price
let displayPrice = Number(p.price1 || 0);
if (p[usePrice] !== undefined && p[usePrice] !== null && p[usePrice] !== '') {
displayPrice = Number(p[usePrice]);
}
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #1e293b';
tr.style.cursor = 'pointer';
tr.onmouseover = () => tr.style.background = '#1e293b';
tr.onmouseout = () => tr.style.background = 'transparent';
tr.innerHTML = `
${p.image ? ` ` : '
'}
${p.sku||''}
${p.name}
${p.spec||''}
€${displayPrice.toFixed(2)}
${Number(p.stock||0)}
选择
`;
// Click row to select
tr.addEventListener('click', (e) => {
addSoItem(p);
// prodSelModal.style.display = 'none'; // Optional: keep open to add more? User usually wants to pick one by one or stay.
// Let's keep it open or close? "选择商品" usually implies picking one. But "addSoItem" appends.
// If we want multiple selection, we shouldn't close.
// But typically user clicks "Close" when done.
// Let's NOT close automatically to allow multiple adds, or flash a success?
// For now, let's close it to be safe, or just flash.
// The prompt didn't specify, but "Search Product" modal usually closes on selection or allows multiple.
// Let's close it for now as per previous behavior.
prodSelModal.style.display = 'none';
});
tbody.appendChild(tr);
});
prodSelList.style.display = 'block'; // Ensure it's not grid
prodSelList.appendChild(table);
}
if (soAddItem) {
soAddItem.addEventListener('click', () => {
if (!soCustomer.value.trim()) {
alert('请先选择客户');
return;
}
prodSelModal.style.display = 'flex';
prodSelSearch.value = '';
loadProductSelector();
});
}
if (prodSelClose) prodSelClose.addEventListener('click', () => prodSelModal.style.display = 'none');
if (prodSelSearch) prodSelSearch.addEventListener('input', () => loadProductSelector());
function addSoItem(p) {
// Determine Price
let price = Number(p.price1 || 0);
if (!currentSoCustomer && soCustomer.value) {
// Try to find customer
const all = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
];
currentSoCustomer = all.find(c => c.name === soCustomer.value);
}
if (currentSoCustomer) {
const tier = currentSoCustomer.use_price || 'price1';
// If p has price2, price3 etc.
// Assuming p structure has price1, price2, price3... based on product schema.
// If not, fallback to price1.
if (p[tier] !== undefined && p[tier] !== null && p[tier] !== '') {
price = Number(p[tier]);
}
}
// Determine Tax Rate
let taxRate = 0.10; // Default 10%
if (p.tax_rate !== undefined && p.tax_rate !== null && p.tax_rate !== '') {
taxRate = Number(p.tax_rate);
// Fix: if taxRate is percentage integer (e.g. 10, 21), convert to decimal
if (taxRate >= 1) taxRate = taxRate / 100;
}
// "If Is IVA (true), add tax. If No (false), do NOT add tax."
if (currentSoCustomer) {
// Check if is_iva is false explicitly
if (currentSoCustomer.is_iva === false) {
taxRate = 0;
}
}
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #1e293b';
tr.dataset.taxRate = taxRate; // Store tax rate
tr.dataset.productId = p.id || '';
tr.dataset.sku = p.sku || '';
tr.innerHTML = `
0.00
${price.toFixed(2)}
×
`;
tr.querySelector('.btn-red').addEventListener('click', () => { tr.remove(); updateSoTotal(); });
tr.querySelectorAll('input').forEach(i => {
i.addEventListener('input', updateSoTotal);
i.addEventListener('focus', function() { this.select(); });
});
soItems.appendChild(tr);
updateSoTotal();
}
if (soSave) {
soSave.addEventListener('click', async () => {
const customer = (soCustomer.value||'').trim();
if (!customer) { alert('请选择客户'); return; }
const items = [];
Array.from(soItems.children).forEach(tr => {
const name = (tr.querySelector('.name').value||'').trim();
const desc = (tr.querySelector('.desc').value||'').trim();
const qty = parseFloat(tr.querySelector('.qty').value)||0;
const price = parseFloat(tr.querySelector('.price').value)||0;
// Get tax_rate from dataset
let taxRate = parseFloat(tr.dataset.taxRate);
if (isNaN(taxRate)) taxRate = 0.10; // Fallback
if (taxRate >= 1) taxRate = taxRate / 100; // Safety fix for integer percentages
const productId = tr.dataset.productId || '';
const sku = tr.dataset.sku || '';
if (name) items.push({ name, description: desc, qty, price, total: qty*price, tax_rate: taxRate, productId, sku });
});
if (items.length === 0) { alert('请至少添加一个商品'); return; }
const editId = soInvoiceNo.dataset.id;
let res;
const sales = soSales ? soSales.value : '';
if (editId) {
// Update existing invoice
res = await fetchWithAuth(`/api/invoices/${editId}`, {
method: 'PUT',
body: JSON.stringify({
customer,
date: soDate.value,
items,
notes: soNotes.value,
sales,
trust_days: parseInt(soTrust.value||'30', 10)
})
});
} else {
// Create new invoice
res = await fetchWithAuth('/api/invoices', {
method: 'POST',
body: JSON.stringify({
customer,
date: soDate.value,
items,
notes: soNotes.value,
sales,
trust_days: parseInt(soTrust.value||'30', 10),
invoice_no: soInvoiceNo.dataset.nextNo
})
});
}
if (res.ok) {
alert(editId ? '订单已更新' : '订单已保存');
soCustomer.value = '';
if (soUsePrice) soUsePrice.value = '';
soNotes.value = '';
soItems.innerHTML = '';
if (soSales) {
soSales.value = '';
soSales.disabled = false;
}
updateSoTotal();
delete soInvoiceNo.dataset.id; // Clear edit mode
loadNextInvoiceNo();
location.hash = '#sales-invoice';
} else {
const err = await res.json().catch(()=>({}));
if (err.error === 'cannot_edit_paid_invoice') {
alert('无法修改:订单已全额付款');
} else {
alert('保存失败');
}
}
});
}
let invPage = 1;
let invTotal = 0;
const invPageSize = 100;
let currentInvoices = [];
async function loadInvoices() {
if (!invRows) return;
const q = (invSearch?.value||'').trim();
const res = await fetchWithAuth(`/api/invoices?page=${invPage}&size=${invPageSize}&q=${encodeURIComponent(q)}`);
if (res.ok) {
const data = await res.json();
const list = data.list || [];
invTotal = data.total || 0;
currentInvoices = list;
invRows.innerHTML = '';
renderInvPager();
if (list.length === 0) {
invRows.innerHTML = '暂无发票数据 ';
return;
}
list.forEach((x, i) => {
const tr = document.createElement('tr');
const total = Number(x.total_amount||0);
const paid = Number(x.paid_amount||0);
let status = '';
let statusColor = '';
if (paid >= total && total > 0) {
status = '已付款';
statusColor = '#10b981'; // Green
} else if (paid > 0) {
status = '部分付款';
statusColor = '#f59e0b'; // Orange
} else {
status = '未付款';
statusColor = '#ef4444'; // Red
}
// Fix: Ensure customer, date, total are displayed correctly
// Some old data might have different field names or be empty
const displayCustomer = x.customer || x.client || '-';
const displayDate = x.date || x.invoice_date || '-';
// Find company name
let companyName = '-';
const all = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
];
const cust = all.find(c => c.name === displayCustomer);
if (cust && cust.company) companyName = cust.company;
const isPaid = (paid >= total && total > 0);
const editBtn = isPaid
? `修改 `
: `修改 `;
const seq = invTotal - ((invPage - 1) * invPageSize + i);
tr.innerHTML = `
${seq}
${x.invoice_no||''}
${displayCustomer}
${companyName}
${displayDate}
€${total.toFixed(2)}
${status}
${x.notes||''}
预览
打印
导出PDF
${editBtn}
`;
invRows.appendChild(tr);
});
}
}
function renderInvPager() {
if (!invPager) return;
invPager.innerHTML = '';
// Always show pager, even if only 1 page
const totalPages = Math.max(1, Math.ceil(invTotal / invPageSize));
const createBtn = (text, page, disabled=false) => {
const btn = document.createElement('button');
btn.className = 'light-btn';
btn.textContent = text;
btn.disabled = disabled;
if (page === invPage) {
btn.style.background = '#0b1524';
btn.style.cursor = 'default';
} else {
btn.addEventListener('click', () => {
invPage = page;
loadInvoices();
});
}
return btn;
};
invPager.appendChild(createBtn('上一页', invPage-1, invPage<=1));
let start = Math.max(1, invPage - 2);
let end = Math.min(totalPages, invPage + 2);
if (start > 1) {
invPager.appendChild(createBtn('1', 1));
if (start > 2) {
const span = document.createElement('span');
span.textContent = '...';
span.style.color = '#94a3b8';
span.style.padding = '0 4px';
invPager.appendChild(span);
}
}
for (let i = start; i <= end; i++) {
invPager.appendChild(createBtn(String(i), i));
}
if (end < totalPages) {
if (end < totalPages - 1) {
const span = document.createElement('span');
span.textContent = '...';
span.style.color = '#94a3b8';
span.style.padding = '0 4px';
invPager.appendChild(span);
}
invPager.appendChild(createBtn(String(totalPages), totalPages));
}
invPager.appendChild(createBtn('下一页', invPage+1, invPage>=totalPages));
}
// Invoice Actions
const invPrevModal = document.getElementById('invoice-preview-modal');
const invPrevClose = document.getElementById('inv-prev-close');
const invPrevPrint = document.getElementById('inv-prev-print');
if (invPrevClose) invPrevClose.addEventListener('click', () => invPrevModal.style.display = 'none');
if (invPrevPrint) invPrevPrint.addEventListener('click', () => window.print());
window.previewInvoice = async function(id) {
const inv = currentInvoices.find(x => String(x.id) === String(id));
if (!inv) return;
// Load Company Info for Header
try {
const ci = await apiFetchJSON('/api/company-info');
if (ci) {
const infoHtml = `
${ci.name || 'EMPRESA'}
CIF: ${ci.tax_id || ''}
${ci.street || ''}
${ci.zip || ''} ${ci.city || ''} ${ci.country || ''}
${ci.phone || ''}${ci.email ? ' ' + ci.email : ''}
`;
const el = document.getElementById('prev-company-info');
if (el) el.innerHTML = infoHtml;
const payHtml = `
Forma de pago
${ci.bank_name || ''}
${ci.iban || ''}
`;
const payEl = document.getElementById('prev-payment-info');
if (payEl) payEl.innerHTML = payHtml;
}
} catch {}
// Load Customer Info
let customerInfoHtml = '';
try {
const contacts = await apiFetchJSON('/api/contacts?type=customers');
const cust = contacts.find(c => c.name === inv.customer);
if (cust) {
customerInfoHtml = `
Facturado a
${cust.company || cust.name || ''}
${cust.code ? `${cust.code}
` : ''}
${cust.address ? `${cust.address}
` : ''}
${cust.zip ? `${cust.zip}
` : ''}
${cust.city ? `${cust.city}
` : ''}
${cust.country ? `${cust.country}
` : ''}
`;
} else {
customerInfoHtml = `
Facturado a
${inv.customer || ''}
`;
}
} catch {
customerInfoHtml = `
Facturado a
${inv.customer || ''}
`;
}
const custEl = document.getElementById('prev-customer-info');
if (custEl) custEl.innerHTML = customerInfoHtml;
document.getElementById('prev-no').textContent = (inv.invoice_no||'').replace('Factura:','');
// Update status based on payment
const total = Number(inv.total_amount||0);
const paid = Number(inv.paid_amount||0);
const statusEl = document.getElementById('prev-status');
if (statusEl) {
if (paid >= total && total > 0) {
statusEl.textContent = 'PAGADA';
statusEl.style.color = '#10b981'; // Green
statusEl.style.borderColor = '#10b981';
} else if (paid > 0) {
statusEl.textContent = 'PAGADA PARCIALMENTE';
statusEl.style.color = '#f59e0b'; // Orange
statusEl.style.borderColor = '#f59e0b';
} else {
statusEl.textContent = 'POR PAGAR';
statusEl.style.color = '#ef4444'; // Red
statusEl.style.borderColor = '#ef4444';
}
}
// Format dates: YYYY-MM-DD -> DD/MM/YYYY
const formatDate = (d) => {
if (!d) return '';
const parts = d.split('-');
if (parts.length === 3) return `${parts[2]}/${parts[1]}/${parts[0]}`;
return d;
};
const dateStr = formatDate(inv.date);
document.getElementById('prev-date').textContent = dateStr;
const prevSalesEl = document.getElementById('prev-sales');
if (prevSalesEl) prevSalesEl.textContent = inv.sales || '';
document.getElementById('prev-notes').textContent = inv.notes||'';
const tbody = document.getElementById('prev-items');
tbody.innerHTML = '';
const items = Array.isArray(inv.items) ? inv.items : (typeof inv.items === 'string' ? JSON.parse(inv.items) : []);
let subtotal = 0;
let taxDetails = {}; // key: tax rate, value: tax amount
items.forEach((item, idx) => {
const qty = Number(item.qty||0);
const price = Number(item.price||0);
// Use item.tax_rate if available (0.1, 0.21 etc), otherwise default to 0 if not set?
// Or prompt implies some products have specific tax.
// Let's assume item has tax_rate property. If not, maybe default 0 or check if user set it?
// For now, let's look for tax_rate in item.
let taxRate = Number(item.tax_rate);
// Modified: If taxRate is not a number or 0, we check if it was explicitly set to 0.
// If it's undefined/null/NaN, default to 0.10 (10%) based on user feedback "这个产品的税率是 10%".
// But we should ideally read from product.
// For now, let's assume if tax_rate is missing, it is 0.10 (10%).
if (isNaN(taxRate) || item.tax_rate === undefined || item.tax_rate === null || item.tax_rate === '') {
taxRate = 0.10;
}
// Fix: if taxRate is percentage integer (e.g. 10, 21), convert to decimal
if (taxRate >= 1) taxRate = taxRate / 100;
// If taxRate is 0, we don't show tax for this line?
// "Impuesto (税收) 没有税收的客户 这里不显示" -> If customer has no tax, all items 0 tax.
// "这个产品的税率是 10% ,那发票这里应该是就是计算是10%"
const rowVal = qty * price;
subtotal += rowVal;
const taxAmt = rowVal * taxRate;
// Accumulate tax details
if (taxRate > 0) {
const key = (taxRate * 100).toFixed(2); // "21.00", "10.00"
if (!taxDetails[key]) taxDetails[key] = 0;
taxDetails[key] += taxAmt;
}
const tr = document.createElement('tr');
let taxDisplay = '';
if (taxRate > 0) {
taxDisplay = `IVA ${(taxRate*100).toFixed(2)}%`;
}
tr.innerHTML = `
${idx + 1}
${item.name||''}
${item.description||''}
${qty}
${price.toFixed(2)}
${taxDisplay}
${rowVal.toFixed(2)}
`;
tbody.appendChild(tr);
});
// Calculate total tax
let totalTax = 0;
Object.values(taxDetails).forEach(v => totalTax += v);
const grandTotal = subtotal + totalTax;
document.getElementById('prev-subtotal').textContent = subtotal.toFixed(2);
// Render Tax Rows
const taxRowsContainer = document.getElementById('prev-tax-rows');
if (taxRowsContainer) {
taxRowsContainer.innerHTML = '';
Object.keys(taxDetails).sort((a,b)=>Number(b)-Number(a)).forEach(rate => {
const amt = taxDetails[rate];
const tr = document.createElement('tr');
tr.innerHTML = `
IVA (${rate}%)
${amt.toFixed(2)}
`;
taxRowsContainer.appendChild(tr);
});
// If no tax, maybe show 0? Or nothing?
// "没有税收的客户 这里不显示" -> if taxDetails empty, nothing shown.
} else {
// Fallback for old template structure if element not found (though we will update html next)
const prevTaxEl = document.getElementById('prev-tax');
if (prevTaxEl) prevTaxEl.textContent = totalTax.toFixed(2);
}
document.getElementById('prev-grand-total').textContent = grandTotal.toFixed(2) + '€';
document.getElementById('prev-grand-total-2').textContent = grandTotal.toFixed(2) + '€';
invPrevModal.style.display = 'flex';
};
window.printInvoice = function(id) {
previewInvoice(id);
setTimeout(() => window.print(), 500);
};
let pendingEditInvoiceId = null;
// Invoice Edit Modal Logic
const editModal = document.getElementById('invoice-edit-modal');
const editClose = document.getElementById('edit-modal-close');
const editItems = document.getElementById('edit-items');
const editAddItem = document.getElementById('edit-add-item');
const editSave = document.getElementById('edit-save');
const editCustomer = document.getElementById('edit-customer');
const editCustomerDd = document.getElementById('edit-customer-dd');
const editCustomerSearch = document.getElementById('edit-customer-search');
const editCustomerList = document.getElementById('edit-customer-list');
let currentEditCustomer = null;
if (editClose) editClose.addEventListener('click', () => editModal.style.display = 'none');
// Re-implement customer search for edit modal
if (editCustomer) {
editCustomer.addEventListener('focus', () => {
editCustomerSearch.value = '';
renderEditCustomerList();
editCustomerDd.style.display = 'block';
editCustomerSearch.focus();
});
// Close dropdown when clicking outside
document.addEventListener('click', e => {
if (!editCustomer.contains(e.target) && !editCustomerDd.contains(e.target)) {
editCustomerDd.style.display = 'none';
}
});
}
if (editCustomerSearch) {
editCustomerSearch.addEventListener('input', () => renderEditCustomerList(editCustomerSearch.value));
}
function renderEditCustomerList(filter = '') {
const list = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
];
editCustomerList.innerHTML = '';
const f = filter.toLowerCase();
list.forEach(c => {
if (!f || c.name.toLowerCase().includes(f) || (c.company||'').toLowerCase().includes(f)) {
const div = document.createElement('div');
div.className = 'dd-item';
div.textContent = c.name + (c.company ? ` (${c.company})` : '');
div.onclick = () => {
editCustomer.value = c.name;
currentEditCustomer = c;
// Update Use Price
if (editUsePrice) {
const p = c.use_price || 'price1';
const map = { price1:'价格1', price2:'价格2', price3:'价格3', price4:'价格4' };
editUsePrice.value = map[p] || p;
}
// New Logic for Salesperson
if (editSales) {
if (c.sales) {
editSales.value = c.sales;
editSales.disabled = true;
} else {
editSales.value = '';
editSales.disabled = false;
}
}
editCustomerDd.style.display = 'none';
// Re-calc tax for existing items
Array.from(editItems.children).forEach(tr => {
// If customer is not IVA, taxRate becomes 0. Else use stored or default.
// But we don't store original tax rate easily if we overwrite it.
// Let's assume standard behavior: update tax based on new customer preference.
let taxRate = 0.10; // Default fallback
const stored = parseFloat(tr.dataset.taxRate);
if (!isNaN(stored)) taxRate = stored;
if (c.is_iva === false) taxRate = 0;
else if (taxRate === 0 && stored !== 0) taxRate = 0.10; // Try to restore if previously 0? Hard to know.
// Simplest: Just apply is_iva=false logic.
tr.dataset.taxRate = taxRate;
const qty = parseFloat(tr.querySelector('.qty').value)||0;
const price = parseFloat(tr.querySelector('.price').value)||0;
const taxAmt = qty * price * taxRate;
tr.querySelector('.iva-amt').textContent = taxAmt.toFixed(2);
});
updateEditTotal();
};
editCustomerList.appendChild(div);
}
});
}
window.editInvoice = function(id) {
const inv = currentInvoices.find(x => String(x.id) === String(id));
if (!inv) return;
// Fill basic info
document.getElementById('edit-id').value = inv.id;
document.getElementById('edit-invoice-no').textContent = `Factura:${inv.invoice_no}`;
editCustomer.value = inv.customer || '';
document.getElementById('edit-date').value = inv.date || '';
document.getElementById('edit-notes').value = inv.notes || '';
// Find customer object
const allContacts = [
...(contactsData.customers||[]),
...(contactsData.merchants||[]),
...(contactsData.others||[])
];
currentEditCustomer = allContacts.find(c => c.name === (inv.customer||''));
// Update Use Price
if (editUsePrice) {
if (currentEditCustomer) {
const p = currentEditCustomer.use_price || 'price1';
const map = { price1:'价格1', price2:'价格2', price3:'价格3', price4:'价格4' };
editUsePrice.value = map[p] || p;
} else {
editUsePrice.value = '';
}
}
// Handle Sales
if (editSales) {
// Prioritize invoice record, then customer record
if (inv.sales) {
editSales.value = inv.sales;
// Lock if customer has sales? Or just if invoice has sales?
// User said "When customer info already set salesperson... lock it".
// If the invoice has a sales value that matches the customer's bound sales, lock it.
// If the invoice has a sales value but customer has none (or different), maybe allow edit?
// Simplest interpretation: If customer has bound sales, lock. Else unlock.
// But we must populate with inv.sales first.
if (currentEditCustomer && currentEditCustomer.sales) {
editSales.disabled = true;
} else {
editSales.disabled = false;
}
} else {
// No sales on invoice. Check customer.
if (currentEditCustomer && currentEditCustomer.sales) {
editSales.value = currentEditCustomer.sales;
editSales.disabled = true;
} else {
editSales.value = '';
editSales.disabled = false;
}
}
}
// Clear items
editItems.innerHTML = '';
// Parse and add items
let items = [];
try {
items = Array.isArray(inv.items) ? inv.items : (typeof inv.items === 'string' ? JSON.parse(inv.items) : []);
} catch (e) { items = []; }
if (Array.isArray(items)) {
items.forEach(item => addEditItem(item));
}
updateEditTotal();
editModal.style.display = 'flex';
};
function addEditItem(p = {}) {
const tr = document.createElement('tr');
tr.style.borderBottom = '1px solid #1e293b';
// Determine tax rate
let taxRate = 0.10;
if (p.tax_rate !== undefined && p.tax_rate !== null && p.tax_rate !== '') {
taxRate = Number(p.tax_rate);
if (taxRate >= 1) taxRate = taxRate / 100;
}
// Apply customer override
if (currentEditCustomer && currentEditCustomer.is_iva === false) {
taxRate = 0;
}
tr.dataset.taxRate = taxRate;
tr.dataset.productId = p.productId || '';
tr.dataset.sku = p.sku || '';
const qty = Number(p.qty) || 0;
const price = Number(p.price) || 0;
const taxAmt = qty * price * taxRate;
const total = qty * price; // Excl tax in row total display usually, or incl?
// In sales order table: "金额" column usually is total without tax or with?
// Looking at addSoItem: total = qty*price. And footer adds tax.
// So row total is subtotal.
tr.innerHTML = `
${taxAmt.toFixed(2)}
${total.toFixed(2)}
×
`;
// Events
tr.querySelector('.btn-red').addEventListener('click', () => { tr.remove(); updateEditTotal(); });
const inputs = tr.querySelectorAll('input');
inputs.forEach(i => {
i.addEventListener('focus', function() { this.select(); });
if (i.classList.contains('qty') || i.classList.contains('price')) {
i.addEventListener('input', () => {
const q = parseFloat(tr.querySelector('.qty').value) || 0;
const pr = parseFloat(tr.querySelector('.price').value) || 0;
const r = parseFloat(tr.dataset.taxRate) || 0;
tr.querySelector('.amt').textContent = (q * pr).toFixed(2);
tr.querySelector('.iva-amt').textContent = (q * pr * r).toFixed(2);
updateEditTotal();
});
}
});
editItems.appendChild(tr);
}
if (editAddItem) {
editAddItem.addEventListener('click', () => {
addEditItem({ name:'', description:'', qty:1, price:0 });
});
}
function updateEditTotal() {
let sub = 0, tax = 0;
Array.from(editItems.children).forEach(tr => {
const q = parseFloat(tr.querySelector('.qty').value) || 0;
const p = parseFloat(tr.querySelector('.price').value) || 0;
const r = parseFloat(tr.dataset.taxRate) || 0;
sub += q * p;
tax += q * p * r;
});
document.getElementById('edit-subtotal').textContent = sub.toFixed(2);
document.getElementById('edit-tax').textContent = tax.toFixed(2);
document.getElementById('edit-total').textContent = (sub + tax).toFixed(2);
}
if (editSave) {
editSave.addEventListener('click', async () => {
const id = document.getElementById('edit-id').value;
const customer = editCustomer.value.trim();
if (!customer) { alert('请选择客户'); return; }
const items = [];
Array.from(editItems.children).forEach(tr => {
const name = tr.querySelector('.name').value.trim();
const desc = tr.querySelector('.desc').value.trim();
const qty = parseFloat(tr.querySelector('.qty').value) || 0;
const price = parseFloat(tr.querySelector('.price').value) || 0;
const taxRate = parseFloat(tr.dataset.taxRate) || 0;
const productId = tr.dataset.productId || '';
const sku = tr.dataset.sku || '';
if (name) items.push({ name, description: desc, qty, price, total: qty*price, tax_rate: taxRate, productId, sku });
});
if (items.length === 0) { alert('请至少添加一个商品'); return; }
const res = await fetchWithAuth(`/api/invoices/${id}`, {
method: 'PUT',
body: JSON.stringify({
customer,
date: document.getElementById('edit-date').value,
items,
notes: document.getElementById('edit-notes').value,
sales: editSales ? editSales.value : '',
trust_days: parseInt(document.getElementById('edit-trust').value||'30', 10)
})
});
if (res.ok) {
alert('修改已保存');
editModal.style.display = 'none';
loadInvoices();
} else {
const err = await res.json().catch(()=>({}));
alert(err.error === 'cannot_edit_paid_invoice' ? '无法修改:订单已全额付款' : '保存失败');
}
});
}
// Deprecated old edit logic
async function loadInvoiceForEdit(id) {
// ... kept for compatibility if needed, but window.editInvoice is overwritten
}
if (invRefresh) invRefresh.addEventListener('click', () => { invPage=1; loadInvoices(); });
if (invSearch) invSearch.addEventListener('change', () => { invPage=1; loadInvoices(); });
async function route() {
const hash = location.hash || '#ledger';
const au = getAuthUser(); if (au && !au.role) { setAuthUser({ ...au, role: getUserRoleName(au.name) }); }
document.querySelectorAll('.nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === hash));
// Auto-expand nav group if active link is inside
document.querySelectorAll('.nav-group').forEach(group => {
const hasActive = group.querySelector('a.active');
if (hasActive) {
const header = group.querySelector('.nav-group-header');
const children = group.querySelector('.nav-children');
if (header) header.classList.remove('collapsed');
if (children) children.classList.remove('collapsed');
}
});
(function applyNavPerms(){
const u = getAuthUser();
const roleName = (u?.role) || getUserRoleName(u?.name || '');
const role = roleName ? rolesData.find(r => r.name === roleName) : null;
const perms = (roleName==='超级管理员') ? allTruePerms() : (role?.perms || {});
const map = {
'#home':'home', '#ledger':'ledger', '#payables':'payables', '#contacts':'contacts',
'#sales-order':'sales_order', '#sales-invoice':'sales_invoice', '#sales-products':'sales_products',
'#system':'system', '#categories':'categories', '#accounts':'accounts', '#user-accounts':'user_accounts', '#role-accounts':'role_accounts', '#sales-accounts':'sales_accounts'
};
document.querySelectorAll('.nav a').forEach(a => {
const m = map[a.getAttribute('href') || ''];
const allow = (roleName==='超级管理员') ? true
: (m==='home' ? true
: (m==='system' ? false
: !!(perms[m] && perms[m].view)));
a.style.display = allow ? 'block' : 'none';
});
})();
const home = document.getElementById('page-home');
const ledger = document.getElementById('page-ledger');
const payables = document.getElementById('page-payables');
const contacts = document.getElementById('page-contacts');
const categories = document.getElementById('page-categories');
const salesOrder = document.getElementById('page-sales-order');
const salesInvoice = document.getElementById('page-sales-invoice');
const salesProducts = document.getElementById('page-sales-products');
const accounts = document.getElementById('page-accounts');
const userAccounts = document.getElementById('page-user-accounts');
const salesAccounts = document.getElementById('page-sales-accounts');
const roleAccounts = document.getElementById('page-role-accounts');
const companyInfoPage = document.getElementById('page-company-info');
const partnerOrdersPage = document.getElementById('page-partner-orders');
const systemPage = document.getElementById('page-system');
const loginPage = document.getElementById('page-login');
const empty = document.getElementById('page-empty');
const authed = !!(getAuthUser() && (localStorage.getItem('authToken') || ''));
if (!authed) { location.href = './login.html'; return; }
if (systemPage) systemPage.style.display = 'none';
if (companyInfoPage) companyInfoPage.style.display = 'none';
function ensureView(module) {
const u = getAuthUser(); const roleName = u?.role || '';
const nameWithFallback = roleName || getUserRoleName(u?.name || '');
if (nameWithFallback==='超级管理员' || module==='home') return true;
const role = rolesData.find(r => r.name === nameWithFallback);
const allow = !!(role && role.perms && role.perms[module] && role.perms[module].view);
if (!allow) { location.hash = '#home'; return false; }
return true;
}
if (hash === '#home') {
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
if (salesOrder) salesOrder.style.display = 'none';
if (salesInvoice) salesInvoice.style.display = 'none';
if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
if (home) {
home.style.display = 'block';
if (homePeriodSel) homePeriodSel.value = 'month';
renderHomeChart('month');
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
}
} else if (hash === '#ledger') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('ledger')) return;
ledger.style.display = 'block';
if (salesOrder) salesOrder.style.display = 'none';
if (salesInvoice) salesInvoice.style.display = 'none';
if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
const gpEl = document.getElementById('global-pager'); if (gpEl) gpEl.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
salesAccounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
try {
ledgerHdrType = 'all';
ledgerHdrCat = '';
ledgerHdrOwner = '';
setLabel(document.getElementById('ld-type-label'), '类型', false);
setLabel(document.getElementById('ld-cat-label'), '子类目', false);
setLabel(document.getElementById('ld-owner-label'), '往来单位', false);
} catch {}
await loadLedgerFromServer();
applyFilters();
} else if (hash === '#sales-order') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('sales_order')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
if (salesOrder) salesOrder.style.display = 'block';
if (salesInvoice) salesInvoice.style.display = 'none';
if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
salesAccounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
// init sales order
await apiContactsList();
await loadSalesPeople();
// Pre-fetch products for selector
await loadProductSelector();
if (pendingEditInvoiceId) {
await loadInvoiceForEdit(pendingEditInvoiceId);
pendingEditInvoiceId = null;
} else {
// Normal init for new order
soCustomer.value = '';
if (soUsePrice) soUsePrice.value = '';
soNotes.value = '';
soItems.innerHTML = '';
updateSoTotal();
if (soInvoiceNo) delete soInvoiceNo.dataset.id; // Clear edit ID
// Only load next invoice no if not already set (e.g. by editInvoice)
// Actually, if we are in "new" mode (pendingEditInvoiceId is null), we SHOULD load next no.
// But we check if textContent is empty to avoid double loading if user just switched tabs back and forth?
// For "New", we probably want to ensure it's fresh.
await loadNextInvoiceNo();
}
} else if (hash === '#sales-invoice') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('sales_invoice')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
if (salesOrder) salesOrder.style.display = 'none';
if (salesInvoice) salesInvoice.style.display = 'block';
if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
salesAccounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
invPage = 1;
await loadInvoices();
} else if (hash === '#sales-products') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('sales_products')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
if (salesOrder) salesOrder.style.display = 'none';
if (salesInvoice) salesInvoice.style.display = 'none';
if (salesProducts) salesProducts.style.display = 'block';
payables.style.display = 'none';
contacts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
salesAccounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
prodPage = 1;
await loadProducts();
} else if (hash === '#payables') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('payables')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'block';
contacts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
salesAccounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
try {
payFilterType = 'all';
payFilterSalesName = '';
payFilterStatus = 'all';
payFilterOverdue = 'all';
payPage = 1;
const setDefault = () => {
const reset = (el, text) => { if (el) { el.textContent = text + ' ▾'; el.style.color = ''; } };
reset(document.getElementById('th-type-label'), '款项类型');
reset(document.getElementById('th-sales-label'), '业务员');
reset(document.getElementById('th-arrears-label'), '欠款');
reset(document.getElementById('th-trust-label'), '信任天数');
};
setDefault();
} catch {}
await loadPayablesFromServer();
renderPayables();
} else if (hash === '#contacts') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('contacts')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'block';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'flex';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await loadSalesPeople();
renderContacts();
} else if (hash.startsWith('#partner-orders')) {
const nameParam = decodeURIComponent((hash.split(':')[1] || '').trim());
if (home) home.style.display = 'none';
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
if (partnerOrdersPage) {
partnerOrdersPage.style.display = 'block';
partnerOrdersHead.textContent = '往来单位:' + (nameParam || '');
partnerOrdersRows.innerHTML = '';
await loadPayablesFromServer();
const list = payRecords.filter(r => (r.partner || '') === (nameParam || ''));
list.forEach(r => {
const tr = document.createElement('tr');
const paid = r.paid || 0;
const arrears = Math.max(0, (r.amount || 0) - paid);
[r.type, r.partner || '', r.doc || '', (r.amount||0).toFixed(2), (r.invoiceNo||''), (Number(r.invoiceAmount||0).toFixed(2)), paid.toFixed(2), arrears.toFixed(2), r.date || ''].forEach((v,i) => {
const td = document.createElement('td');
td.textContent = String(v);
if (i===7 && arrears>0) td.style.color = '#ef4444';
tr.appendChild(td);
});
partnerOrdersRows.appendChild(tr);
});
}
} else if (hash === '#categories') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('categories')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'block';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await apiCategoriesList();
renderCats();
} else if (hash === '#accounts') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('accounts')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'block';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await apiAccountsList();
refreshAccountOptions();
renderAccounts();
} else if (hash === '#user-accounts') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('user_accounts')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'block';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
salesAccounts.style.display = 'none';
const gpEl = document.getElementById('global-pager'); if (gpEl) gpEl.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await apiUsersList();
renderUserAccounts();
} else if (hash === '#role-accounts') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('role_accounts')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'block';
const permsPage = document.getElementById('page-role-perms'); if (permsPage) permsPage.style.display = 'none';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await apiRolesList();
renderRoles();
} else if (hash === '#role-perms') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
const gp = document.getElementById('global-pager'); if (gp) gp.style.display = 'none';
const permsPage = document.getElementById('page-role-perms'); if (permsPage) permsPage.style.display = 'block';
loginPage.style.display = 'none';
empty.style.display = 'none';
} else if (hash === '#sales-accounts') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
if (!ensureView('sales_accounts')) return;
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'block';
roleAccounts.style.display = 'none';
const uw = document.getElementById('undo-wrap'); if (uw) uw.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'none';
await apiSalesList();
renderSales();
} else if (hash === '#company-info') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
const u = getAuthUser(); const rn = (u?.role) || getUserRoleName(u?.name || '');
// Only super admin or those with system.view can see, usually system settings are restricted
if (rn !== '超级管理员') { location.hash = '#home'; return; }
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
systemPage.style.display = 'none';
if (companyInfoPage) companyInfoPage.style.display = 'block';
loginPage.style.display = 'none';
empty.style.display = 'none';
await loadCompanyInfo();
} else if (hash === '#system') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
const u = getAuthUser(); const rn = (u?.role) || getUserRoleName(u?.name || '');
if (rn !== '超级管理员') { location.hash = '#home'; return; }
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
salesAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
systemPage.style.display = 'block';
loginPage.style.display = 'none';
empty.style.display = 'none';
} else if (hash === '#login') {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'block';
empty.style.display = 'none';
} else {
if (home) home.style.display = 'none';
if (partnerOrdersPage) partnerOrdersPage.style.display = 'none';
ledger.style.display = 'none'; if (salesOrder) salesOrder.style.display = 'none'; if (salesInvoice) salesInvoice.style.display = 'none'; if (salesProducts) salesProducts.style.display = 'none';
payables.style.display = 'none';
contacts.style.display = 'none';
categories.style.display = 'none';
accounts.style.display = 'none';
userAccounts.style.display = 'none';
roleAccounts.style.display = 'none';
loginPage.style.display = 'none';
empty.style.display = 'block';
}
}
window.addEventListener('hashchange', route);
document.querySelectorAll('.nav a').forEach(a => {
a.addEventListener('click', () => setTimeout(() => route(), 0));
});
initPersist();
setAuthUI();
loadLedgerFromServer();
loadPayablesFromServer();
applyFilters();
window.addEventListener('resize', updateLedgerHeaderCover);
document.getElementById('partner-orders-back')?.addEventListener('click', () => { location.hash = '#contacts'; });
renderContacts();
apiCategoriesList().then(() => renderCats());
apiAccountsList().then(() => { refreshAccountOptions(); renderAccounts(); });
apiSalesList().then(() => renderSales());
apiRolesList().then(() => { renderRoles(); route(); });
apiUsersList().then(() => renderUserAccounts());
refreshLedgerTypeOptions();
setCategories();
(function initPageByHash(){
const h = location.hash || '#ledger';
const isPayables = (h === '#payables');
const gp = document.getElementById('global-pager');
if (!isPayables && gp) gp.style.display = 'none';
if (isPayables) renderPayables();
})();
accAdd?.addEventListener('click', () => {
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
});
function showAccCreate() {
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
}
document.addEventListener('click', e => {
const btn = e.target.closest && e.target.closest('#acc-add');
if (!btn) return;
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
e.preventDefault();
});
// Company Info
async function loadCompanyInfo() {
try {
const data = await apiFetchJSON('/api/company-info');
if (data) {
document.getElementById('ci-name').value = data.name || '';
document.getElementById('ci-tax').value = data.tax_id || '';
document.getElementById('ci-phone').value = data.phone || '';
document.getElementById('ci-email').value = data.email || '';
document.getElementById('ci-street').value = data.street || '';
document.getElementById('ci-zip').value = data.zip || '';
document.getElementById('ci-city').value = data.city || '';
document.getElementById('ci-country').value = data.country || '';
document.getElementById('ci-bank').value = data.bank_name || '';
document.getElementById('ci-iban').value = data.iban || '';
document.getElementById('ci-swift').value = data.swift || '';
}
} catch {}
}
const companyInfoForm = document.getElementById('company-info-form');
if (companyInfoForm) {
companyInfoForm.addEventListener('submit', async e => {
e.preventDefault();
const payload = {
name: document.getElementById('ci-name').value.trim(),
tax_id: document.getElementById('ci-tax').value.trim(),
phone: document.getElementById('ci-phone').value.trim(),
email: document.getElementById('ci-email').value.trim(),
street: document.getElementById('ci-street').value.trim(),
zip: document.getElementById('ci-zip').value.trim(),
city: document.getElementById('ci-city').value.trim(),
country: document.getElementById('ci-country').value.trim(),
bank_name: document.getElementById('ci-bank').value.trim(),
iban: document.getElementById('ci-iban').value.trim(),
swift: document.getElementById('ci-swift').value.trim(),
};
try {
await apiFetchJSON('/api/company-info', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) });
alert('公司信息已保存');
} catch {
alert('保存失败');
}
});
}
// Appended from index.html
accAdd?.addEventListener('click', () => {
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
});
function showAccCreate() {
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
}
document.addEventListener('click', e => {
const btn = e.target.closest && e.target.closest('#acc-add');
if (!btn) return;
const nameEl = document.getElementById('acc-create-name');
const descEl = document.getElementById('acc-create-desc');
const modal = document.getElementById('acc-create-modal');
if (nameEl) nameEl.value = '';
if (descEl) descEl.value = '';
if (modal) modal.style.display = 'flex';
e.preventDefault();
});
// Products Logic
let prodPage = 1;
let prodTotal = 0;
const prodPageSize = 50;
const prodSearch = document.getElementById('prod-search');
const prodRefresh = document.getElementById('prod-refresh');
const prodAdd = document.getElementById('prod-add');
const prodRows = document.getElementById('prod-rows');
const prodPager = document.getElementById('prod-pager');
const prodModal = document.getElementById('product-modal');
const prodForm = document.getElementById('prod-form');
const prodCancel = document.getElementById('prod-cancel');
const prodFile = document.getElementById('prod-file');
const prodFileName = document.getElementById('prod-file-name');
const prodPreview = document.getElementById('prod-preview');
if (prodFile) {
prodFile.addEventListener('change', () => {
const file = prodFile.files[0];
if (file) {
prodFileName.textContent = file.name;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('prod-image').value = e.target.result;
prodPreview.src = e.target.result;
prodPreview.style.display = 'block';
};
reader.readAsDataURL(file);
} else {
prodFileName.textContent = '未选择文件';
prodPreview.style.display = 'none';
}
});
}
async function loadProducts() {
if (!prodRows) return;
const q = (prodSearch?.value||'').trim();
const res = await fetchWithAuth(`/api/products?page=${prodPage}&size=${prodPageSize}&q=${encodeURIComponent(q)}`);
if (res.ok) {
const data = await res.json();
const list = data.list || [];
prodTotal = data.total || 0;
renderProducts(list);
renderProdPager();
}
}
function renderProducts(list) {
prodRows.innerHTML = '';
if (list.length === 0) {
prodRows.innerHTML = '暂无商品数据 ';
return;
}
list.forEach(x => {
const tr = document.createElement('tr');
tr.innerHTML = `
${x.id}
${x.image ? ` ` : ''}
${x.sku||''}
${x.barcode||''}
${x.name||''}
${x.name_cn||''}
${x.spec||''}
${Number(x.price1||0).toFixed(2)}
${Number(x.price2||0).toFixed(2)}
${Number(x.price3||0).toFixed(2)}
${Number(x.price4||0).toFixed(2)}
${Number(x.tax_rate||0).toFixed(2)}%
${Number(x.stock||0)}
${x.created_at ? new Date(Number(x.created_at)).toLocaleDateString() : ''}
编辑
删除
`;
const editBtn = tr.querySelector('.prod-edit-btn');
const delBtn = tr.querySelector('.prod-del-btn');
editBtn.addEventListener('click', () => openProdModal(x));
delBtn.addEventListener('click', () => deleteProduct(x));
prodRows.appendChild(tr);
});
}
function renderProdPager() {
if (!prodPager) return;
prodPager.innerHTML = '';
const totalPages = Math.ceil(prodTotal / prodPageSize);
if (totalPages <= 1) return;
const createBtn = (text, page, disabled=false) => {
const btn = document.createElement('button');
btn.className = 'light-btn';
btn.textContent = text;
btn.disabled = disabled;
if (page === prodPage) btn.style.background = '#0b1524'; // active style
else btn.addEventListener('click', () => {
prodPage = page;
loadProducts();
});
return btn;
};
prodPager.appendChild(createBtn('上一页', prodPage-1, prodPage<=1));
const span = document.createElement('span');
span.style.padding = '8px';
span.textContent = `${prodPage} / ${totalPages}`;
prodPager.appendChild(span);
prodPager.appendChild(createBtn('下一页', prodPage+1, prodPage>=totalPages));
}
function openProdModal(x) {
if (!prodModal) return;
const isEdit = !!x;
document.getElementById('prod-modal-title').textContent = isEdit ? '编辑商品' : '新增商品';
document.getElementById('prod-id').value = isEdit ? x.id : '';
document.getElementById('prod-sku').value = x?.sku||'';
document.getElementById('prod-barcode').value = x?.barcode||'';
document.getElementById('prod-name').value = x?.name||'';
document.getElementById('prod-name-cn').value = x?.name_cn||'';
document.getElementById('prod-spec').value = x?.spec||'';
document.getElementById('prod-tax').value = x?.tax_rate||'10';
document.getElementById('prod-p1').value = x?.price1||'';
document.getElementById('prod-p2').value = x?.price2||'';
document.getElementById('prod-p3').value = x?.price3||'';
document.getElementById('prod-p4').value = x?.price4||'';
document.getElementById('prod-stock').value = x?.stock||'0';
document.getElementById('prod-image').value = x?.image||'';
document.getElementById('prod-desc').value = x?.description||'';
document.getElementById('prod-notes').value = x?.notes||'';
if (x?.image) {
prodPreview.src = x.image;
prodPreview.style.display = 'block';
prodFileName.textContent = '已上传图片';
} else {
prodPreview.style.display = 'none';
prodFileName.textContent = '未选择文件';
prodFile.value = '';
}
// Clear validation styles
document.querySelectorAll('.invalid').forEach(el => el.classList.remove('invalid'));
document.querySelectorAll('.invalid-label').forEach(el => el.classList.remove('invalid-label'));
prodModal.style.display = 'flex';
}
async function deleteProduct(x) {
if (!confirm('确定删除该商品吗?')) return;
const res = await fetchWithAuth(`/api/products/${x.id}`, { method: 'DELETE' });
if (res.ok) {
loadProducts();
} else {
alert('删除失败');
}
}
if (prodAdd) prodAdd.addEventListener('click', () => openProdModal(null));
if (prodCancel) prodCancel.addEventListener('click', () => prodModal.style.display = 'none');
if (prodRefresh) prodRefresh.addEventListener('click', () => { prodPage=1; loadProducts(); });
if (prodSearch) prodSearch.addEventListener('change', () => { prodPage=1; loadProducts(); });
if (prodForm) {
prodForm.addEventListener('submit', async e => {
e.preventDefault();
// Validation
const reqFields = [
{ id:'prod-sku', label:'货号' },
{ id:'prod-name', label:'商品名称' },
{ id:'prod-p1', label:'价格1' },
{ id:'prod-tax', label:'税率' }
];
let hasError = false;
reqFields.forEach(f => {
const el = document.getElementById(f.id);
const val = (el.value||'').trim();
const label = el.parentElement.querySelector('.label');
el.classList.remove('invalid');
if (label) label.classList.remove('invalid-label');
if (!val) {
hasError = true;
el.classList.add('invalid');
if (label) label.classList.add('invalid-label');
}
});
if (hasError) return;
const btn = document.getElementById('prod-save-btn');
if (btn) {
btn.disabled = true;
btn.textContent = '保存中...';
}
try {
const id = document.getElementById('prod-id').value;
const data = {
sku: document.getElementById('prod-sku').value,
barcode: document.getElementById('prod-barcode').value,
name: document.getElementById('prod-name').value,
name_cn: document.getElementById('prod-name-cn').value,
spec: document.getElementById('prod-spec').value,
tax_rate: document.getElementById('prod-tax').value,
price1: document.getElementById('prod-p1').value,
price2: document.getElementById('prod-p2').value,
price3: document.getElementById('prod-p3').value,
price4: document.getElementById('prod-p4').value,
stock: document.getElementById('prod-stock').value,
image: document.getElementById('prod-image').value,
description: document.getElementById('prod-desc').value,
notes: document.getElementById('prod-notes').value
};
const url = id ? `/api/products/${id}` : '/api/products';
const method = id ? 'PUT' : 'POST';
const res = await fetchWithAuth(url, {
method,
body: JSON.stringify(data),
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
prodModal.style.display = 'none';
loadProducts();
} else {
try {
const err = await res.json();
if (err.error === 'duplicate_sku') alert('货号已存在');
else alert('保存失败');
} catch { alert('保存失败'); }
}
} catch (e) {
console.error(e);
alert('保存出错: ' + e.message);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = '保存';
}
}
});
}
// Init Route
route();
// Note Delete Modal Logic
let pendingDeleteNoteId = null;
let pendingDeleteNoteIdx = null;
let pendingDeleteNoteContactId = null;
const noteDeleteModal = document.getElementById('note-delete-modal');
const noteDeleteOk = document.getElementById('note-delete-ok');
const noteDeleteCancel = document.getElementById('note-delete-cancel');
if (noteDeleteCancel) {
noteDeleteCancel.addEventListener('click', () => {
noteDeleteModal.style.display = 'none';
pendingDeleteNoteId = null;
pendingDeleteNoteIdx = null;
pendingDeleteNoteContactId = null;
});
}
if (noteDeleteOk) {
noteDeleteOk.addEventListener('click', async () => {
if (pendingDeleteNoteContactId) {
// Real delete
try {
await apiFetchJSON(`/api/contacts/notes/${pendingDeleteNoteId}`, { method: 'DELETE' });
loadContactNotes(pendingDeleteNoteContactId);
} catch (e) {
alert('删除失败');
}
} else {
// Temp delete
if (pendingDeleteNoteIdx !== null) {
tempContactNotes.splice(pendingDeleteNoteIdx, 1);
loadContactNotes(null);
}
}
noteDeleteModal.style.display = 'none';
pendingDeleteNoteId = null;
pendingDeleteNoteIdx = null;
pendingDeleteNoteContactId = null;
});
}
// Contact Notes Logic
async function loadContactNotes(id) {
const rows = document.getElementById('ct-notes-rows');
rows.innerHTML = '加载中... ';
let list = [];
if (id) {
try {
list = await apiFetchJSON(`/api/contacts/${id}/notes`);
} catch (e) {
console.error(e);
rows.innerHTML = '加载失败 ';
return;
}
} else {
// Use temp notes
list = tempContactNotes;
}
rows.innerHTML = '';
if (list.length === 0) {
rows.innerHTML = '暂无备注 ';
return;
}
list.forEach((n, idx) => {
const tr = document.createElement('tr');
const d = new Date(Number(n.created_at));
const dateStr = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
tr.innerHTML = `
${String(n.note||'')}
${String(n.created_by||'')}
${dateStr}
✎
×
`;
tr.querySelector('.btn-blue').addEventListener('click', () => {
editingNoteId = id ? n.id : idx;
document.getElementById('ct-note-text').value = n.note || '';
document.getElementById('ct-note-input-area').style.display = 'flex';
document.getElementById('ct-note-text').focus();
});
tr.querySelector('.btn-red').addEventListener('click', () => {
pendingDeleteNoteId = id ? n.id : null;
pendingDeleteNoteIdx = idx;
pendingDeleteNoteContactId = id;
document.getElementById('note-delete-modal').style.display = 'flex';
});
rows.appendChild(tr);
});
}
// Re-bind listeners to support replacing old ones (using onclick to avoid duplicate listeners)
const btnNew = document.getElementById('ct-note-new-btn');
if (btnNew) btnNew.onclick = () => {
editingNoteId = null;
document.getElementById('ct-note-input-area').style.display = 'flex';
document.getElementById('ct-note-text').value = '';
document.getElementById('ct-note-text').focus();
};
const btnCancel = document.getElementById('ct-note-cancel');
if (btnCancel) btnCancel.onclick = () => {
editingNoteId = null;
document.getElementById('ct-note-input-area').style.display = 'none';
document.getElementById('ct-note-text').value = '';
};
const btnSave = document.getElementById('ct-note-save');
if (btnSave) btnSave.onclick = async () => {
const id = document.getElementById('ct-id').value;
const note = document.getElementById('ct-note-text').value.trim();
if (!note) { alert('请输入备注内容'); return; }
if (id) {
// Save to server
try {
if (editingNoteId) {
// Update existing
await apiFetchJSON(`/api/contacts/notes/${editingNoteId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note })
});
} else {
// Create new
await apiFetchJSON(`/api/contacts/${id}/notes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ note })
});
}
editingNoteId = null;
document.getElementById('ct-note-text').value = '';
document.getElementById('ct-note-input-area').style.display = 'none';
loadContactNotes(id);
} catch (e) {
alert('保存失败');
}
} else {
// Save to temp
const user = getAuthUser();
if (editingNoteId !== null) {
// Update temp
if (tempContactNotes[editingNoteId]) {
tempContactNotes[editingNoteId].note = note;
}
} else {
// Create temp
tempContactNotes.push({
note,
created_at: Date.now(),
created_by: user ? user.name : 'Unknown'
});
}
editingNoteId = null;
document.getElementById('ct-note-text').value = '';
document.getElementById('ct-note-input-area').style.display = 'none';
loadContactNotes(null);
}
};