mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 15:04:47 +00:00
feat: add Финансы tab (contract signing + payment tracking)
This commit is contained in:
parent
ce76d9771a
commit
88c69e36e6
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user