Portal, logo, workspace shortcut

This commit is contained in:
KabundiNjuki
2026-10-05 16:04:29 +03:00
parent eafa1242db
commit 72046591a3
95 changed files with 5480 additions and 793 deletions
+17
View File
@@ -0,0 +1,17 @@
{% extends "templates/web.html" %}
{% block title %}Customer Onboarding · MAKAP{% endblock %}
{% block page_content %}
<div id="makap-onboarding" class="onboarding-page">
<style>
#makap-onboarding{font-family:Inter,system-ui,sans-serif;background:#f3f5f9;color:#12182b;min-height:calc(100vh - 60px);padding:28px 34px 55px}#makap-onboarding *{box-sizing:border-box}.onb-head{display:flex;justify-content:space-between;align-items:flex-end;gap:20px;max-width:1280px;margin:auto}.onb-eyebrow{text-transform:uppercase;letter-spacing:.1em;font-size:11px;font-weight:700;color:#6f7b91;margin:0 0 8px}.onb-head h1{font-size:30px;margin:0 0 7px;letter-spacing:-.04em}.onb-head p{color:#667086;margin:0;font-size:14px}.onb-actions{display:flex;gap:9px}.onb-btn{border:1px solid #dfe4ed;border-radius:8px;background:#fff;color:#26324b;padding:11px 15px;font-weight:700;cursor:pointer}.onb-btn.primary{background:#2e6be6;border-color:#2e6be6;color:#fff}.onb-btn:hover{filter:brightness(.96)}.onb-summary{max-width:1280px;margin:26px auto 18px;display:grid;grid-template-columns:repeat(6,1fr);gap:12px}.onb-card{background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:16px 17px;cursor:pointer}.onb-card.active{border-color:#2e6be6;box-shadow:0 0 0 2px #dbe6ff}.onb-card label{display:block;color:#707b91;font-size:12px;margin-bottom:10px}.onb-card strong{font-size:27px;letter-spacing:-.04em}.onb-toolbar{max-width:1280px;margin:0 auto 12px;background:#fff;border:1px solid #e7eaf1;border-radius:12px;padding:13px;display:flex;gap:10px;align-items:center}.onb-search{flex:1;border:1px solid #dfe4ed;border-radius:7px;padding:11px 13px;font-size:13px;min-width:170px}.onb-select{border:1px solid #dfe4ed;border-radius:7px;background:#fff;padding:11px 13px;font-size:13px}.onb-table-wrap{max-width:1280px;margin:auto;background:#fff;border:1px solid #e7eaf1;border-radius:12px;overflow:auto}.onb-table{border-collapse:collapse;width:100%;font-size:13px}.onb-table th{text-align:left;text-transform:uppercase;letter-spacing:.08em;color:#8791a4;font-size:10px;padding:14px 15px;border-bottom:1px solid #e7eaf1;white-space:nowrap}.onb-table td{padding:15px;border-bottom:1px solid #f0f2f6;white-space:nowrap}.onb-table tr:hover{background:#f8faff}.onb-id{color:#1e4fbe;font-weight:800;cursor:pointer}.onb-muted{color:#69748b}.onb-badge{border-radius:20px;padding:5px 9px;font-size:11px;font-weight:700;background:#eef3ff;color:#2e6be6}.onb-badge.review{background:#fff5e5;color:#b46b17}.onb-badge.approved{background:#eaf8f0;color:#1f8a5c}.onb-badge.rejected{background:#fff0f2;color:#c23f60}.onb-empty{text-align:center;padding:42px;color:#8a94a7}.onb-modal-backdrop{position:fixed;inset:0;background:#0f1b2d99;z-index:99;display:none;align-items:center;justify-content:center;padding:20px}.onb-modal-backdrop.open{display:flex}.onb-modal{background:#fff;border-radius:14px;width:min(760px,100%);max-height:90vh;overflow:auto;padding:25px;box-shadow:0 25px 70px #0f1b2d44}.onb-modal-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:22px}.onb-modal h2{margin:0;font-size:22px}.onb-close{border:0;background:transparent;font-size:25px;cursor:pointer;color:#6b7588}.onb-form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.onb-field{display:flex;flex-direction:column;gap:6px}.onb-field.full{grid-column:1/-1}.onb-field label{font-size:12px;font-weight:700;color:#47536b}.onb-field input,.onb-field select,.onb-field textarea{font:inherit;border:1px solid #dfe4ed;border-radius:7px;padding:10px 11px}.onb-field textarea{min-height:74px;resize:vertical}.onb-form-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:21px}.onb-detail{display:grid;grid-template-columns:1fr 1fr;gap:14px}.onb-detail div{background:#f7f9fc;border-radius:8px;padding:11px}.onb-detail small{color:#7d879a;display:block;font-size:10px;text-transform:uppercase;letter-spacing:.07em;margin-bottom:4px}.onb-detail span{font-size:13px}.onb-loading{padding:40px;text-align:center;color:#7c879b}@media(max-width:900px){.onb-summary{grid-template-columns:repeat(3,1fr)}.onb-head{display:block}.onb-actions{margin-top:18px}}@media(max-width:600px){ #makap-onboarding{padding:20px 13px 40px}.onb-summary{grid-template-columns:repeat(2,1fr)}.onb-toolbar{display:block}.onb-search,.onb-select{width:100%;margin-bottom:8px}.onb-form-grid,.onb-detail{grid-template-columns:1fr}}
</style>
<div class="onb-head"><div><p class="onb-eyebrow">Customer & commercial · M1–2</p><h1>Customer onboarding</h1><p>Capture, review, and convert new customer requests.</p></div><div class="onb-actions"><button class="onb-btn" id="onb-refresh">Refresh</button><button class="onb-btn primary" id="onb-new">+ New onboarding request</button></div></div>
<div class="onb-summary" id="onb-summary"></div>
<div class="onb-toolbar"><input class="onb-search" id="onb-search" placeholder="Search organisation, contact, email, or request ID…"><select class="onb-select" id="onb-status"><option>All</option><option>Submitted</option><option>Under Review</option><option>Approved</option><option>Completed</option><option>Rejected</option></select></div>
<div class="onb-table-wrap"><table class="onb-table"><thead><tr><th>Request</th><th>Organisation</th><th>Contact</th><th>Location</th><th>Status</th><th>Customer match</th><th>Created</th></tr></thead><tbody id="onb-rows"><tr><td class="onb-loading" colspan="7">Loading onboarding requests…</td></tr></tbody></table></div>
<div class="onb-modal-backdrop" id="onb-modal"><div class="onb-modal"><div class="onb-modal-head"><div><p class="onb-eyebrow">MAKAP request</p><h2 id="onb-modal-title">New onboarding request</h2></div><button class="onb-close" id="onb-close">×</button></div><form id="onb-form"><div class="onb-form-grid"><div class="onb-field"><label>Organisation name *</label><input name="organisation_name" required></div><div class="onb-field"><label>Trading name</label><input name="trading_name"></div><div class="onb-field"><label>Customer type *</label><select name="customer_type" required><option value="Company">Company</option><option value="Individual">Individual</option></select></div><div class="onb-field"><label>Business tax identifier</label><input name="business_tax_identifier"></div><div class="onb-field"><label>Contact name *</label><input name="contact_name" required></div><div class="onb-field"><label>Email address *</label><input name="email_id" type="email" required></div><div class="onb-field"><label>Telephone</label><input name="telephone"></div><div class="onb-field"><label>Location</label><input name="location"></div><div class="onb-field full"><label>Submitted address</label><textarea name="submitted_address"></textarea></div><div class="onb-field full"><label>Product interest</label><textarea name="product_interest"></textarea></div><div class="onb-field full"><label>Initial enquiry</label><textarea name="initial_enquiry"></textarea></div><div class="onb-field"><label>Registration source *</label><select name="registration_source" required><option value="Website">Website</option><option value="Assisted">Assisted</option></select></div><div class="onb-field"><label>Assisted channel</label><input name="assisted_channel"></div><div class="onb-field full"><label><input type="checkbox" name="privacy_notice_acknowledged" value="1" required> Privacy notice acknowledged *</label></div></div><div class="onb-form-actions"><button type="button" class="onb-btn" id="onb-cancel">Cancel</button><button class="onb-btn primary" type="submit">Create request</button></div></form><div id="onb-detail-content"></div></div></div>
</div>
<script>
(function(){const A='/api/method/';const call=(method,args={})=>fetch(A+method,{method:'POST',headers:{'Content-Type':'application/json','X-Frappe-CSRF-Token':window.frappe?.csrf_token||''},body:JSON.stringify(args)}).then(r=>r.json()).then(r=>{if(r.exc)throw Error('Request failed');return r.message});const $=s=>document.querySelector(s);const esc=v=>String(v??'—').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));let selected='All';const labels={all:'All requests',submitted:'Submitted',under_review:'Under review',approved:'Approved',completed:'Completed',rejected:'Rejected'};function badge(s){let c=s==='Approved'||s==='Completed'?'approved':s==='Under Review'?'review':s==='Rejected'?'rejected':'';return `<span class="onb-badge ${c}">${esc(s||'—')}</span>`}function date(v){return v?new Date(String(v).replace(' ','T')).toLocaleDateString(): '—'}function summary(d){$('#onb-summary').innerHTML=Object.entries(labels).map(([k,l])=>`<div class="onb-card ${selected===(k==='all'?'All':k==='under_review'?'Under Review':k[0].toUpperCase()+k.slice(1))?'active':''}" data-status="${k==='all'?'All':k==='under_review'?'Under Review':k[0].toUpperCase()+k.slice(1)}"><label>${l}</label><strong>${Number(d[k]||0).toLocaleString()}</strong></div>`).join('');document.querySelectorAll('.onb-card').forEach(x=>x.onclick=()=>{selected=x.dataset.status;$('#onb-status').value=selected;load()})}function rows(data){$('#onb-rows').innerHTML=data.length?data.map(x=>`<tr><td class="onb-id" data-id="${esc(x.name)}">${esc(x.name)}</td><td><strong>${esc(x.organisation_name)}</strong><br><span class="onb-muted">${esc(x.trading_name||'')}</span></td><td>${esc(x.contact_name)}<br><span class="onb-muted">${esc(x.email_id)}</span></td><td>${esc(x.location)}</td><td>${badge(x.onboarding_status)}</td><td>${esc(x.customer_match_result)}</td><td>${date(x.creation)}</td></tr>`).join(''):'<tr><td colspan="7" class="onb-empty">No onboarding requests match your filters.</td></tr>';document.querySelectorAll('.onb-id').forEach(x=>x.onclick=()=>detail(x.dataset.id))}async function load(){try{const [s,r]=await Promise.all([call('makap_backend.makap_backend.onboarding_api.get_onboarding_summary'),call('makap_backend.makap_backend.onboarding_api.get_onboarding_requests',{status:$('#onb-status').value,search:$('#onb-search').value})]);summary(s);rows(r)}catch(e){$('#onb-rows').innerHTML='<tr><td colspan="7" class="onb-empty">Unable to load onboarding data. Check your Frappe permissions.</td></tr>'}}function open(){ $('#onb-modal').classList.add('open');$('#onb-form').style.display='block';$('#onb-detail-content').innerHTML='';$('#onb-modal-title').textContent='New onboarding request'}function close(){ $('#onb-modal').classList.remove('open')}async function detail(name){try{const x=await call('makap_backend.makap_backend.onboarding_api.get_onboarding_request',{name});$('#onb-modal-title').textContent=x.organisation_name||x.name;$('#onb-form').style.display='none';$('#onb-detail-content').innerHTML='<div class="onb-detail">'+[['Request',x.name],['Status',x.onboarding_status],['Organisation',x.organisation_name],['Trading name',x.trading_name],['Customer type',x.customer_type],['Tax identifier',x.business_tax_identifier],['Contact',x.contact_name],['Email',x.email_id],['Telephone',x.telephone],['Location',x.location],['Customer match',x.customer_match_result],['Portal access',x.portal_access_status],['Product interest',x.product_interest],['Initial enquiry',x.initial_enquiry],['Review notes',x.review_notes]].map(([a,b])=>`<div><small>${a}</small><span>${esc(b)}</span></div>`).join('')+'</div>';$('#onb-modal').classList.add('open')}catch(e){alert('Unable to open this request')}}$('#onb-new').onclick=open;$('#onb-close').onclick=close;$('#onb-cancel').onclick=close;$('#onb-refresh').onclick=load;$('#onb-status').onchange=()=>{selected=$('#onb-status').value;load()};let timer;$('#onb-search').oninput=()=>{clearTimeout(timer);timer=setTimeout(load,250)};$('#onb-form').onsubmit=async e=>{e.preventDefault();const data=Object.fromEntries(new FormData(e.target));data.privacy_notice_acknowledged=data.privacy_notice_acknowledged?1:0;try{const name=await call('makap_backend.makap_backend.onboarding_api.create_onboarding_request',{data:JSON.stringify(data)});close();await load();detail(name)}catch(err){alert('Unable to create request. Check required fields and permissions.')}};load()})();
</script>
{% endblock %}
+688
View File
@@ -0,0 +1,688 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>MAKAP Backend — Portal</title>
<link rel="icon" href="/assets/makap_backend/images/makap-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{--brand:#B85807;--brand-d:#9C4A06;--brand-l:#FBE2C4;--ink:#1c1f1a;--soft:#5c6459;--mute:#6b7568;--line:#E5E7DF;--bg:#F7F8F4;--card:#fff;--red:#c23f60;--amber:#B08900;--blue:#2E6BE6;--purple:#6E5BD6;--ok:#2f7d4f}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;font-family:'Inter',system-ui,sans-serif;background:var(--bg);color:var(--ink);font-size:14.5px;line-height:1.5;-webkit-font-smoothing:antialiased;padding-top:env(safe-area-inset-top,0px);padding-bottom:env(safe-area-inset-bottom,0px)}
a{color:inherit;text-decoration:none}
button{font:inherit;cursor:pointer}
.shell{display:flex;min-height:100%}
.sidebar{width:275px;flex:0 0 275px;background:#fff;border-right:1px solid var(--line);display:flex;flex-direction:column;padding:20px 14px;position:sticky;top:0;height:100vh;overflow-y:auto;z-index:40}
.brand{display:flex;align-items:center;gap:10px;padding:2px 8px 14px}
.brand-logo{width:38px;height:30px;object-fit:contain;flex:0 0 38px}
.brand-mark{width:30px;height:30px;border-radius:8px;background:linear-gradient(135deg,var(--brand),#FFAD5C);display:flex;align-items:center;justify-content:center;font-weight:800;font-size:12.5px;color:#fff}
.brand-name{font-weight:800;font-size:16px;line-height:1.1}
.brand-sub{font-size:11px;color:var(--mute);letter-spacing:.04em}
.nav-group-label{font-size:11.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--mute);padding:14px 10px 6px;font-weight:700;cursor:pointer;user-select:none;display:flex;justify-content:space-between}
.nav-group-label::after{content:'⌄';opacity:.7;transition:transform .15s}
.nav-group.collapsed .nav-group-label::after{transform:rotate(-90deg)}
.nav-group.collapsed .nav-item{display:none}
.nav-item{display:flex;align-items:center;gap:10px;padding:9px 10px;border-radius:8px;font-size:14px;font-weight:500;color:#4a5240;margin-bottom:1px;min-height:38px}
.nav-item:hover{background:var(--brand-l);color:var(--ink)}
.nav-item.active{background:var(--brand);color:#fff;box-shadow:0 5px 14px rgba(184,88,7,.25)}
.nav-item .ico{flex:0 0 17px;width:17px;height:17px;opacity:.85}
.nav-item .dot{flex:0 0 7px;width:7px;height:7px;border-radius:50%;background:#cfd3c6;margin:0 5px}
.nav-item.active .dot{background:#fff}
.nav-tag{margin-left:auto;font-size:10px;font-weight:700;padding:1px 7px;border-radius:20px;white-space:nowrap;color:var(--mute)}
.nav-tag.count{background:var(--brand-l);color:var(--brand-d)}
.nav-item.active .nav-tag{background:rgba(255,255,255,.25);color:#fff}
.sidebar-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--line);display:flex;gap:8px}
.sidebar-foot a,.sidebar-foot button{flex:1;text-align:center;border:1px solid var(--line);background:#fff;border-radius:8px;padding:8px;font-size:12.5px;font-weight:600;color:#4a5240}
.sidebar-foot a:hover,.sidebar-foot button:hover{background:var(--brand-l)}
.backdrop{display:none;position:fixed;inset:0;background:rgba(28,31,26,.4);z-index:35}
.main{flex:1;min-width:0}
.topbar{height:64px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;padding:0 26px;position:sticky;top:env(safe-area-inset-top,0px);z-index:20}
.menu-btn{display:none;border:1px solid var(--line);background:#fff;border-radius:8px;width:38px;height:38px;font-size:18px}
.crumb{font-size:14.5px;color:var(--soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.crumb b{color:var(--ink);font-weight:600}
.gsearch{margin-left:auto;width:340px;max-width:100%;border:1px solid var(--line);background:var(--bg);border-radius:8px;padding:9px 13px;font:inherit;font-size:13.5px}
.gsearch:focus{outline:2px solid var(--brand-l);border-color:var(--brand)}
.avatar{width:34px;height:34px;border-radius:50%;background:var(--brand-l);color:var(--brand-d);display:flex;align-items:center;justify-content:center;font-weight:700;font-size:13px;flex:0 0 34px}
.content{padding:28px 34px 60px;max-width:1300px}
.hero{background:linear-gradient(115deg,#FDEEDD 0%,#FBE2C4 42%,#fff 100%);border:1px solid #F3DCC0;border-radius:16px;padding:26px 30px;margin-bottom:22px;display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap}
.hero h1{margin:0 0 4px;font-size:26px;font-weight:800;letter-spacing:-.4px}
.hero p{margin:0;color:#4a5240}
.btn{border:1px solid #E3E6DD;background:#fff;color:#23281e;border-radius:8px;padding:9px 14px;font-weight:700;font-size:13px}
.btn:hover{background:var(--brand-l)}
.btn.primary{background:var(--brand);border-color:var(--brand);color:#fff}
.btn.primary:hover{background:var(--brand-d)}
.btn.danger{color:var(--red);border-color:#f3c9c7}
.btn.danger:hover{background:#fbeaea}
.btn[disabled]{opacity:.55;cursor:not-allowed}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:16px}
.grid-sm{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:22px}
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px}
.card h3{margin:0 0 14px;font-size:15px}
.kpi{cursor:pointer;transition:box-shadow .15s,transform .15s}
.kpi:hover{box-shadow:0 8px 22px rgba(28,31,26,.08);transform:translateY(-1px)}
.kpi-label{font-size:13px;color:var(--soft);font-weight:500;margin-bottom:10px}
.kpi-val{font-size:32px;font-weight:800;letter-spacing:-.4px;line-height:1.1}
.mini{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px 16px;cursor:pointer}
.mini:hover{background:#FBF3E8}
.mini small{display:block;color:var(--mute);font-size:11.5px;margin-bottom:4px}
.mini strong{font-size:22px;font-weight:800}
.section-title{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--mute);font-weight:700;margin:6px 2px 10px}
.pipe{display:flex;align-items:flex-end;gap:12px;height:170px}
.pipe-col{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%;min-width:0}
.pipe-bar{width:100%;background:var(--brand-l);border-radius:8px 8px 0 0;min-height:5px}
.pipe-bar.hot{background:var(--brand)}
.pipe-num{font-weight:800;margin-top:6px}
.pipe-label{font-size:11px;color:var(--mute);text-align:center;margin-top:2px;line-height:1.2}
.row-item{display:flex;align-items:center;gap:12px;padding:11px 8px;border-radius:9px;cursor:pointer}
.row-item:hover{background:#FBF3E8}
.row-item+.row-item{border-top:1px solid #EEF1E9}
.badge{width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:800;flex:0 0 30px;font-size:13px}
.badge.red{background:#fdecef;color:var(--red)}.badge.amber{background:#fff5d6;color:var(--amber)}.badge.blue{background:#e8f0fd;color:var(--blue)}.badge.purple{background:#efecfb;color:var(--purple)}.badge.ok{background:#e4f4ea;color:var(--ok)}
.row-copy{flex:1;min-width:0}
.row-copy strong{display:block;font-size:14px}
.row-copy span{font-size:12.5px;color:var(--mute)}
.row-count{font-weight:800}
.tag{display:inline-block;padding:3px 9px;border-radius:20px;font-size:11px;font-weight:700;background:var(--brand-l);color:var(--brand-d)}
.tag.done{background:#e4f4ea;color:var(--ok)}.tag.warn{background:#fff5d6;color:var(--amber)}.tag.bad{background:#fdecef;color:var(--red)}.tag.grey{background:#EEF1E9;color:var(--mute)}
.table-wrap{overflow-x:auto;background:#fff;border:1px solid var(--line);border-radius:12px}
table.t{width:100%;border-collapse:collapse;font-size:13px}
table.t th{text-align:left;text-transform:uppercase;font-size:10.5px;letter-spacing:.07em;color:var(--mute);padding:13px 14px;border-bottom:1px solid var(--line);white-space:nowrap;user-select:none}
table.t th[data-sort]{cursor:pointer}
table.t th[data-sort]:hover{color:var(--brand)}
table.t td{padding:12px 14px;border-bottom:1px solid #EEF1E9;white-space:nowrap;max-width:280px;overflow:hidden;text-overflow:ellipsis}
table.t tr:last-child td{border-bottom:0}
table.t tbody tr:hover{background:#FBF3E8}
.link{color:var(--brand);font-weight:700;cursor:pointer}
.muted{color:var(--mute)}
.num{text-align:right}
.page-head{display:flex;justify-content:space-between;align-items:flex-end;gap:14px;margin-bottom:18px;flex-wrap:wrap}
.page-head h1{margin:0 0 4px;font-size:26px}
.page-head p{margin:0;color:var(--soft)}
.toolbar{background:#fff;border:1px solid var(--line);border-radius:11px;padding:10px;display:flex;gap:9px;margin-bottom:12px;flex-wrap:wrap}
.toolbar input,.toolbar select{border:1px solid #E3E6DD;border-radius:7px;padding:9px 12px;font:inherit;font-size:13px;background:#fff}
.toolbar input{flex:1;min-width:200px}
.pager{display:flex;justify-content:space-between;align-items:center;margin-top:12px;color:var(--soft);font-size:13px}
.pager div{display:flex;gap:8px}
.state{padding:60px 20px;text-align:center;color:var(--mute)}
.state.err{color:var(--red)}
.icon-btn{border:1px solid #E3E6DD;background:#fff;border-radius:7px;width:30px;height:30px;font-size:13px;color:#4a5240}
.icon-btn:hover{background:var(--brand-l)}
.icon-btn.danger{color:var(--red)}
.acts{display:flex;gap:6px}
.overlay{position:fixed;inset:0;background:rgba(28,31,26,.45);display:none;align-items:center;justify-content:center;z-index:60;padding:16px}
.overlay.open{display:flex}
.modal{background:#fff;border-radius:14px;width:760px;max-width:100%;max-height:92vh;display:flex;flex-direction:column;box-shadow:0 25px 60px rgba(28,31,26,.28)}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line)}
.modal-head h3{margin:0;font-size:18px}
.modal-x{border:0;background:transparent;font-size:26px;line-height:1;color:var(--mute);padding:0 4px}
.modal-body{padding:18px 22px;overflow-y:auto}
.modal-foot{padding:14px 22px;border-top:1px solid var(--line);display:flex;gap:9px;justify-content:flex-end;flex-wrap:wrap}
.modal-foot .spacer{margin-right:auto}
.form-err{background:#FBEAEA;color:#B3393E;border-radius:8px;padding:10px 12px;font-size:13px;margin-bottom:12px;display:none;white-space:pre-wrap}
.notice{background:#EEF1E9;border-radius:8px;padding:9px 12px;font-size:13px;margin-bottom:14px;color:var(--soft)}
.fields{display:grid;grid-template-columns:1fr 1fr;gap:4px 16px}
.field{margin-bottom:12px}
.field.wide{grid-column:1/-1}
.field label{display:block;font-size:12px;font-weight:700;color:#4a5240;margin-bottom:5px}
.field input:not([type=checkbox]),.field select,.field textarea{width:100%;border:1px solid #E3E6DD;border-radius:8px;padding:9px 11px;font:inherit;font-size:13px;background:#fff}
.field textarea{min-height:70px;resize:vertical}
.field input:disabled,.field select:disabled,.field textarea:disabled{background:#F4F5F0;color:#4a5240}
.field input:focus,.field select:focus,.field textarea:focus{outline:2px solid var(--brand-l);border-color:var(--brand)}
.grid-wrap{margin:8px 0 14px}
.grid-title{font-size:12px;text-transform:uppercase;letter-spacing:.07em;font-weight:700;color:var(--mute);margin-bottom:7px}
.grid-scroll{overflow-x:auto;border:1px solid var(--line);border-radius:10px;margin-bottom:8px}
table.grid{width:100%;border-collapse:collapse;font-size:12.5px}
table.grid th{text-align:left;font-size:10.5px;text-transform:uppercase;color:var(--mute);padding:9px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
table.grid td{padding:5px 6px;border-bottom:1px solid #EEF1E9}
table.grid input:not([type=checkbox]),table.grid select{width:100%;min-width:110px;border:1px solid #E3E6DD;border-radius:6px;padding:6px 8px;font:inherit;font-size:12.5px;background:#fff}
table.grid input:disabled,table.grid select:disabled{background:#F4F5F0}
.toast{position:fixed;right:22px;bottom:22px;background:#1c1f1a;color:#fff;padding:12px 16px;border-radius:8px;font-size:13px;box-shadow:0 14px 35px rgba(28,31,26,.25);transform:translateY(20px);opacity:0;pointer-events:none;transition:.2s;z-index:80;max-width:min(420px,90vw)}
.toast.show{transform:none;opacity:1}
.toast.bad{background:#B3393E}
@media(max-width:1100px){.grid4,.grid-sm{grid-template-columns:repeat(2,1fr)}.grid2{grid-template-columns:1fr}}
@media(max-width:900px){
.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);transition:transform .2s;box-shadow:0 0 40px rgba(0,0,0,.2)}
.sidebar.open{transform:none}
.backdrop.open{display:block}
.menu-btn{display:block}
.content{padding:20px 16px 50px}
.topbar{padding:0 14px}
.gsearch{width:100%;margin-left:0;flex:1}
.crumb{display:none}
.fields{grid-template-columns:1fr}
}
@media(max-width:560px){.grid4,.grid-sm{grid-template-columns:1fr 1fr}.kpi-val{font-size:26px}.hero h1{font-size:22px}}
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar" id="sidebar" aria-label="Navigation">
<div class="brand"><img class="brand-logo" src="/assets/makap_backend/images/makap-icon.png" alt="MAKAP"><div><div class="brand-name">MAKAP Backend</div><div class="brand-sub">Manufacturing MVP</div></div></div>
<nav id="nav"><div class="state">Loading…</div></nav>
<div class="sidebar-foot"><a href="/app">Open Desk</a><button type="button" id="logout">Log out</button></div>
</aside>
<div class="backdrop" id="backdrop"></div>
<div class="main">
<header class="topbar">
<button class="menu-btn" id="menu" type="button" aria-label="Open menu">☰</button>
<div class="crumb" id="crumb"></div>
<input class="gsearch" id="gsearch" type="search" placeholder="Jump to a module… ( / )" autocomplete="off" aria-label="Jump to a module">
<div class="avatar" id="avatar">U</div>
</header>
<main class="content" id="view"></main>
</div>
</div>
<div class="overlay" id="overlay"><div class="modal" role="dialog" aria-modal="true" id="modal"></div></div>
<div class="toast" id="toast" role="status"></div>
<script>window.MAKAP = {csrf: '', user: '', fullName: ''};</script>
<script>
(function () {
'use strict';
var M = window.MAKAP;
var API = 'makap_backend.makap_backend.portal_api.';
var CRUD = 'makap_backend.makap_backend.portal_crud.';
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var view = $('#view'), crumb = $('#crumb'), nav = $('#nav');
var navToken = 0, refreshTimer = null, metaCache = {}, listState = {}, labels = {};
function attr(v) { return String(v == null ? '' : v).replace(/[&<>"']/g, function (c) { return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]; }); }
function esc(v) { return (v == null || v === '') ? '<span class="muted">—</span>' : attr(v); }
function num(v) { return new Intl.NumberFormat(undefined, {maximumFractionDigits: 2}).format(Number(v || 0)); }
function ago(v) {
if (!v) return '—';
var d = new Date(String(v).replace(' ', 'T'));
if (isNaN(d.getTime())) return attr(v);
var h = Math.round((Date.now() - d.getTime()) / 36e5);
return h < 1 ? 'Just now' : h < 24 ? h + 'h ago' : Math.round(h / 24) + 'd ago';
}
function slug(dt) { return dt.toLowerCase().replace(/ /g, '-'); }
function toast(msg, bad) {
var t = $('#toast'); t.textContent = msg; t.className = 'toast show' + (bad ? ' bad' : '');
clearTimeout(toast._t); toast._t = setTimeout(function () { t.className = 'toast'; }, 3200);
}
function store(k, v) { try { if (v === undefined) return localStorage.getItem(k); localStorage.setItem(k, v); } catch (e) { return null; } }
function parseErr(j, status) {
try {
if (j && j._server_messages) {
return JSON.parse(j._server_messages).map(function (m) { try { return JSON.parse(m).message; } catch (e) { return m; } }).join('\n').replace(/<[^>]+>/g, '');
}
} catch (e) {}
if (status === 401 || status === 403) return 'Not permitted, or your session expired. Reload the page.';
if (j && j.exception) return String(j.exception).replace(/^[\w.]+:\s*/, '');
return 'Request failed (' + status + ')';
}
function api(method, args) {
return fetch('/api/method/' + method, {
method: 'POST', credentials: 'same-origin',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Frappe-CSRF-Token': M.csrf},
body: JSON.stringify(args || {})
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (j) {
if (!r.ok || j.exc) throw new Error(parseErr(j, r.status));
return j.message;
});
});
}
/* ---------- identity ---------- */
var fullName = 'User';
function setIdentity() {
fullName = M.fullName || M.user || 'User';
$('#avatar').textContent = fullName.split(/\s+/).map(function (p) { return p[0]; }).join('').slice(0, 2).toUpperCase();
}
/* ---------- sidebar ---------- */
var ICO = '<svg class="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/></svg>';
function collapsedSet() { try { return JSON.parse(store('makap_collapsed') || '[]'); } catch (e) { return []; } }
function renderNav(groups) {
var coll = collapsedSet();
var html = '<a class="nav-item" data-dash="1" href="#/">' + ICO + '<span class="nav-text">Dashboard</span></a>';
groups.forEach(function (g) {
html += '<div class="nav-group' + (coll.indexOf(g.label) > -1 ? ' collapsed' : '') + '" data-label="' + attr(g.label) + '"><div class="nav-group-label" tabindex="0" role="button">' + attr(g.label) + '</div>';
g.items.forEach(function (it) {
var href = it.href ? attr(it.href) : '#/list/' + encodeURIComponent(it.doctype);
if (it.doctype) labels[it.doctype] = it.label;
var tag = it.badge ? '<span class="nav-tag count">' + attr(it.badge) + '</span>' : (it.tag ? '<span class="nav-tag">' + attr(it.tag) + '</span>' : '');
html += '<a class="nav-item" href="' + href + '"' + (it.doctype ? ' data-doctype="' + attr(it.doctype) + '"' : '') + '><span class="dot"></span><span class="nav-text">' + attr(it.label) + '</span>' + tag + '</a>';
});
html += '</div>';
});
nav.innerHTML = html;
$$('.nav-group-label', nav).forEach(function (l) {
var toggle = function () {
var g = l.parentElement; g.classList.toggle('collapsed');
store('makap_collapsed', JSON.stringify($$('.nav-group.collapsed', nav).map(function (x) { return x.dataset.label; })));
};
l.onclick = toggle;
l.onkeydown = function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } };
});
}
function setActive(doctype) {
$$('.nav-item', nav).forEach(function (a) {
a.classList.toggle('active', doctype ? a.dataset.doctype === doctype : !!a.dataset.dash);
});
}
function closeSidebar() { $('#sidebar').classList.remove('open'); $('#backdrop').classList.remove('open'); }
$('#menu').onclick = function () { $('#sidebar').classList.add('open'); $('#backdrop').classList.add('open'); };
$('#backdrop').onclick = closeSidebar;
$('#logout').onclick = function () { api('logout').catch(function () {}).then(function () { location.href = '/login'; }); };
/* jump-to search */
var gs = $('#gsearch');
function filterNav(q) {
q = q.trim().toLowerCase();
$$('.nav-group', nav).forEach(function (g) {
var any = false;
$$('.nav-item', g).forEach(function (a) {
var hit = !q || $('.nav-text', a).textContent.toLowerCase().indexOf(q) > -1;
a.style.display = hit ? '' : 'none'; if (hit) any = true;
});
g.style.display = any ? '' : 'none';
if (q) g.classList.remove('collapsed');
});
}
gs.oninput = function () { filterNav(gs.value); };
gs.onkeydown = function (e) {
if (e.key === 'Enter') {
var first = $$('.nav-group .nav-item', nav).filter(function (a) { return a.style.display !== 'none' && a.parentElement.style.display !== 'none'; })[0];
if (first) { first.click(); gs.value = ''; filterNav(''); gs.blur(); }
} else if (e.key === 'Escape') { gs.value = ''; filterNav(''); gs.blur(); }
};
document.addEventListener('keydown', function (e) {
var tag = (e.target.tagName || '').toLowerCase();
if ((e.key === '/' && tag !== 'input' && tag !== 'textarea' && tag !== 'select') || (e.ctrlKey && e.key.toLowerCase() === 'g')) {
e.preventDefault(); $('#sidebar').classList.add('open'); gs.focus();
}
});
/* ---------- router ---------- */
function route() {
clearInterval(refreshTimer);
closeSidebar();
var parts = location.hash.replace(/^#\/?/, '').split('/');
if (parts[0] === 'list' && parts[1]) showList(decodeURIComponent(parts.slice(1).join('/')));
else showDashboard();
}
window.addEventListener('hashchange', route);
function go(h) { if (location.hash === h) route(); else location.hash = h; }
function toList(dt) { go('#/list/' + encodeURIComponent(dt)); }
/* ---------- dashboard ---------- */
function showDashboard() {
var tok = ++navToken;
setActive(null);
crumb.innerHTML = 'MAKAP Backend &nbsp;›&nbsp; <b>Dashboard</b>';
view.innerHTML = '<div class="hero"><div><h1>Welcome back, ' + attr(fullName.split(' ')[0]) + ' 👋</h1><p>Here\'s what\'s happening across MAKAP Uganda\'s manufacturing workflow.</p></div><div><button class="btn" id="dash-refresh" type="button">↻ Refresh</button> <span class="muted" id="dash-time" style="margin-left:8px;font-size:12.5px"></span></div></div><div id="dash-body"><div class="state">Loading dashboard…</div></div>';
var load = function () {
api(API + 'get_portal_dashboard').then(function (d) {
if (tok !== navToken) return;
renderDashboard(d);
$('#dash-time').textContent = 'Updated ' + new Date().toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'});
}).catch(function (e) {
if (tok !== navToken) return;
$('#dash-body').innerHTML = '<div class="state err">Could not load the dashboard.<br>' + attr(e.message) + '<br><br><button class="btn" id="dash-retry" type="button">Retry</button></div>';
$('#dash-retry').onclick = load;
});
};
$('#dash-refresh').onclick = load;
load();
refreshTimer = setInterval(load, 60000);
}
function renderDashboard(d) {
var k = d.kpis || {};
var primary = [
['Onboarding requests', k.onboarding_requests, 'Customer Onboarding Request'],
['Packaging specifications', k.packaging_specifications, 'Packaging Specification'],
['Approved for manufacture', k.approved_for_manufacture, 'Packaging Specification'],
['Critical requirements tracked', k.critical_requirements_tracked, null]
];
var secondary = [
['Active sales orders', k.active_sales_orders, 'Sales Order'], ['Active work orders', k.active_work_orders, 'Work Order'],
['Open purchase orders', k.open_purchase_orders, 'Purchase Order'], ['Deliveries this month', k.deliveries_this_month, 'Delivery Note'],
['Onboarding in review', k.onboarding_open, 'Customer Onboarding Request'], ['Specs pending approval', k.specifications_pending, 'Packaging Specification'],
['Customer returns', k.returns_total, 'Delivery Note'], ['Closure views', k.closure_views, 'Order Closure And Margin View']
];
var html = '<div class="grid4">' + primary.map(function (p) {
return '<div class="card kpi"' + (p[2] ? ' data-go="' + attr(p[2]) + '"' : '') + '><div class="kpi-label">' + attr(p[0]) + '</div><div class="kpi-val">' + num(p[1]) + '</div></div>';
}).join('') + '</div>';
html += '<div class="section-title">Operations at a glance</div><div class="grid-sm">' + secondary.map(function (p) {
return '<div class="mini" data-go="' + attr(p[2]) + '"><small>' + attr(p[0]) + '</small><strong>' + num(p[1]) + '</strong></div>';
}).join('') + '</div>';
var max = Math.max.apply(null, (d.pipeline || []).map(function (x) { return Number(x.value || 0); }).concat([1]));
html += '<div class="grid2"><div class="card"><h3>Sales order pipeline</h3><div class="pipe">' + (d.pipeline || []).map(function (x) {
var v = Number(x.value || 0);
return '<div class="pipe-col"><div class="pipe-bar' + (v > 0 && v === max ? ' hot' : '') + '" style="height:' + Math.max(3, Math.round(v / max * 100)) + '%"></div><div class="pipe-num">' + num(v) + '</div><div class="pipe-label">' + attr(x.label) + '</div></div>';
}).join('') + '</div></div>';
var icons = {red: '!', amber: '⌁', blue: '$', purple: '≈'};
html += '<div class="card"><h3>Needs attention</h3>' + ((d.priorities || []).length ? d.priorities.map(function (p) {
return '<div class="row-item" data-go="' + attr(p.route) + '"><div class="badge ' + attr(p.tone) + '">' + (icons[p.tone] || '!') + '</div><div class="row-copy"><strong>' + attr(p.label) + '</strong><span>' + attr(p.detail) + '</span></div><span class="row-count">' + num(p.value) + '</span></div>';
}).join('') : '<div class="row-item"><div class="badge ok">✓</div><div class="row-copy"><strong>No active blockers</strong><span>Nothing is waiting on attention.</span></div></div>') + '</div></div>';
html += '<div class="grid2"><div class="card"><h3>Recent sales orders</h3>';
if ((d.recent_orders || []).length) {
html += '<div class="table-wrap" style="border:0"><table class="t"><thead><tr><th>Order</th><th>Customer</th><th>Status</th><th class="num">Total</th><th>Updated</th></tr></thead><tbody>' + d.recent_orders.map(function (o) {
var s = o.status || 'Draft';
var tone = ['Completed', 'Closed'].indexOf(s) > -1 ? 'done' : ['To Bill', 'To Deliver', 'To Deliver and Bill'].indexOf(s) > -1 ? 'warn' : s === 'Cancelled' ? 'bad' : 'grey';
return '<tr><td class="link" data-open="Sales Order" data-name="' + attr(o.name) + '">' + attr(o.name) + '</td><td>' + esc(o.customer) + '</td><td><span class="tag ' + tone + '">' + attr(s) + '</span></td><td class="num">' + num(o.grand_total) + '</td><td>' + ago(o.modified) + '</td></tr>';
}).join('') + '</tbody></table></div>';
} else html += '<div class="state">No sales orders yet.</div>';
html += '</div><div class="card"><h3>Order closure &amp; margin</h3>';
html += (d.recent_closures || []).length ? d.recent_closures.map(function (c) {
var open = c.operating_close !== 'Closed';
return '<div class="row-item" data-open="Order Closure And Margin View" data-name="' + attr(c.name) + '"><div class="badge ' + (open ? 'amber' : 'ok') + '">' + (open ? '!' : '✓') + '</div><div class="row-copy"><strong>' + attr(c.sales_order || c.name) + '</strong><span>' + attr(open ? (c.primary_blocker || 'Open operating close') : 'Operating close complete') + '</span></div><span class="tag ' + (open ? 'warn' : 'done') + '">' + attr(c.operating_close || 'Open') + '</span></div>';
}).join('') : '<div class="state">No closure views yet.</div>';
html += '</div></div>';
$('#dash-body').innerHTML = html;
$$('[data-go]', $('#dash-body')).forEach(function (el) { el.onclick = function () { toList(el.dataset.go); }; });
$$('[data-open]', $('#dash-body')).forEach(function (el) { el.onclick = function () { openForm(el.dataset.open, el.dataset.name, showDashboard); }; });
}
/* ---------- generic list ---------- */
function showList(doctype) {
var tok = ++navToken;
setActive(doctype);
var label = labels[doctype] || doctype;
crumb.innerHTML = 'MAKAP Backend &nbsp;›&nbsp; <b>' + attr(label) + '</b>';
var st = listState[doctype] = listState[doctype] || {search: '', start: 0, size: 20, order: 'modified', dir: 'desc', docstatus: 'all', state: 'all'};
view.innerHTML = '<div class="state">Loading ' + attr(label) + '…</div>';
var shellBuilt = false;
function fetchRows() {
return api(API + 'portal_list', {doctype: doctype, search: st.search, start: st.start, page_length: st.size, order_by: st.order, order_dir: st.dir, docstatus: st.docstatus, state: st.state});
}
function reload() {
fetchRows().then(function (d) {
if (tok !== navToken) return;
if (!shellBuilt) buildShell(d); fill(d);
}).catch(function (e) {
if (tok !== navToken) return;
var box = $('#list-table') || view;
box.innerHTML = '<div class="state err">Unable to load ' + attr(label) + '.<br>' + attr(e.message) + '</div>';
});
}
function buildShell(d) {
shellBuilt = true;
view.innerHTML =
'<div class="page-head"><div><h1>' + attr(label) + '</h1><p id="list-sub"></p></div><div style="display:flex;gap:9px">' +
(d.can_create ? '<button class="btn primary" id="l-new" type="button">+ New</button>' : '') +
'<button class="btn" id="l-refresh" type="button">↻ Refresh</button></div></div>' +
'<div class="toolbar"><input id="l-search" type="search" placeholder="Search ' + attr(label) + '…" value="' + attr(st.search) + '">' +
(d.submittable ? '<select id="l-docstatus"><option value="all">All documents</option><option value="0">Draft</option><option value="1">Submitted</option><option value="2">Cancelled</option></select>' : '') +
(d.has_disabled ? '<select id="l-state"><option value="all">All</option><option value="active">Active</option><option value="disabled">Disabled</option></select>' : '') +
'</div><div id="list-table"></div><div class="pager" id="list-pager"></div>';
if ($('#l-docstatus')) { $('#l-docstatus').value = st.docstatus; $('#l-docstatus').onchange = function (e) { st.docstatus = e.target.value; st.start = 0; reload(); }; }
if ($('#l-state')) { $('#l-state').value = st.state; $('#l-state').onchange = function (e) { st.state = e.target.value; st.start = 0; reload(); }; }
var t; $('#l-search').oninput = function (e) { st.search = e.target.value; st.start = 0; clearTimeout(t); t = setTimeout(reload, 300); };
$('#l-refresh').onclick = reload;
if ($('#l-new')) $('#l-new').onclick = function () { openForm(doctype, null, reload); };
}
function cell(c, v) {
if (v == null || v === '') return '<span class="muted">—</span>';
if (c.fieldtype === 'Check') return Number(v) ? '✓' : '—';
if (['Currency', 'Float', 'Int', 'Percent'].indexOf(c.fieldtype) > -1) return attr(num(v));
return attr(v);
}
function fill(d) {
$('#list-sub').textContent = d.total + ' record' + (d.total === 1 ? '' : 's');
var cols = d.columns;
var head = cols.map(function (c) {
var arrow = st.order === c.fieldname ? (st.dir === 'asc' ? ' ▲' : ' ▼') : '';
return '<th data-sort="' + attr(c.fieldname) + '">' + attr(c.label) + arrow + '</th>';
}).join('') + (d.submittable ? '<th>Doc status</th>' : '') + '<th></th>';
var body = d.rows.length ? d.rows.map(function (r) {
var ds = Number(r.docstatus || 0);
var canDel = d.can_delete && ds === 0;
return '<tr>' + cols.map(function (c, i) {
return '<td' + (i === 0 ? ' class="link" data-name="' + attr(r.name) + '"' : '') + '>' + cell(c, r[c.fieldname]) + '</td>';
}).join('') + (d.submittable ? '<td><span class="tag ' + (ds === 1 ? 'done' : ds === 2 ? 'bad' : 'grey') + '">' + (ds === 1 ? 'Submitted' : ds === 2 ? 'Cancelled' : 'Draft') + '</span></td>' : '') +
'<td><div class="acts"><button class="icon-btn" data-edit="' + attr(r.name) + '" title="Open" aria-label="Open">✎</button>' +
(canDel ? '<button class="icon-btn danger" data-del="' + attr(r.name) + '" title="Delete" aria-label="Delete">🗑</button>' : '') + '</div></td></tr>';
}).join('') : '<tr><td colspan="' + (cols.length + 2) + '"><div class="state">No records found.</div></td></tr>';
$('#list-table').innerHTML = '<div class="table-wrap"><table class="t"><thead><tr>' + head + '</tr></thead><tbody>' + body + '</tbody></table></div>';
$$('#list-table th[data-sort]').forEach(function (th) {
th.onclick = function () {
var f = th.dataset.sort;
if (st.order === f) st.dir = st.dir === 'asc' ? 'desc' : 'asc'; else { st.order = f; st.dir = 'asc'; }
st.start = 0; reload();
};
});
$$('#list-table [data-name]').forEach(function (td) { td.onclick = function () { openForm(doctype, td.dataset.name, reload); }; });
$$('#list-table [data-edit]').forEach(function (b) { b.onclick = function () { openForm(doctype, b.dataset.edit, reload); }; });
$$('#list-table [data-del]').forEach(function (b) { b.onclick = function () { doDelete(doctype, b.dataset.del, reload); }; });
var from = d.total ? d.start + 1 : 0, to = Math.min(d.start + d.rows.length, d.total);
$('#list-pager').innerHTML = '<span>Showing ' + from + '–' + to + ' of ' + d.total + '</span><div><button class="btn" id="p-prev" type="button"' + (d.start <= 0 ? ' disabled' : '') + '>← Prev</button><button class="btn" id="p-next" type="button"' + (to >= d.total ? ' disabled' : '') + '>Next →</button></div>';
$('#p-prev').onclick = function () { st.start = Math.max(0, st.start - st.size); reload(); };
$('#p-next').onclick = function () { st.start += st.size; reload(); };
}
reload();
}
function doDelete(doctype, name, done) {
if (!window.confirm('Delete ' + name + '? This cannot be undone.')) return;
api(CRUD + 'delete_doc', {doctype: doctype, name: name}).then(function () { toast('Deleted ' + name); closeModal(); if (done) done(); })
.catch(function (e) { toast(e.message, true); });
}
/* ---------- modal + form ---------- */
var overlay = $('#overlay'), modal = $('#modal');
function closeModal() { overlay.classList.remove('open'); modal.innerHTML = ''; }
overlay.addEventListener('mousedown', function (e) { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function (e) {
if (!overlay.classList.contains('open')) return;
if (e.key === 'Escape') { closeModal(); return; }
if (e.key === 'Tab') {
var f = $$('button,input,select,textarea,a[href]', modal).filter(function (x) { return !x.disabled && x.offsetParent !== null; });
if (!f.length) return;
var first = f[0], last = f[f.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
});
function getMeta(dt) {
if (metaCache[dt]) return Promise.resolve(metaCache[dt]);
return api(CRUD + 'get_doctype_meta', {doctype: dt}).then(function (m) { return (metaCache[dt] = m); });
}
function today() { var d = new Date(); return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); }
function defaultFor(f) {
var d = f.default; if (!d || /^__/.test(d)) return '';
if (d === 'Today') return today();
if (d === 'Now') return today() + ' ' + new Date().toTimeString().slice(0, 8);
return d;
}
function inputHtml(f, val, dis, kind, isNew) {
var d = (dis || f.read_only) ? ' disabled' : '';
var req = (f.reqd && !d) ? ' required' : '';
var a = ' data-' + kind + '="' + attr(f.fieldname) + '"';
var v = val == null ? '' : val;
if (isNew && v === '' && !f.read_only) v = defaultFor(f);
switch (f.fieldtype) {
case 'Select':
var opts = (f.options || '').split('\n');
return '<select' + a + d + req + '>' + ((f.reqd && v !== '') ? '' : '<option value=""></option>') + opts.filter(function (o) { return o !== ''; }).map(function (o) {
return '<option value="' + attr(o) + '"' + (o === v ? ' selected' : '') + '>' + attr(o) + '</option>';
}).join('') + '</select>';
case 'Check':
return '<input type="checkbox"' + a + d + (Number(v) ? ' checked' : '') + '>';
case 'Text': case 'Small Text': case 'Long Text': case 'Text Editor':
return '<textarea' + a + d + req + '>' + attr(v) + '</textarea>';
case 'Int': case 'Float': case 'Currency': case 'Percent':
return '<input type="number" step="any"' + a + d + req + ' value="' + attr(v) + '">';
case 'Date':
return '<input type="date"' + a + d + req + ' value="' + attr(String(v).slice(0, 10)) + '">';
case 'Datetime':
return '<input type="datetime-local"' + a + d + req + ' value="' + attr(String(v).replace(' ', 'T').slice(0, 16)) + '">';
case 'Time':
return '<input type="time"' + a + d + req + ' value="' + attr(String(v).slice(0, 5)) + '">';
case 'Link':
return '<input type="text" autocomplete="off" list="dl-' + attr((f.options || '').replace(/\W/g, '_')) + '" data-link="' + attr(f.options) + '"' + a + d + req + ' value="' + attr(v) + '" placeholder="' + attr(f.options) + '">';
default:
return '<input type="text"' + a + d + req + ' value="' + attr(v) + '">';
}
}
function rowHtml(t, row, dis) {
return '<tr>' + t.fields.map(function (f) { return '<td>' + inputHtml(f, row[f.fieldname], dis, 'c') + '</td>'; }).join('') +
'<td>' + (dis ? '' : '<button class="icon-btn danger" type="button" data-rm="1" aria-label="Remove row">✕</button>') + '</td></tr>';
}
function linkDatalists(meta) {
var seen = {}, out = '';
var all = meta.fields.concat.apply(meta.fields, meta.table_fields.map(function (t) { return t.fields; }));
all.forEach(function (f) {
if (f.fieldtype === 'Link' && f.options && !seen[f.options]) { seen[f.options] = 1; out += '<datalist id="dl-' + attr(f.options.replace(/\W/g, '_')) + '"></datalist>'; }
});
return out;
}
function openForm(doctype, name, done) {
modal.innerHTML = '<div class="modal-head"><h3>' + attr(name || ('New ' + doctype)) + '</h3><button class="modal-x" type="button" id="m-x" aria-label="Close">&times;</button></div><div class="modal-body"><div class="state">Loading…</div></div>';
overlay.classList.add('open');
$('#m-x').onclick = closeModal;
Promise.all([getMeta(doctype), name ? api(CRUD + 'get_doc', {doctype: doctype, name: name}) : Promise.resolve({})]).then(function (res) {
buildForm(doctype, name, res[0], res[1], done);
}).catch(function (e) {
$('.modal-body', modal).innerHTML = '<div class="state err">' + attr(e.message) + '</div>';
});
}
function buildForm(doctype, name, meta, doc, done) {
var isNew = !name, ds = Number(doc.docstatus || 0);
var editable = meta.writable && (isNew ? meta.can_create : meta.can_write) && ds === 0;
var notice = '';
if (!meta.writable) notice = 'This record type is view-only in the portal.';
else if (ds === 1) notice = 'This document is submitted. Cancel it to make changes.';
else if (ds === 2) notice = 'This document is cancelled.';
else if (!isNew && !meta.can_write) notice = 'You have read-only access to this record.';
var fieldsHtml = meta.fields.map(function (f) {
var wide = ['Text', 'Small Text', 'Long Text', 'Text Editor'].indexOf(f.fieldtype) > -1;
var chk = f.fieldtype === 'Check';
return '<div class="field' + (wide ? ' wide' : '') + '"><label>' + attr(f.label) + (f.reqd && editable ? ' *' : '') + '</label>' + inputHtml(f, doc[f.fieldname], !editable, 'f', isNew) + '</div>';
}).join('');
var gridsHtml = meta.table_fields.map(function (t) {
var rows = (doc[t.fieldname] || []);
return '<div class="grid-wrap"><div class="grid-title">' + attr(t.label) + '</div><div class="grid-scroll"><table class="grid" data-t="' + attr(t.fieldname) + '"><thead><tr>' +
t.fields.map(function (f) { return '<th>' + attr(f.label) + '</th>'; }).join('') + '<th></th></tr></thead><tbody>' +
rows.map(function (r) { return rowHtml(t, r, !editable); }).join('') + '</tbody></table></div>' +
(editable ? '<button class="btn" type="button" data-add="' + attr(t.fieldname) + '">+ Add row</button>' : '') + '</div>';
}).join('');
$('.modal-body', modal).innerHTML = '<div class="form-err" id="m-err"></div>' + (notice ? '<div class="notice">' + attr(notice) + '</div>' : '') +
'<form id="m-form" autocomplete="off"><div class="fields">' + fieldsHtml + '</div>' + gridsHtml + '</form>' + linkDatalists(meta);
var foot = '<div class="modal-foot">';
if (!isNew) foot += '<a class="btn spacer" href="/app/' + slug(doctype) + '/' + encodeURIComponent(name) + '" target="_blank" rel="noopener">Open in Desk ↗</a>';
if (!isNew && editable && meta.can_delete) foot += '<button class="btn danger" type="button" id="m-del">Delete</button>';
if (!isNew && meta.submittable && ds === 0 && meta.can_submit) foot += '<button class="btn" type="button" id="m-submit">Submit</button>';
if (!isNew && meta.submittable && ds === 1 && meta.can_cancel) foot += '<button class="btn danger" type="button" id="m-cancel-doc">Cancel document</button>';
foot += '<button class="btn" type="button" id="m-close">Close</button>';
if (editable) foot += '<button class="btn primary" type="button" id="m-save">Save</button>';
foot += '</div>';
var old = $('.modal-foot', modal); if (old) old.remove();
modal.insertAdjacentHTML('beforeend', foot);
var err = $('#m-err'), form = $('#m-form');
function showErr(m) { err.textContent = m; err.style.display = 'block'; err.scrollIntoView({block: 'nearest'}); }
function busy(on) { $$('.modal-foot .btn', modal).forEach(function (b) { b.disabled = on; }); }
$('#m-close').onclick = closeModal;
/* link typeahead (shared datalists) */
var lt;
function fetchLinks(inp) {
var dt = inp.dataset.link; if (!dt) return;
clearTimeout(lt);
lt = setTimeout(function () {
api(CRUD + 'link_options', {doctype: dt, txt: inp.value}).then(function (rows) {
var dl = document.getElementById('dl-' + dt.replace(/\W/g, '_')); if (!dl) return;
dl.innerHTML = (rows || []).map(function (r) { return '<option value="' + attr(r.value) + '">' + attr(r.label !== r.value ? r.label : '') + '</option>'; }).join('');
}).catch(function () {});
}, 200);
}
form.addEventListener('input', function (e) { if (e.target.dataset && e.target.dataset.link) fetchLinks(e.target); });
form.addEventListener('focusin', function (e) { if (e.target.dataset && e.target.dataset.link) fetchLinks(e.target); });
if (editable) {
$$('[data-add]', modal).forEach(function (b) {
b.onclick = function () {
var t = meta.table_fields.filter(function (x) { return x.fieldname === b.dataset.add; })[0];
$('table.grid[data-t="' + t.fieldname + '"] tbody', modal).insertAdjacentHTML('beforeend', rowHtml(t, {}, false));
};
});
form.addEventListener('click', function (e) {
if (e.target.dataset && e.target.dataset.rm) e.target.closest('tr').remove();
});
}
function readVal(el) {
if (el.type === 'checkbox') return el.checked ? 1 : 0;
if (el.type === 'datetime-local') return el.value.replace('T', ' ');
return el.value;
}
function collect() {
var values = {};
meta.fields.forEach(function (f) {
if (f.read_only) return;
var el = form.querySelector('[data-f="' + f.fieldname + '"]'); if (el) values[f.fieldname] = readVal(el);
});
meta.table_fields.forEach(function (t) {
values[t.fieldname] = $$('table.grid[data-t="' + t.fieldname + '"] tbody tr', form).map(function (tr) {
var row = {};
$$('[data-c]', tr).forEach(function (el) { row[el.dataset.c] = readVal(el); });
return row;
});
});
return values;
}
function after(msg) { closeModal(); toast(msg); if (done) done(); }
if ($('#m-save')) $('#m-save').onclick = function () {
err.style.display = 'none';
if (!form.reportValidity()) return;
busy(true);
var call = isNew ? api(CRUD + 'create_doc', {doctype: doctype, values: collect()}) : api(CRUD + 'update_doc', {doctype: doctype, name: name, values: collect()});
call.then(function (r) { after((isNew ? 'Created ' : 'Saved ') + r.name); }).catch(function (e) { busy(false); showErr(e.message); });
};
if ($('#m-del')) $('#m-del').onclick = function () { doDelete(doctype, name, done); };
if ($('#m-submit')) $('#m-submit').onclick = function () {
if (!window.confirm('Submit ' + name + '? Submitted documents can\'t be edited.')) return;
busy(true); api(CRUD + 'submit_doc', {doctype: doctype, name: name}).then(function () { after('Submitted ' + name); }).catch(function (e) { busy(false); showErr(e.message); });
};
if ($('#m-cancel-doc')) $('#m-cancel-doc').onclick = function () {
if (!window.confirm('Cancel ' + name + '?')) return;
busy(true); api(CRUD + 'cancel_doc', {doctype: doctype, name: name}).then(function () { after('Cancelled ' + name); }).catch(function (e) { busy(false); showErr(e.message); });
};
var first = form.querySelector('input:not([disabled]),select:not([disabled]),textarea:not([disabled])'); if (first) first.focus();
}
/* ---------- boot ---------- */
function start() {
api(API + 'get_portal_navigation').then(function (groups) { renderNav(groups); route(); })
.catch(function (e) {
nav.innerHTML = '<div class="state err">Navigation unavailable.<br>' + attr(e.message) + '</div>';
route();
});
}
fetch('/api/method/' + API + 'get_session_info', {credentials: 'same-origin', headers: {'Accept': 'application/json'}})
.then(function (r) {
if (r.status === 401 || r.status === 403) { location.href = '/login?redirect-to=/makap-portal'; throw new Error('login'); }
return r.json().then(function (j) { if (!r.ok || !j.message) throw new Error(parseErr(j, r.status)); return j.message; });
})
.then(function (s) { M.csrf = s.csrf_token; M.user = s.user; M.fullName = s.full_name; setIdentity(); start(); })
.catch(function (e) {
if (e.message === 'login') return;
view.innerHTML = '<div class="state err">Could not start the portal.<br>' + attr(e.message) + '</div>';
});
})();
</script>
</body>
</html>
+17
View File
@@ -0,0 +1,17 @@
import frappe
from frappe.sessions import get_csrf_token
from frappe.utils import get_fullname
no_cache = 1
def get_context(context):
if frappe.session.user == "Guest":
frappe.local.flags.redirect_location = "/login?redirect-to=/makap-portal"
raise frappe.Redirect
if frappe.db.get_value("User", frappe.session.user, "user_type") != "System User":
raise frappe.PermissionError("The MAKAP portal is for internal users only.")
context.no_cache = 1
context.csrf_token = get_csrf_token()
context.user = frappe.session.user
context.full_name = get_fullname(frappe.session.user)