feat(permissions): granular module permissions + workshop permissions matrix
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled

This commit is contained in:
2026-07-03 07:55:17 +00:00
parent 4718a09f15
commit 6e85497e42
7 changed files with 484 additions and 136 deletions

View File

@@ -34,13 +34,17 @@ var Workshop = (function() {
var isMechanic = role === 'mechanic';
var hidePrices = isRestricted;
var perms = user.permissions || [];
// Owner/admin/counter/cashier can create/edit/delete service orders.
// Mechanics have limited access: view allowed statuses, change status, edit diagnosis/repair notes.
var canEdit = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier';
var canCreate = canEdit;
var canDelete = role === 'owner' || role === 'admin';
var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
var canChangeStatus = canEdit || isMechanic;
// Workshop-specific permissions are loaded from the backend. These defaults
// will be overwritten after init() fetches the role configuration.
var workshopPerms = { statuses: [], actions: [] };
function hasWsAction(action) {
return workshopPerms.actions.indexOf(action) !== -1;
}
function isWsStatusVisible(status) {
return role === 'owner' || role === 'admin' || workshopPerms.statuses.indexOf(status) !== -1;
}
var canEdit = false, canCreate = false, canDelete = false, canSell = false, canChangeStatus = false;
var hidePrices = false;
var COLUMNS = [
{key: 'por_revisar', label: 'Por revisar'},
@@ -183,7 +187,45 @@ var Workshop = (function() {
// ─── Init ───
function init() {
async function loadWorkshopPerms() {
try {
var res = await fetch('/pos/api/config/role-permissions/workshop', { headers: headers() });
if (res.ok) {
var data = await res.json();
var cfg = (data.roles || {})[role] || { statuses: [], actions: [] };
workshopPerms = {
statuses: Array.isArray(cfg.statuses) ? cfg.statuses : [],
actions: Array.isArray(cfg.actions) ? cfg.actions : []
};
}
} catch (e) {
console.error('Workshop.loadWorkshopPerms:', e);
}
// Fallback defaults when fetch fails / no config
if (!workshopPerms.statuses.length && role !== 'owner' && role !== 'admin') {
if (role === 'mechanic') {
workshopPerms.statuses = ['por_revisar','en_revision','revisada','en_reparacion','reparada','autorizada','cancelada'];
workshopPerms.actions = ['change_status','add_labor','view_notes'];
} else if (role === 'workshop') {
workshopPerms.statuses = COLUMNS.filter(function(c) {
return ['por_entregar','entregado','por_enviar','enviado','por_recolectar'].indexOf(c.key) === -1;
}).map(function(c) { return c.key; }).concat(['cancelada']);
workshopPerms.actions = ['change_status','add_labor','view_notes'];
}
}
}
async function init() {
await loadWorkshopPerms();
// Set action flags from workshop permissions.
canCreate = role === 'owner' || role === 'admin' || hasWsAction('create_order');
canEdit = role === 'owner' || role === 'admin' || hasWsAction('edit_order');
canDelete = role === 'owner' || role === 'admin' || hasWsAction('delete_order');
canChangeStatus = role === 'owner' || role === 'admin' || hasWsAction('change_status');
canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
hidePrices = role !== 'owner' && role !== 'admin' && !hasWsAction('view_prices');
var savedView = localStorage.getItem('workshop_view');
if (savedView) currentView = savedView;
bindFilters();
@@ -196,14 +238,11 @@ var Workshop = (function() {
document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; });
var searchInput = document.getElementById('filterSearch');
if (searchInput) searchInput.placeholder = 'Buscar orden';
// Remove statuses that workshop/mechanic cannot see from the status filter.
// Remove statuses that the role cannot see from the status filter.
var statusSel = document.getElementById('filterStatus');
if (statusSel) {
var hiddenStatuses = isMechanic
? ['cotizada','por_autorizar','autorizacion_parcial','por_facturar','facturada','por_entregar','entregado','por_enviar','enviado','por_recolectar']
: ['por_entregar','entregado','por_enviar','enviado','por_recolectar'];
Array.from(statusSel.options).forEach(function(opt) {
if (opt.value && hiddenStatuses.indexOf(opt.value) !== -1) opt.remove();
if (opt.value && !isWsStatusVisible(opt.value)) opt.remove();
});
statusSel.value = '';
}
@@ -263,25 +302,18 @@ var Workshop = (function() {
.then(function(r) { return r.json(); })
.then(function(d) {
var cards = document.querySelectorAll('#statsRow .summary-card');
if (isRestricted) {
// Taller accounts: hide delivery/logistics card; show work-in-progress summary.
cards.forEach(function(card, idx) {
card.style.display = (idx === 2) ? 'none' : ''; // hide "Por entregar"
});
document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0);
document.getElementById('statRepair').textContent = fmt(
(d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) +
(d.cotizada || 0) + (d.por_autorizar || 0) +
(d.autorizada || 0) + (d.autorizacion_parcial || 0)
);
document.getElementById('statReady').textContent = '0';
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
return;
}
cards.forEach(function(card) { card.style.display = ''; });
document.getElementById('statReceived').textContent = fmt(d.por_revisar || 0);
document.getElementById('statRepair').textContent = fmt((d.en_reparacion || 0) + (d.en_revision || 0) + (d.revisada || 0) + (d.cotizada || 0) + (d.por_autorizar || 0));
document.getElementById('statReady').textContent = fmt(d.por_entregar || 0);
var porRevisar = isWsStatusVisible('por_revisar') ? (d.por_revisar || 0) : 0;
var enTaller = 0;
['en_reparacion','en_revision','revisada','cotizada','por_autorizar','autorizada','autorizacion_parcial'].forEach(function(s) {
if (isWsStatusVisible(s)) enTaller += d[s] || 0;
});
var porEntregar = isWsStatusVisible('por_entregar') ? (d.por_entregar || 0) : 0;
document.getElementById('statReceived').textContent = fmt(porRevisar);
document.getElementById('statRepair').textContent = fmt(enTaller);
document.getElementById('statReady').textContent = fmt(porEntregar);
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
})
.catch(function() {});
@@ -407,15 +439,7 @@ var Workshop = (function() {
function renderKanban() {
var board = document.getElementById('kanbanBoard');
board.innerHTML = '';
var hiddenCols = [];
if (isMechanic) {
// Mechanic sees everything except commercial and delivery/logistics statuses.
hiddenCols = ['cotizada','por_autorizar','autorizacion_parcial','por_facturar','facturada','por_entregar','entregado','por_enviar','enviado','por_recolectar'];
} else if (role === 'workshop') {
// Workshop sees everything except delivery/logistics statuses.
hiddenCols = ['por_entregar','entregado','por_enviar','enviado','por_recolectar'];
}
COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) {
COLUMNS.filter(function(col) { return isWsStatusVisible(col.key); }).forEach(function(col) {
var colOrders = orders.filter(function(o) { return o.status === col.key; });
var colEl = document.createElement('div');
colEl.className = 'kanban-column';
@@ -558,7 +582,7 @@ var Workshop = (function() {
// Footer actions
var footer = document.getElementById('detailFooter');
var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) {
return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1;
return isWsStatusVisible(s);
});
var statusHtml = canChangeStatus && allowedNext.length ?
'<div class="so-detail__actions" style="margin-right:auto;">' +
@@ -568,15 +592,16 @@ var Workshop = (function() {
' </select>' +
' <button class="btn btn--primary" onclick="Workshop.changeStatus()">Actualizar estado</button>' +
'</div>' : '';
var canPrint = role !== 'workshop' && role !== 'mechanic';
footer.innerHTML = statusHtml +
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
(canEdit ? '<button class="btn btn--secondary" onclick="Workshop.openEditOrderModal()">Editar orden</button>' : '') +
(canDelete ? '<button class="btn btn--danger" onclick="Workshop.deleteOrder()">Eliminar orden</button>' : '') +
(isRestricted ? '' : '<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
(canPrint ? '<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
'<svg viewBox="0 0 24 24"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>' +
'Imprimir orden</button>') +
(canEdit && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(canEdit && canSell && !o.sale_id && o.status !== 'cancelled' ? '<button class="btn btn--secondary" onclick="Workshop.convertToRemission()">Generar nota de remisión</button>' : '') +
'Imprimir orden</button>' : '') +
(hasWsAction('convert_to_sale') && canSell && (o.status === 'por_entregar' || o.status === 'entregado') && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(hasWsAction('convert_to_remission') && canSell && !o.sale_id && o.status !== 'cancelled' ? '<button class="btn btn--secondary" onclick="Workshop.convertToRemission()">Generar nota de remisión</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
@@ -632,11 +657,12 @@ var Workshop = (function() {
}
function renderArticles(o) {
var canManageItems = hasWsAction('add_items');
var colCount = (hidePrices ? 5 : 6) - (isRestricted ? 1 : 0);
var partsRows = (o.items || []).map(function(it) {
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(it.unit_price) + '</td>';
var mechanicCells = isRestricted ? '' : '<td>' + esc(mechanicName(it.mechanic_id)) + '</td>';
var actionCell = canEdit && it.status !== 'cancelado' ?
var actionCell = canManageItems && it.status !== 'cancelado' ?
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.editItemInline(' + it.id + ')">Editar</button></td>' : '<td></td>';
return '<tr>' +
'<td>' + esc(it.name) + '<br><small>' + esc(it.part_number || '') + '</small></td>' +
@@ -650,7 +676,7 @@ var Workshop = (function() {
}).join('');
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + (isRestricted ? '' : '<th>Mecánico</th>') + '<th>Estado</th><th>Observaciones</th><th></th></tr>';
var addParts = canEdit ?
var addParts = canManageItems ?
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);align-items:flex-start;flex-wrap:wrap;">' +
' <div style="position:relative;flex:1;min-width:160px;">' +
' <input class="form-input" id="newItemSearch" placeholder="Buscar refacción por nombre/número" autocomplete="off" oninput="Workshop.searchItemsForSO()" />' +