Commit d6fd7909 authored by wenwen.tang's avatar wenwen.tang 😕

feat(web): 台账「合同原件」列的按钮与合同管理浮层

- td.files 不再走打字编辑:有合同显示「📎 N 份」,编辑态空行显示「+ 合同」,只读空行显示 —
- openFilesDlg:列出文件名 / 大小 / 上传时间 + 查阅 / 下载 / 移除,底部「上传合同」;
  pickContract 走真实文件选择器(可多选,逐个上传,>20MB 跳过)
- 增删都走 stageCell(rowId,'AL','files',…) 暂存,与普通格子一致——
  回列表点「保存 N 处改动」才写台账;只读态浮层没有上传/移除入口
- 修 bug:renderGrid 的 files 分支原先读台账原值,上传后(暂存未保存)格子仍显示旧按钮 →
  改为优先读 TB_PEND 暂存值
- startEdit 对 files 列直接返回;delRow/startEdit 的行标签统一走 rowLabelOf()
- fmtVal 增加 files 分支(修改记录 / 确认框里显示文件名列表)
- CSS:td.files 按钮样式(pill + 虚线加号 + 暂存态黄底);admin.html 补用法说明
parent 09815170
...@@ -340,6 +340,9 @@ ...@@ -340,6 +340,9 @@
改动会先<b>暂存并高亮</b>(不逐格弹窗),改完点顶部「保存 N 处改动」时<b>统一确认一次</b>再写入; 改动会先<b>暂存并高亮</b>(不逐格弹窗),改完点顶部「保存 N 处改动」时<b>统一确认一次</b>再写入;
不想要就点「放弃改动」。Esc 取消当前单元格编辑。 不想要就点「放弃改动」。Esc 取消当前单元格编辑。
数值/金额列按类型校验,「合计」行为当前显示行的实时汇总。每次提交都会记入下方「修改记录」,可撤销。 数值/金额列按类型校验,「合计」行为当前显示行的实时汇总。每次提交都会记入下方「修改记录」,可撤销。
<br>最右边的<b>「合同原件」</b>列用来挂本单元的合同(存服务器),点格子里的按钮可以<b>查阅 / 下载</b>合同;
编辑模式下同一个浮层里能<b>上传</b>或<b>移除</b>(一份可挂多份:租赁合同 + 补充协议 + 车位合同)。
支持 PDF / Word / Excel / 图片,单个 ≤ 20MB;「移除」只解除本行关联,不会删掉服务器上的文件。
<br>数据仅初次导入有效,之后不提供导入;增删改一律在页面完成。 <br>数据仅初次导入有效,之后不提供导入;增删改一律在页面完成。
</div> </div>
</div> </div>
......
...@@ -3292,6 +3292,8 @@ let TB_FILTER = ''; ...@@ -3292,6 +3292,8 @@ let TB_FILTER = '';
let TB_EDIT = false; // 编辑模式:默认关闭(锁定只读) let TB_EDIT = false; // 编辑模式:默认关闭(锁定只读)
let TB_HIST = []; // 修改记录(最近 10 步,可撤销) let TB_HIST = []; // 修改记录(最近 10 步,可撤销)
let TB_PEND = {}; // 批量编辑:暂存的单元格改动(key = rowId|col) let TB_PEND = {}; // 批量编辑:暂存的单元格改动(key = rowId|col)
let FDLG = null; // 「合同原件」浮层当前正在管理的行 id
let FINDEX = null; // 合同索引缓存(id → 元信息),进台账页时重置
function nowStr() { function nowStr() {
const d = new Date(); const d = new Date();
...@@ -3309,6 +3311,8 @@ async function loadTable(key) { ...@@ -3309,6 +3311,8 @@ async function loadTable(key) {
TB_KEY = key; TB_KEY = key;
TB_HIST = []; TB_HIST = [];
TB_PEND = {}; TB_PEND = {};
FDLG = null;
FINDEX = null; // 合同索引每次进页重拉,避免缓存过期
$('#tbWrap').innerHTML = '<div class="empty">加载中…</div>'; $('#tbWrap').innerHTML = '<div class="empty">加载中…</div>';
try { try {
const d = await api('/api/tables/' + key); const d = await api('/api/tables/' + key);
...@@ -3346,6 +3350,7 @@ function fmtVal(v, type) { ...@@ -3346,6 +3350,7 @@ function fmtVal(v, type) {
if (type === 'num') return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 4 }); if (type === 'num') return Number(v).toLocaleString('zh-CN', { maximumFractionDigits: 4 });
if (type === 'int') return String(v); if (type === 'int') return String(v);
if (type === 'pct') return (Number(v) * 100).toFixed(2) + '%'; if (type === 'pct') return (Number(v) * 100).toFixed(2) + '%';
if (type === 'files') return filesOf(v).map(f => f.name || f.id).join('、');
return String(v); return String(v);
} }
...@@ -3420,7 +3425,19 @@ function renderGrid() { ...@@ -3420,7 +3425,19 @@ function renderGrid() {
if (c.type === 'money' || c.type === 'num' || c.type === 'pct') cls.push('num'); if (c.type === 'money' || c.type === 'num' || c.type === 'pct') cls.push('num');
if (j < pin) cls.push('pin'); if (j < pin) cls.push('pin');
const left = j < pin ? ` style="left:${lefts[j]}px"` : ''; const left = j < pin ? ` style="left:${lefts[j]}px"` : '';
tds += `<td class="${cls.join(' ')}"${left} data-row="${esc(r._id)}" data-col="${esc(c.id)}"` + const attr = ` data-row="${esc(r._id)}" data-col="${esc(c.id)}"`;
if (c.type === 'files') { // 合同原件:按钮进浮层,不在格子里打字
const pend = TB_PEND[r._id + '|' + c.id]; // 已暂存的改动优先显示
const n = filesOf(pend ? pend.to : raw).length;
const inner = n
? `<button class="fbtn" data-files="${esc(r._id)}" title="查阅 / 管理本行合同原件">📎 ${n} 份</button>`
: (TB_EDIT
? `<button class="fbtn add" data-files="${esc(r._id)}" title="上传合同原件">+ 合同</button>`
: '<span class="muted">—</span>');
tds += `<td class="${cls.join(' ')} files"${left}${attr} title="${esc(shown)}">${inner}</td>`;
return;
}
tds += `<td class="${cls.join(' ')}"${left}${attr}` +
` title="${esc(String(shown))}">${esc(shown)}</td>`; ` title="${esc(String(shown))}">${esc(shown)}</td>`;
}); });
tds += `<td class="act">${TB_EDIT tds += `<td class="act">${TB_EDIT
...@@ -3474,6 +3491,7 @@ function bindGridEvents() { ...@@ -3474,6 +3491,7 @@ function bindGridEvents() {
if (TB_EDIT) { if (TB_EDIT) {
wrap.querySelectorAll('td.cell').forEach(td => { wrap.querySelectorAll('td.cell').forEach(td => {
if (td.classList.contains('files')) return; // 合同列走按钮,不进打字编辑
td.addEventListener('click', () => startEdit(td)); td.addEventListener('click', () => startEdit(td));
}); });
wrap.querySelectorAll('[data-delrow]').forEach(b => b.addEventListener('click', ev => { wrap.querySelectorAll('[data-delrow]').forEach(b => b.addEventListener('click', ev => {
...@@ -3481,6 +3499,11 @@ function bindGridEvents() { ...@@ -3481,6 +3499,11 @@ function bindGridEvents() {
delRow(b.dataset.delrow); delRow(b.dataset.delrow);
})); }));
} }
// 合同列按钮:只读态也能查阅
wrap.querySelectorAll('[data-files]').forEach(b => b.addEventListener('click', ev => {
ev.stopPropagation();
openFilesDlg(b.dataset.files);
}));
} }
/* ---------------- 编辑锁 ---------------- */ /* ---------------- 编辑锁 ---------------- */
...@@ -3710,6 +3733,139 @@ async function undoAt(i) { ...@@ -3710,6 +3733,139 @@ async function undoAt(i) {
} catch (e) { toast(e.message, 'err'); } } catch (e) { toast(e.message, 'err'); }
} }
/* ---------------- 合同原件(台账「合同原件」列 · type=files) ---------------- */
/* 单元格里只存 [{id, name}] 的紧凑 JSON,本体在服务器 data/contracts/。
增删都走 stageCell 暂存,和普通格子一样要点「保存 N 处改动」才落盘;
上传本身是即时请求(文件已进服务器),只是关联关系随保存一起写入。 */
function filesOf(v) {
if (!v) return [];
if (Array.isArray(v)) return v.slice();
try { const a = JSON.parse(v); return Array.isArray(a) ? a : []; } catch (e) { return []; }
}
function fileUrl(id) {
return '/api/contracts/' + encodeURIComponent(id) + '?token=' + encodeURIComponent(token());
}
/** 行的识别标签:房间号优先(与单元格编辑 / 删除行的口径一致) */
function rowLabelOf(rowId) {
const row = (TB.table.rows || []).find(x => x._id === rowId) || {};
return [row.C || row.D || row.B || row._id, row.F || row.G].filter(Boolean).join(' · ');
}
/** 该行合同列"当前值":优先取暂存值(还没保存的改动) */
function filesValueOf(rowId) {
const p = TB_PEND[rowId + '|AL'];
return filesOf(p ? p.to : rawOf(rowId, 'AL'));
}
async function ensureFileIndex() {
if (FINDEX) return FINDEX;
FINDEX = {};
try {
const d = await api('/api/contracts');
(d.contracts || []).forEach(c => { FINDEX[c.id] = c; });
} catch (e) { /* 索引拿不到不影响查阅,只是少显示大小/时间 */ }
return FINDEX;
}
/** 改本行合同列(暂存,不落盘) */
function setFiles(rowId, list) {
const to = list.length ? JSON.stringify(list) : '';
stageCell(rowId, 'AL', 'files', rowLabelOf(rowId), rawOf(rowId, 'AL'), to);
renderGrid();
if (FDLG === rowId && $('#modal').classList.contains('show')) renderFilesDlg(rowId);
}
function openFilesDlg(rowId) {
FDLG = rowId;
openModal('合同原件 · ' + rowLabelOf(rowId), '<div class="empty">加载中…</div>');
renderFilesDlg(rowId);
}
async function renderFilesDlg(rowId) {
const idx = await ensureFileIndex();
if (FDLG !== rowId) return; // 期间又换了别的行
const list = filesValueOf(rowId);
const canWrite = TB_EDIT && !isViewer();
const body = list.length
? `<div class="table-wrap"><table>
<thead><tr><th>文件名</th><th style="width:90px">大小</th>
<th style="width:150px">上传时间</th><th style="width:190px">操作</th></tr></thead>
<tbody>${list.map((f, k) => {
const m = idx[f.id] || {};
return `<tr>
<td><a href="${esc(fileUrl(f.id))}" target="_blank" rel="noopener">${esc(f.name || m.name || '合同原件')}</a></td>
<td class="num">${m.size ? esc(fmtSize(m.size)) : '—'}</td>
<td class="muted">${esc(m.at || '—')}</td>
<td>
<a href="${esc(fileUrl(f.id))}" target="_blank" rel="noopener"><button class="sm">查阅</button></a>
<a href="${esc(fileUrl(f.id))}&dl=1"><button class="sm">下载</button></a>
${canWrite ? `<button class="sm danger" data-fdel="${k}">移除</button>` : ''}
</td></tr>`;
}).join('')}</tbody></table></div>`
: `<div class="empty">本行还没有挂合同原件${canWrite ? ',点下面的「上传合同」选文件' : ''}。</div>`;
$('#modalBody').innerHTML = `${body}
<div class="spacer"></div>
<div class="muted" style="font-size:12px">
支持 PDF / Word / Excel / 图片,单个文件不超过 20MB。
${canWrite
? '从本行移除只解除关联,不会删掉服务器上的文件(别的行若也挂了同一份,照常可查阅)。' +
'改动会先<b>暂存</b>,回列表点「保存 N 处改动」才写入台账。'
: '需要增删合同请先在上方勾选「编辑模式」。'}
</div>
<div class="spacer"></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
${canWrite ? '<button id="fdlg-up" class="primary">上传合同</button>' : ''}
<button onclick="closeModal()">关闭</button>
</div>`;
const up = $('#fdlg-up');
if (up) up.addEventListener('click', () => pickContract(rowId));
$('#modalBody').querySelectorAll('[data-fdel]').forEach(b => {
b.addEventListener('click', () => removeContract(rowId, Number(b.dataset.fdel)));
});
}
async function pickContract(rowId) {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.pdf,.doc,.docx,.xls,.xlsx,.png,.jpg,.jpeg,.gif,.webp,.bmp';
inp.multiple = true;
inp.onchange = async () => {
const files = Array.from(inp.files || []);
if (!files.length) return;
const cur = filesValueOf(rowId).slice();
let added = 0;
for (const f of files) {
if (f.size > 20 * 1024 * 1024) { toast(`「${f.name}」超过 20MB,已跳过`, 'err'); continue; }
try {
const dataUrl = await readAsDataURL(f);
const d = await api('/api/contracts', {
method: 'POST', body: { filename: f.name, data: dataUrl },
});
if (FINDEX) FINDEX[d.contract.id] = d.contract;
cur.push({ id: d.contract.id, name: d.contract.name });
added++;
toast(`已上传:${d.contract.name}(${fmtSize(d.contract.size)})`, 'ok');
} catch (e) { toast(`「${f.name}」上传失败:${e.message}`, 'err'); }
}
if (added) setFiles(rowId, cur);
};
inp.click();
}
function removeContract(rowId, k) {
const cur = filesValueOf(rowId).slice();
const f = cur[k];
if (!f) return;
if (!confirm(`从本行移除合同「${f.name || f.id}」?\n\n只解除本行的关联,服务器上的文件仍保留。`)) return;
cur.splice(k, 1);
setFiles(rowId, cur);
toast('已移除关联,记得点「保存改动」落盘', 'ok');
}
/* ---------------- 单元格编辑 ---------------- */ /* ---------------- 单元格编辑 ---------------- */
async function startEdit(td) { async function startEdit(td) {
if (!TB_EDIT) return; if (!TB_EDIT) return;
...@@ -3719,6 +3875,7 @@ async function startEdit(td) { ...@@ -3719,6 +3875,7 @@ async function startEdit(td) {
const old = td.innerHTML; const old = td.innerHTML;
const row = (TB.table.rows || []).find(x => x._id === rowId) || {}; const row = (TB.table.rows || []).find(x => x._id === rowId) || {};
const colDef = (TB.table.columns || []).find(x => x.id === col) || { label: col, type: 'text' }; const colDef = (TB.table.columns || []).find(x => x.id === col) || { label: col, type: 'text' };
if (colDef.type === 'files') return; // 附件列走浮层,不进打字编辑
// 「现租客」「出租方」「免租类型」给出候选(仍可自由输入不在册的值) // 「现租客」「出租方」「免租类型」给出候选(仍可自由输入不在册的值)
const dlAttr = colDef.label === '现租客' ? ' list="tbCompanies"' const dlAttr = colDef.label === '现租客' ? ' list="tbCompanies"'
...@@ -3746,8 +3903,7 @@ async function startEdit(td) { ...@@ -3746,8 +3903,7 @@ async function startEdit(td) {
return; return;
} }
// 批量编辑:先暂存(不落盘),点「保存 N 处改动」时统一确认后一次写入 // 批量编辑:先暂存(不落盘),点「保存 N 处改动」时统一确认后一次写入
const label = [row.C || row.D || row.B || row._id, row.F || row.G] const label = rowLabelOf(rowId);
.filter(Boolean).join(' · ');
stageCell(rowId, col, colDef.type, label, raw, val); stageCell(rowId, col, colDef.type, label, raw, val);
td.innerHTML = val === '' ? '' : esc(val); td.innerHTML = val === '' ? '' : esc(val);
td.title = val; td.title = val;
...@@ -3767,7 +3923,7 @@ async function delRow(id) { ...@@ -3767,7 +3923,7 @@ async function delRow(id) {
const idx = rows.findIndex(x => x._id === id); const idx = rows.findIndex(x => x._id === id);
const r = rows[idx] || {}; const r = rows[idx] || {};
// 行的识别标签:房间号优先(理由同上方单元格编辑) // 行的识别标签:房间号优先(理由同上方单元格编辑)
const label = [r.C || r.D || r.B || id, r.F || r.G].filter(Boolean).join(' · '); const label = rowLabelOf(id);
const ok = await confirmBox({ const ok = await confirmBox({
title: '确认删除整行', title: '确认删除整行',
rows: [ rows: [
......
...@@ -552,6 +552,21 @@ table.edgrid td.cell input { ...@@ -552,6 +552,21 @@ table.edgrid td.cell input {
outline: none; background: #fff; outline: none; background: #fff;
} }
/* 「合同原件」列:格子里是查阅 / 上传按钮,不打字 */
table.edgrid td.files { text-align: center; cursor: default; padding: 3px 6px; }
table.edgrid td.files:hover { background: #f4f8ff; }
table.edgrid td.files button.fbtn {
padding: 2px 9px; height: auto; font-size: 12px; border-radius: 999px;
background: #eef4ff; border: 1px solid #c9dcff; color: #1f4e9c;
white-space: nowrap;
}
table.edgrid td.files button.fbtn:hover { background: #dceaff; border-color: #a9c8ff; }
table.edgrid td.files button.fbtn.add {
background: #f7f9fc; border-style: dashed; border-color: #cfd8e6; color: #7b8798;
}
table.edgrid td.files button.fbtn.add:hover { background: #eaf2ff; border-color: #a9c8ff; color: #1f4e9c; }
table.edgrid td.files.pending button.fbtn { background: #fffbeb; border-color: #fcd34d; color: #8a6100; }
table.edgrid tfoot td { table.edgrid tfoot td {
position: sticky; bottom: 0; z-index: 6; position: sticky; bottom: 0; z-index: 6;
background: #fff8e6; font-weight: 600; color: #7a5a00; background: #fff8e6; font-weight: 600; color: #7a5a00;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment