Files
2026-11-02 20:27:11 +02:00

434 lines
16 KiB
JavaScript

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();
if(result.canSourcing){
const link=el('a','Consulta de fornecedores, cotações e adjudicação',$('detail-data'));
link.href='/purchasing/sourcing/'+selected.id;
}
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);
}