feat: add Финансы tab (contract signing + payment tracking)

This commit is contained in:
wasrusgen 2026-05-28 17:00:02 +03:00
parent ce76d9771a
commit 88c69e36e6

View File

@ -211,6 +211,9 @@ body{font-family:'Inter',sans-serif;background:#F5F6F8;color:#1A1A2E;height:100v
<a class="nav-item" href="#" onclick="switchTab('history');return false">
<span class="ni-icon">🕐</span><span class="ni-label">История</span>
</a>
<a class="nav-item" href="#" onclick="switchTab('finance');return false">
<span class="ni-icon">💰</span><span class="ni-label">Финансы</span>
</a>
</nav>
<div class="sb-footer">
@ -267,6 +270,7 @@ body{font-family:'Inter',sans-serif;background:#F5F6F8;color:#1A1A2E;height:100v
<button class="tab-btn" id="tbtn-interview" onclick="switchTab('interview')">📋 Интервью</button>
<button class="tab-btn" id="tbtn-details" onclick="switchTab('details')">🗂 Реквизиты</button>
<button class="tab-btn" id="tbtn-history" onclick="switchTab('history')">🕐 История</button>
<button class="tab-btn" id="tbtn-finance" onclick="switchTab('finance')">💰 Финансы</button>
</div>
<!-- ══ TAB: РАБОТА ══ -->
@ -524,6 +528,87 @@ body{font-family:'Inter',sans-serif;background:#F5F6F8;color:#1A1A2E;height:100v
</div>
</div><!-- /tab-history -->
<!-- ══ TAB: ФИНАНСЫ ══ -->
<div class="tab-pane" id="tab-finance">
<!-- Договор -->
<div class="inner-section">
<div class="inner-title">
<span>Договор</span>
<span id="contract-chip"></span>
</div>
<div class="fields cols3" style="margin-bottom:16px">
<div class="field">
<label>Тариф</label>
<select id="fin-tariff" onchange="onTariffChange()">
<option value="express">Экспресс-диагностика (0 ₽)</option>
<option value="audit" selected>Полный аудит + план (150 000 ₽)</option>
<option value="impl">Внедрение под ключ (350 000 ₽)</option>
</select>
</div>
<div class="field">
<label>Сумма договора, ₽</label>
<input type="number" id="fin-price" value="150000" min="0" step="1000">
</div>
<div class="field" style="justify-content:flex-end;align-items:flex-end;display:flex;flex-direction:column">
<div style="display:flex;gap:8px;width:100%;justify-content:flex-end;align-items:flex-end;height:100%">
<button onclick="saveContract()" style="background:#F8FAFC;border:1.5px solid #E2E8F0;color:#64748B;padding:8px 14px;border-radius:8px;font-family:'Inter',sans-serif;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap">💾 Сохранить</button>
<button onclick="sendContract()" id="btn-send-contract" style="background:linear-gradient(135deg,#064E3B,#047857);color:#fff;border:none;padding:8px 16px;border-radius:8px;font-family:'Montserrat',sans-serif;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap;display:flex;align-items:center;gap:5px">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>Отправить клиенту
</button>
</div>
</div>
</div>
<div id="contract-sign-info" style="display:none;background:#ECFDF5;border:1.5px solid #A7F3D0;border-radius:10px;padding:12px 16px;font-size:13px;color:#047857;margin-top:4px"></div>
<div id="contract-send-msg" style="font-size:12px;color:#94A3B8;margin-top:6px"></div>
</div>
<!-- Просмотр договора -->
<div class="inner-section">
<div class="inner-title">Документ</div>
<a href="/consult/admin/client/{{code}}/contract" target="_blank" style="display:inline-flex;align-items:center;gap:7px;background:#F8FAFC;border:1.5px solid #E2E8F0;color:#047857;padding:9px 16px;border-radius:8px;font-size:13px;font-weight:600;text-decoration:none;transition:.2s" onmouseover="this.style.background='#F0FDF4'" onmouseout="this.style.background='#F8FAFC'">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Открыть договор для просмотра / редактирования
</a>
</div>
<!-- График платежей -->
<div class="inner-section">
<div class="inner-title">
<span>График платежей</span>
<span id="pay-summary-chip" style="font-size:11px;font-weight:600;color:#047857"></span>
</div>
<div id="payments-list"><div style="color:#94A3B8;font-size:13px">Договор ещё не отправлен клиенту</div></div>
</div>
<!-- Критерии приёмки -->
<div class="inner-section">
<div class="inner-title">Критерии приёмки услуг</div>
<div style="display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#F8FAFC;border-radius:8px;border:1px solid #E2E8F0">
<span style="font-size:16px;flex-shrink:0">1</span>
<div><div style="font-size:12px;font-weight:700;color:#0f172a;margin-bottom:2px">Диагностика AS-IS</div><div style="font-size:12px;color:#64748B">Передан отчёт + клиент подтвердил получение</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#F8FAFC;border-radius:8px;border:1px solid #E2E8F0">
<span style="font-size:16px;flex-shrink:0">2</span>
<div><div style="font-size:12px;font-weight:700;color:#0f172a;margin-bottom:2px">Аудит + TO-BE</div><div style="font-size:12px;color:#64748B">Согласован план внедрения (дорожная карта)</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#F8FAFC;border-radius:8px;border:1px solid #E2E8F0">
<span style="font-size:16px;flex-shrink:0">3</span>
<div><div style="font-size:12px;font-weight:700;color:#0f172a;margin-bottom:2px">Внедрение</div><div style="font-size:12px;color:#64748B">Подписан итоговый Акт об оказанных услугах</div></div>
</div>
<div style="display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#F8FAFC;border-radius:8px;border:1px solid #E2E8F0">
<span style="font-size:16px;flex-shrink:0">🔄</span>
<div><div style="font-size:12px;font-weight:700;color:#0f172a;margin-bottom:2px">Сопровождение</div><div style="font-size:12px;color:#64748B">Ежемесячный мини-акт по итогам периода</div></div>
</div>
<div style="background:#FFFBEB;border:1px solid #FDE68A;border-radius:8px;padding:10px 12px;font-size:12px;color:#92400E">
⚖️ По п. 3.4 договора: при отсутствии мотивированного отказа в течение 5 рабочих дней с момента направления Акта — услуги считаются принятыми.
</div>
</div>
</div>
</div><!-- /tab-finance -->
</div><!-- /content-body -->
</main>
</div><!-- /layout -->
@ -664,7 +749,7 @@ function toggleWorkFormat(){
toggleWorkFormat();
// ── Tabs ─────────────────────────────────────────────────────────────────────
const TAB_IDS = ['work','interview','details','history'];
const TAB_IDS = ['work','interview','details','history','finance'];
function switchTab(id){
TAB_IDS.forEach(t=>{
document.getElementById('tab-'+t).classList.toggle('active', t===id);
@ -672,6 +757,7 @@ function switchTab(id){
const navItem = document.querySelector(`.nav-item[onclick*="'${t}'"]`);
if(navItem) navItem.classList.toggle('active', t===id);
});
if(id==='finance') loadFinances();
}
// ── Collapse (passport) ──────────────────────────────────────────────────────
@ -950,6 +1036,145 @@ async function runSuggest(){
btn.disabled=false;btn.textContent='✨ Сформировать';
}
// ── Finances ──────────────────────────────────────────────────────────────────
const TARIFF_DEFAULTS = {express:0, audit:150000, impl:350000};
const TARIFF_NAMES = {express:'Экспресс-диагностика', audit:'Полный аудит + план', impl:'Внедрение под ключ'};
function onTariffChange(){
const t = document.getElementById('fin-tariff').value;
document.getElementById('fin-price').value = TARIFF_DEFAULTS[t] ?? 0;
}
async function loadFinances(){
try{
const r = await fetch('/consult/api/admin/finances/'+CODE, {credentials:'include'});
if(!r.ok) return;
const d = await r.json();
renderFinances(d);
}catch(e){}
}
function fmtRub(n){ return n===0?'0 ₽':(n.toLocaleString('ru')+' ₽'); }
function renderFinances(d){
const c = d.contract;
const chip = document.getElementById('contract-chip');
const signInfo = document.getElementById('contract-sign-info');
// Contract chip
const statusMap = {
draft: {bg:'#F1F5F9',color:'#64748B',text:'Черновик'},
sent: {bg:'#FEF3C7',color:'#D97706',text:'📨 Отправлен'},
signed: {bg:'#ECFDF5',color:'#047857',text:'✅ Подписан'},
};
if(c){
const s = statusMap[c.status] || statusMap.draft;
chip.innerHTML = `<span style="font-size:11px;font-weight:600;padding:2px 9px;border-radius:12px;background:${s.bg};color:${s.color};margin-left:8px">${s.text}</span>`;
// Prefill form
if(c.tariff) document.getElementById('fin-tariff').value = c.tariff;
if(c.price) document.getElementById('fin-price').value = c.price;
// Signed info
if(c.status === 'signed' && c.signed_at){
signInfo.style.display = 'block';
signInfo.innerHTML = `✅ <b>Подписан:</b> ${fmtTime(c.signed_at)} · IP: ${c.sign_ip||'—'}`;
} else {
signInfo.style.display = 'none';
}
} else {
chip.innerHTML = `<span style="font-size:11px;font-weight:600;padding:2px 9px;border-radius:12px;background:#F1F5F9;color:#94A3B8;margin-left:8px">Не создан</span>`;
}
// Payments
const payList = document.getElementById('payments-list');
const payChip = document.getElementById('pay-summary-chip');
if(!d.payments || !d.payments.length){
payList.innerHTML = '<div style="color:#94A3B8;font-size:13px">Договор ещё не отправлен клиенту — график платежей сформируется автоматически</div>';
payChip.textContent = '';
return;
}
const paidColor = d.balance===0 ? '#047857' : '#D97706';
payChip.textContent = `${fmtRub(d.paid)} из ${fmtRub(d.total)}`;
payChip.style.color = paidColor;
payList.innerHTML = `
<div style="display:grid;grid-template-columns:1fr 100px 100px 90px 130px;gap:0;border:1.5px solid #E5E7EB;border-radius:10px;overflow:hidden">
<div style="display:contents;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:#64748B">
<div style="padding:9px 14px;background:#F8FAFC;border-bottom:1px solid #E5E7EB">Этап</div>
<div style="padding:9px 10px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;text-align:right">Сумма</div>
<div style="padding:9px 10px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;text-align:right">Оплачено</div>
<div style="padding:9px 10px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;text-align:center">Статус</div>
<div style="padding:9px 10px;background:#F8FAFC;border-bottom:1px solid #E5E7EB;text-align:center">Действие</div>
</div>
${d.payments.map((p,i)=>{
const last = i===d.payments.length-1;
const isPaid = p.status==='paid';
const rowBg = isPaid ? '#F0FDF4' : '#fff';
const statusBadge = isPaid
? '<span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:#ECFDF5;color:#047857">✓ Оплачен</span>'
: '<span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:#FEF3C7;color:#D97706">Ожидает</span>';
const border = last?'':'border-bottom:1px solid #E5E7EB';
const action = isPaid
? `<button onclick="markPayment(${p.id},0)" style="font-size:11px;color:#94A3B8;background:none;border:none;cursor:pointer;padding:0">↩ Отменить</button>`
: `<button onclick="openMarkPayModal(${p.id},${p.amount})" style="font-size:11px;font-weight:600;color:#fff;background:#047857;border:none;padding:4px 10px;border-radius:6px;cursor:pointer">✓ Оплачен</button>`;
const noteSpan = p.note ? `<div style="font-size:11px;color:#94A3B8;margin-top:2px">${p.note}</div>` : '';
return `<div style="display:contents">
<div style="padding:10px 14px;background:${rowBg};${border}"><div style="font-size:13px;font-weight:600;color:#0f172a">${p.phase_label}</div>${noteSpan}${p.paid_at?`<div style="font-size:10px;color:#94A3B8">Оплачен: ${fmtTime(p.paid_at)}</div>`:''}</div>
<div style="padding:10px 10px;background:${rowBg};${border};text-align:right;font-size:13px;font-weight:600;color:#0f172a">${fmtRub(p.amount)}</div>
<div style="padding:10px 10px;background:${rowBg};${border};text-align:right;font-size:13px;color:${isPaid?'#047857':'#94A3B8'}">${fmtRub(p.paid_amount)}</div>
<div style="padding:10px 10px;background:${rowBg};${border};text-align:center;display:flex;align-items:center;justify-content:center">${statusBadge}</div>
<div style="padding:10px 10px;background:${rowBg};${border};text-align:center;display:flex;align-items:center;justify-content:center">${action}</div>
</div>`;
}).join('')}
<div style="display:contents;font-size:13px;font-weight:700;color:#0f172a">
<div style="padding:10px 14px;background:#F8FAFC;border-top:2px solid #E5E7EB">ИТОГО</div>
<div style="padding:10px 10px;background:#F8FAFC;border-top:2px solid #E5E7EB;text-align:right">${fmtRub(d.total)}</div>
<div style="padding:10px 10px;background:#F8FAFC;border-top:2px solid #E5E7EB;text-align:right;color:#047857">${fmtRub(d.paid)}</div>
<div style="padding:10px 10px;background:#F8FAFC;border-top:2px solid #E5E7EB;text-align:center;font-size:12px;color:${paidColor}">${d.balance===0?'✅ Закрыт':''+fmtRub(d.balance)}</div>
<div style="padding:10px 10px;background:#F8FAFC;border-top:2px solid #E5E7EB"></div>
</div>
</div>`;
}
async function saveContract(action='save'){
const tariff = document.getElementById('fin-tariff').value;
const price = parseInt(document.getElementById('fin-price').value)||0;
const msg = document.getElementById('contract-send-msg');
msg.textContent = action==='send'?'Отправляю…':'Сохраняю…'; msg.style.color='#94A3B8';
try{
const r = await fetch('/consult/api/admin/finances/'+CODE+'/contract', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({tariff, tariff_name: TARIFF_NAMES[tariff]||tariff, price, action})
});
const d = await r.json();
if(d.ok){
msg.textContent = action==='send'?'✓ Договор отправлен клиенту':'✓ Сохранено'; msg.style.color='#059669';
setTimeout(()=>{msg.textContent='';}, 3000);
await loadFinances();
} else { msg.textContent='Ошибка'; msg.style.color='#DC2626'; }
}catch(e){ msg.textContent='Ошибка подключения'; msg.style.color='#DC2626'; }
}
function sendContract(){
if(!confirm('Отправить договор клиенту? Будет сформирован график платежей и клиент получит доступ к документу.')) return;
saveContract('send');
}
async function markPayment(payId, amount){
await fetch('/consult/api/admin/finances/'+CODE+'/payment/'+payId, {
method:'PATCH', credentials:'include',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({paid_amount: amount})
});
await loadFinances();
}
function openMarkPayModal(payId, amount){
const entered = prompt(`Введите оплаченную сумму (₽):\nПлановая: ${fmtRub(amount)}`, amount);
if(entered===null) return;
const paid = parseInt(entered)||0;
markPayment(payId, paid);
}
// ── Save ──────────────────────────────────────────────────────────────────────
async function saveCard(){
const status=document.getElementById('save-status');