sale order
SAP-ERP Portal CI/CD / build (push) Successful in 3m57s

This commit is contained in:
John
2026-10-05 18:45:17 +05:30
parent e725a6571b
commit eead8f5ffd
129 changed files with 14252 additions and 452 deletions
+271 -7
View File
@@ -424,14 +424,17 @@ const MODULE_GROUPS=[
{k:'production-dashboard',l:'Dashboard'},{k:'production-create',l:'Create Order'},{k:'production-issue',l:'Issue'},
{k:'production-receipt',l:'Receipt'},{k:'work-order-verify',l:'Verify Work Order'},{k:'production-verify',l:'Verify (Production Order — legacy)'},{k:'production-close',l:'Close'},
{k:'production-requirements',l:'Requirements'},{k:'production-batch-issuance',l:'Batch Issuance'},
{k:'production-work-order',l:'Work Order'},{k:'man-power',l:'Man Power'},{k:'oee',l:'OEE'},
{k:'production-work-order',l:'Work Order'},{k:'man-power',l:'Man Power'},{k:'oee',l:'OEE'},{k:'downtime-analysis',l:'Downtime Analysis'},
{k:'rejection-register',l:'Rejection Register'},{k:'rejection-analytics',l:'Rejection Analytics'},
{k:'production-planning',l:'Production Planning'},
{k:'production-planning',l:'Production Planning'},{k:'production-deviations',l:'Deviations'},
]},
{group:'Purchase',items:[
{k:'purchase-request',l:'Purchase Request'},{k:'purchase-quotation',l:'Quotation'},
{k:'purchase-order',l:'Purchase Order'},{k:'purchase-grpo',l:'Goods Receipt PO'},
]},
{group:'Sales',items:[
{k:'sales-orders',l:'Sales Orders'},{k:'sales-samples',l:'Sample Requests'},{k:'sales-reports',l:'Sales Reports'},{k:'sales-admin',l:'Sales Admin'},
]},
{flat:true,k:'approvals',l:'Approvals'},
{flat:true,k:'sap-approvals',l:'SAP Approvals'},
{flat:true,k:'customers',l:'Customers'},
@@ -449,6 +452,10 @@ const MODULE_GROUPS=[
{flat:true,k:'reports',l:'Reports'},
{flat:true,k:'ppc-report',l:'PPC Report'},
{flat:true,k:'pwo-source-audit',l:'PWO Source Report'},
{flat:true,k:'inventory-status-report',l:'Inventory Status Report'},
{flat:true,k:'batch-no-transaction',l:'Batch No. Transaction Report'},
{flat:true,k:'inventory-transfer',l:'Inventory Transfer'},
{flat:true,k:'inventory-posting-list',l:'Inventory Posting List'},
{flat:true,k:'gstr1',l:'GSTR-1'},
{flat:true,k:'gstr2',l:'GSTR-2 Purchase'},
{flat:true,k:'itc04',l:'ITC-04 Job Work'},
@@ -1274,6 +1281,7 @@ const DEVIATION_TYPE_OPTS = [
{ v:'SHORTAGE', l:'Shortage — top up qty of the same item' },
{ v:'SUBSTITUTION', l:'Substitution — issue a different item instead' },
{ v:'DAMAGE', l:'Damage/Loss — write off a damaged qty' },
{ v:'ADDITION', l:'Addition — add a new item/component not on the Production Order' },
];
const MANPOWER_TAB_OPTS = [
{ v:'bb',l:'BB Production' },{ v:'packing',l:'Packing' },{ v:'autoclave',l:'Autoclave' },
@@ -1593,9 +1601,21 @@ function buildSettingsTab() {
<input class="form-input" id="set-deviationDamageSeries" value="${esc(s.deviationDamageSeries!=null?String(s.deviationDamageSeries):'')}" placeholder="e.g. 5" style="width:150px;font-family:var(--mono)"/>
</div>`, 'set-save-deviation');
section('🔁', 'Inventory Transfer', `
<div class="set-row">
<div class="set-info"><div class="set-l">Document Series</div><div class="set-h">SAP document numbering series that every Inventory Transfer posts under — pulled live from SAP (Administration → System Initialization → Document Numbering) so you pick it by its recognizable name, not a raw internal ID.</div></div>
<select class="form-input" id="set-inventoryTransferSeries" data-current="${esc(s.inventoryTransferSeries!=null?String(s.inventoryTransferSeries):'')}" style="width:220px">
<option value="">Loading…</option>
</select>
</div>`, 'set-save-invtransfer');
let itemCodeFloors = {};
try { itemCodeFloors = JSON.parse(s.itemCodeSeriesFloor || '{}') || {}; } catch { itemCodeFloors = {}; }
section('🔢', 'Item Code Series (Create Item)', `
section('🔢', 'Item Master — Create Item', `
<div class="set-row">
<div class="set-info"><div class="set-l">Item Approval Workflow</div><div class="set-h">When ON (default), a new item submitted by anyone other than admin/SAP Adder/system admin queues as PENDING under Approvals → Item Approvals until one of them reviews it. When OFF, every submission — any role — pushes straight to SAP, same as an approver's own submission; there is no review queue at all.</div></div>
${yesNo('set-itemApprovalEnabled', s.itemApprovalEnabled)}
</div>
<div class="set-row">
<div class="set-info"><div class="set-l">Start next code from</div><div class="set-h">Create Item's auto-numbering normally suggests the highest existing code for that prefix, +1. If a mistaken/out-of-order entry already used a much higher number than intended, that suggestion jumps straight past the whole unused gap. Set a floor here to force the next SUGGESTED code to at least this number, per prefix — leave blank/0 for no override (natural behavior, as before). This only ever pushes the suggestion UP; it can never cause a collision with a real existing item.</div></div>
<div style="display:flex;flex-direction:column;gap:8px;width:220px">
@@ -1746,6 +1766,7 @@ function buildSettingsTab() {
}
})();
const notifyDisabledSet = new Set(String(s.notifyDisabledSteps||'').split(',').map(x=>x.trim()).filter(Boolean));
section('✉', 'Stage-Change Email Notifications', `
<div class="set-row">
<div class="set-info"><div class="set-l">Enable Email Notifications</div><div class="set-h">Emails whoever is next assigned an approval step (Work Order, Production Order, Issue for Production, Verify Work Order) or holds the Batch Issuance module, at every stage transition. SMTP server itself is configured in .env — this only controls the on/off switch and optional fixed extra recipients per step. Global switch — turn off to silence all stage-change emails without touching SMTP config.</div></div>
@@ -1758,6 +1779,19 @@ function buildSettingsTab() {
</div>
<div id="notify-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
</div>
<div class="set-row set-col">
<div class="chk-head">
<div class="set-info"><div class="set-l">Notify by Item Group</div><div class="set-h">Optional — route an extra recipient by the order's MAIN PRODUCT's SAP Item Group (not every component's group), in addition to whoever holds the step. E.g. every Work Order/Production Order for a "Solutions" item also emails that team's inbox, regardless of who's assigned the approval step.</div></div>
<button type="button" class="chk-mini" id="notifyig-add-row">+ Add</button>
</div>
<div id="notifyig-rows" style="display:flex;flex-direction:column;gap:6px;margin-top:6px"></div>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Notify Per Step</div><div class="set-h">Uncheck a step to silence its stage-change email entirely — for everyone assigned to it AND any fixed extra recipient above — without touching who's actually assigned. Checked (default) = email fires normally.</div></div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:4px 14px;margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--border2);border-radius:8px;padding:8px 10px">
${NOTIFY_TARGET_OPTS.map(o=>`<label style="display:flex;align-items:center;gap:7px;font-size:12px;cursor:pointer"><input type="checkbox" class="set-notifystep" value="${esc(o.v)}" ${notifyDisabledSet.has(o.v)?'':'checked'}/> ${esc(o.l)}</label>`).join('')}
</div>
</div>
<div class="set-row">
<div class="set-info"><div class="set-l">Confirm Email Delivery</div><div class="set-h">Check SMTP connects with the credentials in .env, and/or send a real test email to confirm it's actually delivered. (Not part of the Save above — these run immediately.)</div></div>
<div style="display:flex;flex-direction:column;gap:6px;width:280px">
@@ -1770,6 +1804,77 @@ function buildSettingsTab() {
</div>
</div>`, 'set-save-notify');
// ── Downtime Analysis (services/downtimeAnalysisStore.js) ──
// Default period/Base Days + the canonical cause taxonomy + per-OEE-tab
// field→cause mapping. Field lists below are a hardcoded mirror of each
// tab's own OEE_SCHEMA (public/oee.html) — duplicated rather than fetched,
// same convention as WORK_ORDER_STEP_KEYS elsewhere in this app, since the
// schema itself lives client-side and rarely changes.
const DT_TAB_FIELDS = {
ebb: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
pd: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['microStops','Micro Stops < 10 min'],['emptyRun','Empty run'],['startShut','Start up / shut down']],
sheet: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['slowSpeed','Slow speed / High Cycle Time'],['headClean','Head Clean'],['otherProblem','Other Problem'],['mcStartShut','M/C Start up / shut down']],
moulding: [['waterTea','Water / Tea Break'],['meal','Meal break'],['breakdown','Breakdown'],['cleaning','Cleaning'],['changeOver','Change over'],['powerCutSlow','Power Cut / Slow Speed'],['headClean','Head Clean'],['nozzleClean','Nozzle Clean'],['mcStartShut','M/C Start up / shut down']],
autoclave:[['boiler','Boiler breakdown'],['waterSys','Water system breakdown'],['bopt','BOPT breakdown'],['coolingTower','Cooling tower breakdown'],['others','Others']],
};
const DT_TAB_LABELS = { ebb:'EBB Production', pd:'PD Production', sheet:'Plastic Sheet Plant', moulding:'Moulding', autoclave:'Autoclave' };
let dtCfg = {};
try { dtCfg = JSON.parse(s.downtimeAnalysisConfig || '{}') || {}; } catch (_e) { dtCfg = {}; }
const dtCauses = Array.isArray(dtCfg.causes) ? dtCfg.causes : [];
const dtMap = dtCfg.tabFieldMap || {};
function dtCauseOptions(sel) {
return `<option value="">— Not included —</option>` + dtCauses.map(c => `<option value="${esc(c)}" ${sel===c?'selected':''}>${esc(c)}</option>`).join('');
}
section('⏱', 'Downtime Analysis', `
<div class="set-row">
<div class="set-info"><div class="set-l">Default Period &amp; Base Days</div><div class="set-h">The report's own page lets a user override these per run — this is just what it starts pre-filled with. Base Days is the common working-day calendar for the period (e.g. 330); a machine whose OEE entries only start partway through the period gets this prorated down automatically.</div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<div><label class="fl">Period From</label><input class="form-input" id="set-dtPeriodFrom" type="date" value="${esc(dtCfg.periodFrom||'')}" style="width:150px"/></div>
<div><label class="fl">Period To</label><input class="form-input" id="set-dtPeriodTo" type="date" value="${esc(dtCfg.periodTo||'')}" style="width:150px"/></div>
<div><label class="fl">Base Days</label><input class="form-input" id="set-dtBaseDays" type="number" min="1" value="${esc(dtCfg.baseDays||330)}" style="width:100px"/></div>
</div>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Downtime Causes</div><div class="set-h">Comma-separated — this is the standardized list every OEE tab's own fields get mapped onto below. Add a new cause here, Save, then reopen this section to assign fields to it.</div></div>
<textarea class="form-input" id="set-dtCauses" rows="2" style="margin-top:6px;resize:vertical">${esc(dtCauses.join(', '))}</textarea>
</div>
<div class="set-row set-col">
<div class="set-info"><div class="set-l">Field → Cause Mapping (per OEE tab)</div><div class="set-h">Different tabs use different field names for the same real-world cause (e.g. Sheet's "Head Clean" vs EBB's "Micro Stops") — map each one onto a standard cause above, or leave it "Not included" to exclude it from the report entirely.</div></div>
<div style="display:flex;flex-direction:column;gap:14px;margin-top:8px">
${Object.keys(DT_TAB_FIELDS).map(tab => `
<div>
<div style="font-size:11px;font-weight:700;color:var(--text2);margin-bottom:5px">${esc(DT_TAB_LABELS[tab]||tab)}</div>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">
${DT_TAB_FIELDS[tab].map(([key,label]) => `
<div style="display:flex;flex-direction:column;gap:5px;padding:8px 10px;border:1px solid var(--border2);border-radius:7px">
<span style="font-size:10.5px;color:var(--text2)">${esc(label)}</span>
<select class="form-input dt-map-sel" data-tab="${esc(tab)}" data-field="${esc(key)}" style="width:100%;height:32px;font-size:11px">${dtCauseOptions(dtMap[tab]&&dtMap[tab][key])}</select>
</div>`).join('')}
</div>
</div>`).join('')}
</div>
</div>`, 'set-save-downtime');
// ── Short in Stock — Auto Email Alerts (services/shortStockAlertStore.js) ──
// Fully independent feature: a background job (started once in server.js,
// runs every 15 min) sums each watched item/group's on-hand quantity and
// emails the concerned user(s) the FIRST time it hits zero — never on a
// report screen, never gated by the Stage-Change Email settings above.
let shortStockRules = [];
try { shortStockRules = JSON.parse(s.shortStockAlertRules || '[]') || []; } catch (_e) { shortStockRules = []; }
section('📉', 'Short in Stock — Auto Email Alerts', `
<div class="set-row">
<div class="set-info"><div class="set-l">Enable Auto Alerts</div><div class="set-h">A background check (every 15 minutes) emails the concerned user(s) below the FIRST time a watched item/group's total on-hand quantity (across every warehouse) hits zero — not repeatedly while it stays at zero, only again once it's restocked and drops to zero a second time.</div></div>
${yesNo('set-shortStockAlertEnabled', s.shortStockAlertEnabled)}
</div>
<div class="set-row set-col">
<div class="chk-head">
<div class="set-info"><div class="set-l">Watch-List</div><div class="set-h">Add a specific Item Code, or an entire Item Group (every item in it), each with its own concerned user(s).</div></div>
<button type="button" class="chk-mini" id="sstock-add-row">+ Add</button>
</div>
<div id="sstock-rows" style="display:flex;flex-direction:column;gap:8px;margin-top:6px"></div>
</div>`, 'set-save-shortstock');
// ── Receipt: Warehouse → Transaction Type mapping (per company) ──
const whWin = document.createElement('div'); whWin.className = 'win';
whWin.innerHTML = `
@@ -1839,6 +1944,112 @@ function buildSettingsTab() {
return out;
}
// ── Notify by Item Group rows ──
let notifyByGroup = {};
try { notifyByGroup = JSON.parse(s.notifyItemGroupEmails || '{}') || {}; } catch (_e) { notifyByGroup = {}; }
const notifyIgRowsEl = div.querySelector('#notifyig-rows');
function notifyIgRowHtml(groupCode, emails) {
return `<div class="notify-row" style="display:flex;gap:8px;align-items:center">
<select class="form-input notifyig-group" style="width:280px">
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(g.code)===String(groupCode)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
</select>
<input class="form-input notifyig-emails" style="flex:1" placeholder="comma-separated emails" value="${esc(emails||'')}"/>
<button type="button" class="chk-mini notifyig-rm" title="Remove">✕</button>
</div>`;
}
function renderNotifyIgRows() {
const keys = Object.keys(notifyByGroup);
notifyIgRowsEl.innerHTML = keys.length
? keys.map(k => notifyIgRowHtml(k, notifyByGroup[k])).join('')
: '<div style="color:var(--text3);font-size:12px">No item-group recipients configured.</div>';
notifyIgRowsEl.querySelectorAll('.notifyig-rm').forEach(b => b.onclick = function(){ this.closest('.notify-row').remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); });
}
renderNotifyIgRows();
const notifyIgAddBtn = div.querySelector('#notifyig-add-row');
if (notifyIgAddBtn) notifyIgAddBtn.onclick = () => {
if (!ITEM_GROUPS.length) { toast('Item groups not loaded (SAP unreachable?)', 'err'); return; }
if (notifyIgRowsEl.querySelector('div[style*="color:var(--text3)"]')) notifyIgRowsEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.innerHTML = notifyIgRowHtml(ITEM_GROUPS[0].code, '');
const row = wrap.firstElementChild;
notifyIgRowsEl.appendChild(row);
row.querySelector('.notifyig-rm').onclick = () => { row.remove(); if(!notifyIgRowsEl.children.length) renderNotifyIgRows(); };
};
function collectNotifyByGroup() {
const out = {};
notifyIgRowsEl.querySelectorAll('.notify-row').forEach(row => {
const k = row.querySelector('.notifyig-group')?.value;
const v = row.querySelector('.notifyig-emails')?.value.trim();
if (k && v) out[k] = v;
});
return out;
}
// ── Short in Stock — Watch-List rows ──
const sstockRowsEl = div.querySelector('#sstock-rows');
function sstockRowHtml(rule) {
const recip = new Set(rule.recipients || []);
return `<div class="sstock-row" data-id="${esc(rule.id)}" style="display:flex;flex-direction:column;gap:6px;padding:10px;border:1px solid var(--glass-border);border-radius:9px;background:var(--surface2,#f8fafc)">
<div style="display:flex;gap:8px;align-items:center">
<select class="form-input sstock-type" style="width:110px">
<option value="ITEM" ${rule.type!=='GROUP'?'selected':''}>Item Code</option>
<option value="GROUP" ${rule.type==='GROUP'?'selected':''}>Item Group</option>
</select>
<input class="form-input sstock-item-val" style="flex:1;font-family:var(--mono);${rule.type==='GROUP'?'display:none':''}" placeholder="Item Code" value="${rule.type!=='GROUP'?esc(rule.value||''):''}"/>
<select class="form-select sstock-group-val" style="flex:1;${rule.type==='GROUP'?'':'display:none'}">
<option value="">— Select Item Group —</option>
${ITEM_GROUPS.map(g=>`<option value="${esc(g.code)}" ${String(rule.value)===String(g.code)?'selected':''}>${esc(g.name)} (${esc(g.code)})</option>`).join('')}
</select>
<button type="button" class="chk-mini sstock-rm" title="Remove">✕</button>
</div>
<div>
<div style="font-size:10px;color:var(--text3,#94a3b8);text-transform:uppercase;letter-spacing:.3px;margin-bottom:4px">Concerned User(s)</div>
<div class="sstock-users" style="display:flex;flex-wrap:wrap;gap:4px 12px;max-height:110px;overflow-y:auto;border:1px solid var(--glass-border);border-radius:7px;padding:6px 8px">
${_users.map(u=>`<label style="display:flex;align-items:center;gap:5px;font-size:11.5px;cursor:pointer"><input type="checkbox" class="sstock-user-cb" value="${esc(u.username)}" ${recip.has(u.username)?'checked':''}/> ${esc(u.fullName||u.username)}</label>`).join('')}
</div>
</div>
</div>`;
}
function renderSstockRows() {
sstockRowsEl.innerHTML = shortStockRules.length
? shortStockRules.map(sstockRowHtml).join('')
: '<div style="color:var(--text3);font-size:12px">No items/groups watched yet.</div>';
wireSstockRows();
}
function wireSstockRows() {
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
const typeSel = row.querySelector('.sstock-type');
const itemInp = row.querySelector('.sstock-item-val');
const groupSel = row.querySelector('.sstock-group-val');
typeSel.onchange = () => {
const isGroup = typeSel.value === 'GROUP';
itemInp.style.display = isGroup ? 'none' : '';
groupSel.style.display = isGroup ? '' : 'none';
};
row.querySelector('.sstock-rm').onclick = () => { row.remove(); if (!sstockRowsEl.children.length) renderSstockRows(); };
});
}
renderSstockRows();
const sstockAddBtn = div.querySelector('#sstock-add-row');
if (sstockAddBtn) sstockAddBtn.onclick = () => {
if (sstockRowsEl.querySelector('div[style*="color:var(--text3)"]')) sstockRowsEl.innerHTML = '';
const wrap = document.createElement('div');
wrap.innerHTML = sstockRowHtml({ id: 'r' + Date.now(), type: 'ITEM', value: '', recipients: [] });
const row = wrap.firstElementChild;
sstockRowsEl.appendChild(row);
wireSstockRows();
};
function collectSstockRules() {
const out = [];
sstockRowsEl.querySelectorAll('.sstock-row').forEach(row => {
const type = row.querySelector('.sstock-type').value;
const value = type === 'GROUP' ? row.querySelector('.sstock-group-val').value : row.querySelector('.sstock-item-val').value.trim().toUpperCase();
const recipients = Array.from(row.querySelectorAll('.sstock-user-cb:checked')).map(cb => cb.value);
if (value && recipients.length) out.push({ id: row.dataset.id, type, value, recipients });
});
return out;
}
// ── Confirm email delivery (SMTP test) ──
const testResult = div.querySelector('#notify-test-result');
const showTestResult = (ok, msg) => { testResult.style.color = ok ? 'var(--green,#0aa457)' : 'var(--red,#c81e1e)'; testResult.textContent = msg; };
@@ -1936,6 +2147,27 @@ function buildSettingsTab() {
saveBtn.disabled = false;
}
coSel.onchange = loadWtt;
// ── Inventory Transfer Series dropdown (lives in the same company
// context as the warehouse mapping above — SAP's NNM1 series table is
// per-company) ──
(async () => {
const selEl = div.querySelector('#set-inventoryTransferSeries');
if (!selEl) return;
const current = selEl.dataset.current || '';
try {
const r = await api('GET', `/sap/lookup/series?objectCode=67&company=${encodeURIComponent(window.__DEFAULT_COMPANY__ || '')}`);
const list = (r.data || []).filter(x => !x.locked);
const opts = ['<option value="">— SAP default series —</option>']
.concat(list.map(x => `<option value="${x.series}" ${String(x.series) === current ? 'selected' : ''}>${esc(x.seriesName)} (${x.series})</option>`));
// Keep a currently-saved value visible even if it's locked/missing from the live list.
if (current && !list.some(x => String(x.series) === current)) {
opts.push(`<option value="${esc(current)}" selected>#${esc(current)} (not found in SAP — keeping saved value)</option>`);
}
selEl.innerHTML = opts.join('');
} catch (_e) {
selEl.innerHTML = `<option value="${esc(current)}" selected>#${esc(current) || '(none)'} — could not load series names</option>`;
}
})();
saveBtn.onclick = async () => {
const map = {};
div.querySelectorAll('.wtt-sel').forEach(s => { map[s.dataset.wh] = s.value; });
@@ -2039,13 +2271,20 @@ function buildSettingsTab() {
deviationDamageSeries: div.querySelector('#set-deviationDamageSeries').value.trim(),
}), p => !p.deviationEnabledTypes ? 'Select at least one Deviation type' : null);
wireSave('set-save-invtransfer', () => ({
inventoryTransferSeries: div.querySelector('#set-inventoryTransferSeries').value.trim(),
}));
wireSave('set-save-itemcode', () => {
const floors = {};
div.querySelectorAll('.set-itemcodefloor').forEach(inp => {
const n = parseInt(inp.value);
if (!isNaN(n) && n > 0) floors[inp.dataset.prefix] = n;
});
return { itemCodeSeriesFloor: JSON.stringify(floors) };
return {
itemApprovalEnabled: div.querySelector('#set-itemApprovalEnabled').value,
itemCodeSeriesFloor: JSON.stringify(floors),
};
});
wireSave('set-save-prodorder', () => ({
@@ -2090,8 +2329,33 @@ function buildSettingsTab() {
wireSave('set-save-notify', () => ({
notifyEmailsEnabled: div.querySelector('#set-notifyEmailsEnabled').value,
notifyExtraEmails: JSON.stringify(collectNotifyExtra()),
notifyItemGroupEmails: JSON.stringify(collectNotifyByGroup()),
notifyDisabledSteps: NOTIFY_TARGET_OPTS.map(o=>o.v).filter(v=>!div.querySelector(`.set-notifystep[value="${CSS.escape(v)}"]`)?.checked).join(','),
}));
wireSave('set-save-shortstock', () => ({
shortStockAlertEnabled: div.querySelector('#set-shortStockAlertEnabled').value,
shortStockAlertRules: JSON.stringify(collectSstockRules()),
}));
wireSave('set-save-downtime', () => {
const causes = (div.querySelector('#set-dtCauses')?.value || '').split(',').map(s => s.trim()).filter(Boolean);
const tabFieldMap = {};
div.querySelectorAll('.dt-map-sel').forEach(sel => {
const tab = sel.dataset.tab, field = sel.dataset.field, cause = sel.value;
if (!cause) return;
(tabFieldMap[tab] = tabFieldMap[tab] || {})[field] = cause;
});
return {
downtimeAnalysisConfig: JSON.stringify({
periodFrom: div.querySelector('#set-dtPeriodFrom')?.value || '',
periodTo: div.querySelector('#set-dtPeriodTo')?.value || '',
baseDays: parseInt(div.querySelector('#set-dtBaseDays')?.value) || 330,
causes, tabFieldMap,
}),
};
});
// Settings search — filters every card's .set-row by its visible text
// (label + description), not just the card title. A card with any
// matching row auto-expands and stays visible; a card with zero matches
@@ -2392,7 +2656,7 @@ function openUserModal(user, cloneFrom) {
<!-- Work Order sign override — off by default, a narrow explicit grant -->
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="uc-wo-verify-override" ${user.woVerifyOverride?'checked':''}/>
<input type="checkbox" id="uc-wo-verify-override" ${user&&user.woVerifyOverride?'checked':''}/>
<span style="font-size:12px;font-weight:600">Verify Work Order: can sign any step as any user &amp; backdate</span>
</label>
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user's Issued/Received/Verified stamps on a Work Order row (on any step they're separately assigned "approve" on) let them pick who it's signed as (any user) and set the sign date to a backdate — including RE-signing a row that's already been stamped, to correct it. Everyone else can only sign as themselves at the current time, once.</div>
@@ -2401,7 +2665,7 @@ function openUserModal(user, cloneFrom) {
<!-- Impersonate — off by default, a narrow explicit grant -->
<div class="form-group">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="uc-can-impersonate" ${user.canImpersonate?'checked':''}/>
<input type="checkbox" id="uc-can-impersonate" ${user&&user.canImpersonate?'checked':''}/>
<span style="font-size:12px;font-weight:600">Can impersonate other users</span>
</label>
<div style="font-size:10.5px;color:var(--text3);margin-top:3px;margin-left:24px">When checked, this user can pick another user and act on their behalf — seeing exactly what that user sees, with that user's permissions — until they return to their own account. Cannot be used to impersonate an admin unless this user is themselves an admin. Every action taken while impersonating is recorded in the Audit Trail against both identities.</div>
@@ -2888,6 +3152,6 @@ function confirmDelete(user) {
// ════════════════════════════════════════
init();
</script>
<script src="/sidebar.js?v=25"></script>
<script src="/sidebar.js?v=31"></script>
</body>
</html>