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

@@ -9,8 +9,6 @@ const Config = (() => {
// Cache for branches (used by employee modal selector)
let _branches = [];
let _rolePermissions = {};
let _availablePermissions = [];
function token() {
return localStorage.getItem('pos_token') || '';
@@ -1035,6 +1033,12 @@ const Config = (() => {
// -------------------------------------------------------------------------
// Role permissions editor
// -------------------------------------------------------------------------
var _rolePermissions = {};
var _availablePermissions = [];
var _workshopPermissions = {};
var _workshopSchema = { statuses: [], actions: [] };
var _currentPermTab = 'modules';
async function loadRolePermissions() {
try {
var res = await fetch(API + '/role-permissions', { headers: headers() });
@@ -1106,6 +1110,108 @@ const Config = (() => {
}
}
// Workshop-specific permissions editor
// -------------------------------------------------------------------------
async function loadWorkshopPermissions() {
try {
var res = await fetch(API + '/role-permissions/workshop', { headers: headers() });
if (!res.ok) return;
var data = await res.json();
_workshopPermissions = data.roles || {};
_workshopSchema = { statuses: data.statuses || [], actions: data.actions || [] };
renderWorkshopPermissions();
} catch (e) {
console.error('Config.loadWorkshopPermissions:', e);
}
}
function renderWorkshopPermissions() {
var container = document.getElementById('workshop-permissions-container');
var roleSel = document.getElementById('cfg-perm-role');
if (!container || !roleSel) return;
var role = roleSel.value;
if (!role) {
container.innerHTML = '<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>';
return;
}
var cfg = _workshopPermissions[role] || { statuses: [], actions: [] };
var visibleStatuses = cfg.statuses || [];
var visibleActions = cfg.actions || [];
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:var(--space-4);">';
// Statuses
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Estatus visibles en Taller</h4>';
(_workshopSchema.statuses || []).forEach(function(s) {
var checked = visibleStatuses.indexOf(s.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-status="' + escapeHtml(s.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(s.label) + '</span>';
html += '</label>';
});
html += '</div>';
// Actions
html += '<div style="border:1px solid var(--color-border);border-radius:var(--radius-md);padding:var(--space-3);background:var(--color-surface-2);">';
html += '<h4 style="margin:0 0 var(--space-3);font-size:var(--text-body-sm);color:var(--color-text-primary);">Acciones permitidas en Taller</h4>';
(_workshopSchema.actions || []).forEach(function(a) {
var checked = visibleActions.indexOf(a.key) !== -1 ? 'checked' : '';
html += '<label style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:var(--space-2);cursor:pointer;font-size:var(--text-body-sm);">';
html += '<input type="checkbox" data-ws-action="' + escapeHtml(a.key) + '" ' + checked + ' style="width:auto;" />';
html += '<span>' + escapeHtml(a.label) + '</span>';
html += '</label>';
});
html += '</div>';
html += '</div>';
container.innerHTML = html;
}
async function saveWorkshopPermissions() {
var roleSel = document.getElementById('cfg-perm-role');
var status = document.getElementById('workshop-permissions-status');
if (!roleSel || !roleSel.value) {
if (status) status.textContent = 'Selecciona un rol';
return;
}
var role = roleSel.value;
var statuses = [];
var actions = [];
document.querySelectorAll('#workshop-permissions-container input[data-ws-status]').forEach(function(cb) {
if (cb.checked) statuses.push(cb.dataset.wsStatus);
});
document.querySelectorAll('#workshop-permissions-container input[data-ws-action]').forEach(function(cb) {
if (cb.checked) actions.push(cb.dataset.wsAction);
});
var payload = { roles: {} };
payload.roles[role] = { statuses: statuses, actions: actions };
try {
var res = await fetch(API + '/role-permissions/workshop', {
method: 'PUT',
headers: headers(),
body: JSON.stringify(payload)
});
var data = await res.json().catch(function() { return { error: res.statusText }; });
if (!res.ok) throw new Error(data.error || 'Error al guardar');
_workshopPermissions[role] = { statuses: statuses, actions: actions };
if (status) status.textContent = 'Permisos de Taller guardados';
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
} catch (e) {
if (status) status.textContent = e.message;
toast(e.message, 'error');
}
}
function switchPermTab(tab) {
_currentPermTab = tab;
document.querySelectorAll('#perm-panel-modules, #perm-panel-workshop').forEach(function(el) { el.style.display = 'none'; el.classList.remove('is-active'); });
document.querySelectorAll('#tab-perm-modules, #tab-perm-workshop').forEach(function(el) { el.classList.remove('is-active'); });
document.getElementById('perm-panel-' + tab).style.display = 'block';
document.getElementById('perm-panel-' + tab).classList.add('is-active');
document.getElementById('tab-perm-' + tab).classList.add('is-active');
}
// -------------------------------------------------------------------------
// Init
// -------------------------------------------------------------------------
@@ -1172,7 +1278,10 @@ const Config = (() => {
loadAllowedBrands();
loadModules();
loadReceiptConfig();
if (isAdmin) loadRolePermissions();
if (isAdmin) {
loadRolePermissions();
loadWorkshopPermissions();
}
// Activate default or stored tab
var defaultTab = 'general';
@@ -1203,7 +1312,8 @@ const Config = (() => {
loadModules, saveModules,
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
openModal, closeModal, openBranchModal, editBranch,
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions,
loadWorkshopPermissions, renderWorkshopPermissions, saveWorkshopPermissions, switchPermTab
};
})();

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()" />' +