434 lines
16 KiB
JavaScript
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);
|
|
}
|