export function actionsFor(record, actor, permissions) { if (Array.isArray(record.allowed_actions)) return record.allowed_actions; const own = record.requester_id === actor; if (record.status === 'draft') return own && permissions.prepare ? ['edit', 'submit'] : []; if ( record.status !== 'pending' || own || record.steps.some((step) => step.approver_id === actor) ) return []; const next = record.steps.find((step) => step.decision === 'pending'); return next && permissions[next.role] ? ['approved', 'rejected'] : []; } export function commandEnvelope(key, version, data = {}) { return { ...data, operation_key: key, ...(version == null ? {} : { version }), }; } export function limitMinor(value) { if (!/^\d+(\.\d{1,2})?$/.test(value)) throw new Error('Indique o limite em MZN com até duas casas decimais.'); const [major, fraction = ''] = value.split('.'); const minor = BigInt(major) * 100n + BigInt(fraction.padEnd(2, '0')); if (minor > 100000000000000n) throw new Error('Limite superior ao montante suportado.'); return Number(minor); } export function limitMajor(minor) { const value = BigInt(minor); return `${value / 100n}.${String(value % 100n).padStart(2, '0')}`; } if (typeof document !== 'undefined') { const contract = JSON.parse( document.querySelector('#ir-contract').textContent, ); const $ = (id) => document.getElementById(id); const el = (tag, text, parent) => { const node = document.createElement(tag); if (text != null) node.textContent = text; if (parent) parent.append(node); return node; }; let state, selected, draft, dirty = false, busy = false; const keys = new Map(); const money = (minor) => new Intl.NumberFormat('pt-MZ', { style: 'currency', currency: 'MZN', }).format(minor / 100); function notice(text) { $('notice').textContent = text; $('notice').className = text ? 'notice' : ''; } async function api(path, method = 'GET', data) { const response = await fetch(path, { method, credentials: 'same-origin', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': contract.csrf, 'X-SGF-Context': contract.token, 'X-Purchasing-Context': contract.context, }, ...(data ? { body: JSON.stringify(data) } : {}), }); const result = await response.json().catch(() => ({})); if (!response.ok) throw new Error( Object.values(result.errors || {}) .flat() .join(' ') || result.message || 'Operação não concluída. O contexto e a chave de repetição foram mantidos.', ); return result; } async function perform(work) { if (busy) return; busy = true; notice('A processar…'); document.querySelectorAll('button').forEach((b) => (b.disabled = true)); try { await work(); } catch (error) { notice(error.message); } finally { busy = false; document .querySelectorAll('button') .forEach((b) => (b.disabled = false)); } } async function command(action, record, data = {}) { const slot = `${action}:${record?.id ?? 'new'}`; if (!keys.has(slot)) keys.set(slot, crypto.randomUUID()); const result = await api( `/api/internal-requisitions/${record ? `${record.id}/${action}` : 'save'}`, 'POST', commandEnvelope(keys.get(slot), record?.version, data), ); keys.delete(slot); dirty = false; selected = result; if (action === 'save') draft = result; try { await load(); await detail(result.id); notice( result.replayed ? 'Resposta recuperada sem repetir efeitos.' : 'Operação registada com origem e versão.', ); } catch { notice( 'Operação confirmada. Não foi possível actualizar a consulta; use Actualizar. A origem gravada foi mantida.', ); } return result; } function line(data = {}) { const node = el('div', null, $('lines')); node.className = 'line'; for (const [name, label, value] of [ ['description', 'Descrição', ''], ['category', 'Categoria', ''], ['quantity', 'Quantidade', '1.000'], ['unit', 'Unidade', 'UN'], ['unit_price', 'Preço unitário MZN', ''], ['tax_rate', 'IVA %', ''], ]) { const labelNode = el('label', label, node), input = el('input', null, labelNode); input.name = name; input.value = data[name] ?? value; input.required = true; if (['quantity', 'unit_price', 'tax_rate'].includes(name)) input.inputMode = 'decimal'; } const label = el('label', 'Tipo', node), type = el('select', null, label); type.name = 'purchase_type'; for (const [value, text] of [ ['consumable', 'Consumível'], ['service', 'Serviço'], ['other', 'Outro'], ]) { const option = el('option', text, type); option.value = value; } type.value = data.purchase_type || 'consumable'; const remove = el('button', 'Remover', node); remove.type = 'button'; remove.onclick = () => { node.remove(); dirty = true; }; } function edit(record) { if (dirty && !confirm('Descartar as alterações por guardar?')) return; draft = record; $('draft-panel').hidden = false; $('draft-title').textContent = record ? `Editar ${record.number}` : 'Nova requisição interna'; $('lines').replaceChildren(); const form = $('draft'); for (const name of [ 'cost_center_id', 'priority', 'needed_by', 'justification', ]) form.elements[name].value = record?.data[name] ?? (name === 'priority' ? 'normal' : ''); (record?.data.items ?? [{}]).forEach(line); dirty = false; } async function detail(id) { if (dirty && !confirm('Descartar as alterações por guardar?')) return; const result = await api(`/api/internal-requisitions/${id}`); selected = result.record; $('detail').hidden = false; $('detail-title').textContent = `${selected.number} · ${selected.status} · Versão ${selected.version}`; $('detail-data').replaceChildren(); el('p', selected.data.justification, $('detail-data')); el( 'p', `Total: ${money(selected.total_minor)} · Origem: ${selected.origin_key}`, $('detail-data'), ); if (selected.status !== 'draft') el( 'p', selected.reservation ? 'Reserva orçamental registada.' : 'Sem reserva: política aplicada não a exigia e não havia dotação configurada.', $('detail-data'), ); $('steps').replaceChildren(); selected.steps.forEach((step) => el( 'p', `${step.position}. ${step.role} · ${step.decision}${step.approver_id ? ` · Identidade ${step.approver_id}` : ''}`, $('steps'), ), ); $('history').replaceChildren(); result.history.forEach((event) => el( 'p', `Versão ${event.version} · ${event.action} · ${event.created_at}`, $('history'), ), ); $('actions').replaceChildren(); for (const action of actionsFor( selected, contract.actor_id, state.permissions, )) { const button = el( 'button', { edit: 'Editar', submit: 'Submeter', approved: 'Aprovar etapa', rejected: 'Rejeitar', }[action], $('actions'), ); button.onclick = () => action === 'edit' ? edit(selected) : perform(async () => { const comment = action === 'rejected' ? prompt('Fundamente a rejeição') : null; if (action === 'rejected' && !comment) return; await command( action === 'approved' || action === 'rejected' ? 'decide' : action, selected, action === 'submit' ? {} : { decision: action, comment }, ); }); } } function mapping(parent, data, idField) { const row = el('div', null, $(parent)); row.className = 'line'; const label = el( 'label', idField === 'id' ? 'Centro de custo' : 'Pessoa', row, ), select = el('select', null, label); select.name = idField; select.required = true; const empty = el('option', 'Seleccione…', select); empty.value = ''; const sources = state.configurationSources?.[ idField === 'id' ? 'centres' : 'members' ] ?? []; for (const source of sources) { const option = el('option', source.code ?? source.name, select); option.value = source.id; } select.value = data?.[idField] ?? ''; const departmentLabel = el('label', 'Departamento', row), input = el('input', null, departmentLabel); input.name = 'department_ref'; input.value = data?.department_ref ?? ''; input.required = true; const b = el('button', 'Retirar associação', row); b.type = 'button'; b.onclick = () => { row.remove(); dirty = true; }; } async function load() { state = await api('/api/internal-requisitions'); $('new').hidden = !state.permissions.prepare; $('policy-panel').hidden = !state.permissions.configure; const select = $('draft').elements.cost_center_id; select.replaceChildren(); (state.centres || []).forEach((c) => { const o = el('option', `${c.label} · ${c.department_ref}`, select); o.value = c.id; }); $('records').replaceChildren(); if (!state.records.length) el('p', 'Sem pedidos autorizados neste contexto.', $('records')); state.records.forEach((r) => { const b = el( 'button', `${r.number} · ${r.status} · ${money(r.total_minor)}`, $('records'), ); b.onclick = () => perform(() => detail(r.id)); }); if (state.permissions.configure) { const p = state.policy, form = $('policy'); for (const k of [ 'enabled', 'budget_required', 'approval_includes_tax', ]) form.elements[k].checked = p?.[k] ?? false; for (const k of ['first_limit_minor', 'second_limit_minor']) form.elements[k].value = p ? limitMajor(p[k]) : ''; $('policy-centres').replaceChildren(); $('policy-members').replaceChildren(); (p?.centres || []).forEach((c) => mapping('policy-centres', c, 'id'), ); (p?.members || []).forEach((m) => mapping('policy-members', m, 'user_id'), ); } } $('new').onclick = () => edit(null); $('reload').onclick = () => perform(load); $('add-line').onclick = () => { line(); dirty = true; }; $('draft').oninput = () => (dirty = true); $('cancel-draft').onclick = () => { if (!dirty || confirm('Descartar as alterações?')) { $('draft-panel').hidden = true; dirty = false; } }; $('draft').onsubmit = (event) => { event.preventDefault(); void perform(async () => { const form = event.target, data = {}; for (const k of [ 'cost_center_id', 'priority', 'needed_by', 'justification', ]) data[k] = k === 'cost_center_id' ? Number(form.elements[k].value) : form.elements[k].value; data.items = [...$('lines').children].map((row) => Object.fromEntries( [...row.querySelectorAll('input,select')].map((i) => [ i.name, i.value, ]), ), ); await command('save', draft, data); $('draft-panel').hidden = true; }); }; $('add-centre').onclick = () => { mapping('policy-centres', null, 'id'); dirty = true; }; $('add-member').onclick = () => { mapping('policy-members', null, 'user_id'); dirty = true; }; $('policy').oninput = () => (dirty = true); $('policy').onsubmit = (event) => { event.preventDefault(); void perform(async () => { const f = event.target, data = { version: state.policy?.revision ?? 0, reason: f.elements.reason.value, }; for (const k of [ 'enabled', 'budget_required', 'approval_includes_tax', ]) data[k] = f.elements[k].checked; for (const k of ['first_limit_minor', 'second_limit_minor']) data[k] = limitMinor(f.elements[k].value); for (const [key, parent, id] of [ ['centres', 'policy-centres', 'id'], ['members', 'policy-members', 'user_id'], ]) data[key] = [...$(parent).children].map((row) => Object.fromEntries( [...row.querySelectorAll('input,select')].map((i) => [ i.name, i.name === id ? Number(i.value) : i.value, ]), ), ); await api('/api/internal-requisitions/policy', 'POST', data); dirty = false; await load(); notice( 'Parametrização versionada e registada. Permissões não foram alteradas.', ); }); }; window.addEventListener('beforeunload', (event) => { if (dirty) { event.preventDefault(); event.returnValue = ''; } }); void perform(load); }