commit13
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user