Files
Makap/makap_backend/www/makap-portal.html
T
2026-10-05 16:04:29 +03:00

689 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>