mirror of
https://github.com/wasrusgen/wasrusgen1-crm.git
synced 2026-06-03 19:44: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">
|
<a class="nav-item" href="#" onclick="switchTab('history');return false">
|
||||||
<span class="ni-icon">🕐</span><span class="ni-label">История</span>
|
<span class="ni-icon">🕐</span><span class="ni-label">История</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a class="nav-item" href="#" onclick="switchTab('finance');return false">
|
||||||
|
<span class="ni-icon">💰</span><span class="ni-label">Финансы</span>
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sb-footer">
|
<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-interview" onclick="switchTab('interview')">📋 Интервью</button>
|
||||||
<button class="tab-btn" id="tbtn-details" onclick="switchTab('details')">🗂 Реквизиты</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-history" onclick="switchTab('history')">🕐 История</button>
|
||||||
|
<button class="tab-btn" id="tbtn-finance" onclick="switchTab('finance')">💰 Финансы</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══ TAB: РАБОТА ══ -->
|
<!-- ══ TAB: РАБОТА ══ -->
|
||||||
@ -524,6 +528,87 @@ body{font-family:'Inter',sans-serif;background:#F5F6F8;color:#1A1A2E;height:100v
|
|||||||
</div>
|
</div>
|
||||||
</div><!-- /tab-history -->
|
</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 -->
|
</div><!-- /content-body -->
|
||||||
</main>
|
</main>
|
||||||
</div><!-- /layout -->
|
</div><!-- /layout -->
|
||||||
@ -664,7 +749,7 @@ function toggleWorkFormat(){
|
|||||||
toggleWorkFormat();
|
toggleWorkFormat();
|
||||||
|
|
||||||
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
// ── Tabs ─────────────────────────────────────────────────────────────────────
|
||||||
const TAB_IDS = ['work','interview','details','history'];
|
const TAB_IDS = ['work','interview','details','history','finance'];
|
||||||
function switchTab(id){
|
function switchTab(id){
|
||||||
TAB_IDS.forEach(t=>{
|
TAB_IDS.forEach(t=>{
|
||||||
document.getElementById('tab-'+t).classList.toggle('active', t===id);
|
document.getElementById('tab-'+t).classList.toggle('active', t===id);
|
||||||
@ -672,6 +757,7 @@ function switchTab(id){
|
|||||||
const navItem = document.querySelector(`.nav-item[onclick*="'${t}'"]`);
|
const navItem = document.querySelector(`.nav-item[onclick*="'${t}'"]`);
|
||||||
if(navItem) navItem.classList.toggle('active', t===id);
|
if(navItem) navItem.classList.toggle('active', t===id);
|
||||||
});
|
});
|
||||||
|
if(id==='finance') loadFinances();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Collapse (passport) ──────────────────────────────────────────────────────
|
// ── Collapse (passport) ──────────────────────────────────────────────────────
|
||||||
@ -950,6 +1036,145 @@ async function runSuggest(){
|
|||||||
btn.disabled=false;btn.textContent='✨ Сформировать';
|
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 ──────────────────────────────────────────────────────────────────────
|
// ── Save ──────────────────────────────────────────────────────────────────────
|
||||||
async function saveCard(){
|
async function saveCard(){
|
||||||
const status=document.getElementById('save-status');
|
const status=document.getElementById('save-status');
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user