Portal, logo, workspace shortcut
This commit is contained in:
@@ -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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 › <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 & 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 › <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">×</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>
|
||||
Reference in New Issue
Block a user