feat(permissions): granular module permissions + workshop permissions matrix
This commit is contained in:
@@ -45,6 +45,47 @@ _DEFAULT_ROLE_PERMISSIONS = {
|
||||
}
|
||||
|
||||
|
||||
# Statuses used in the workshop kanban. Must stay in sync with workshop.js COLUMNS.
|
||||
_WORKSHOP_STATUSES = [
|
||||
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
|
||||
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
|
||||
'por_entregar', 'entregado', 'por_enviar', 'enviado',
|
||||
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
|
||||
]
|
||||
|
||||
# Workshop-specific actions configurable per role.
|
||||
_WORKSHOP_ACTIONS = [
|
||||
{'key': 'create_order', 'label': 'Crear órdenes'},
|
||||
{'key': 'edit_order', 'label': 'Editar órdenes'},
|
||||
{'key': 'delete_order', 'label': 'Eliminar órdenes'},
|
||||
{'key': 'assign_mechanic', 'label': 'Asignar mecánico'},
|
||||
{'key': 'add_items', 'label': 'Agregar artículos'},
|
||||
{'key': 'add_labor', 'label': 'Agregar mano de obra'},
|
||||
{'key': 'change_status', 'label': 'Cambiar estatus'},
|
||||
{'key': 'convert_to_sale', 'label': 'Convertir a venta'},
|
||||
{'key': 'convert_to_remission', 'label': 'Generar nota de remisión'},
|
||||
{'key': 'view_customer_data', 'label': 'Ver datos del cliente/vehículo'},
|
||||
{'key': 'view_prices', 'label': 'Ver precios/costos'},
|
||||
{'key': 'view_notes', 'label': 'Ver bitácora'},
|
||||
]
|
||||
|
||||
# Default workshop permissions per role. Admins see everything; restricted roles only work statuses.
|
||||
_DEFAULT_WORKSHOP_PERMISSIONS = {
|
||||
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
||||
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS]},
|
||||
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
||||
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] != 'delete_order']},
|
||||
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a['key'] for a in _WORKSHOP_ACTIONS if a['key'] not in ('delete_order', 'convert_to_sale')]},
|
||||
'workshop': {
|
||||
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
'mechanic': {
|
||||
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
}
|
||||
|
||||
_AVAILABLE_PERMISSIONS = [
|
||||
{'module': 'Dashboard', 'permissions': [
|
||||
{'key': 'dashboard.view', 'label': 'Ver Dashboard'},
|
||||
@@ -55,23 +96,33 @@ _AVAILABLE_PERMISSIONS = [
|
||||
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
|
||||
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
|
||||
{'key': 'pos.remission', 'label': 'Notas de remisión'},
|
||||
{'key': 'pos.view_cost', 'label': 'Ver costos en POS'},
|
||||
]},
|
||||
{'module': 'Inventario', 'permissions': [
|
||||
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
||||
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
||||
{'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'},
|
||||
{'key': 'inventory.edit', 'label': 'Editar artículos'},
|
||||
{'key': 'inventory.delete', 'label': 'Eliminar artículos'},
|
||||
{'key': 'inventory.adjust', 'label': 'Ajustar stock'},
|
||||
{'key': 'inventory.transfer', 'label': 'Transferir entre sucursales'},
|
||||
{'key': 'inventory.import', 'label': 'Importar artículos masivamente'},
|
||||
{'key': 'inventory.view_cost', 'label': 'Ver costos'},
|
||||
]},
|
||||
{'module': 'Catálogo', 'permissions': [
|
||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||
{'key': 'catalog.edit', 'label': 'Editar catálogo'},
|
||||
]},
|
||||
{'module': 'Clientes', 'permissions': [
|
||||
{'key': 'customers.view', 'label': 'Ver clientes'},
|
||||
{'key': 'customers.create', 'label': 'Crear/editar clientes'},
|
||||
{'key': 'customers.create', 'label': 'Crear clientes'},
|
||||
{'key': 'customers.edit', 'label': 'Editar clientes'},
|
||||
{'key': 'customers.delete', 'label': 'Eliminar clientes'},
|
||||
{'key': 'customers.edit_credit', 'label': 'Editar límite de crédito'},
|
||||
]},
|
||||
{'module': 'Taller', 'permissions': [
|
||||
{'key': 'workshop.view', 'label': 'Ver órdenes'},
|
||||
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes'},
|
||||
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra'},
|
||||
{'key': 'workshop.view', 'label': 'Ver módulo Taller'},
|
||||
{'key': 'workshop.edit', 'label': 'Crear/editar órdenes (deprecado, usar matriz Taller)'},
|
||||
{'key': 'workshop.add_items', 'label': 'Agregar artículos/mano de obra (deprecado, usar matriz Taller)'},
|
||||
]},
|
||||
{'module': 'Facturación', 'permissions': [
|
||||
{'key': 'invoicing.view', 'label': 'Ver facturas'},
|
||||
@@ -79,8 +130,9 @@ _AVAILABLE_PERMISSIONS = [
|
||||
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
|
||||
]},
|
||||
{'module': 'Configuración', 'permissions': [
|
||||
{'key': 'config.view', 'label': 'Ver configuración'},
|
||||
{'key': 'config.edit', 'label': 'Editar configuración'},
|
||||
{'key': 'config.edit_prices', 'label': 'Modificar precios'},
|
||||
{'key': 'config.edit_prices', 'label': 'Modificar precios globales'},
|
||||
]},
|
||||
{'module': 'Contabilidad', 'permissions': [
|
||||
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
|
||||
@@ -115,6 +167,30 @@ def _get_role_permissions(conn, role):
|
||||
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
|
||||
def _get_workshop_permissions(conn, role):
|
||||
"""Return configured workshop permissions for a role, falling back to defaults."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'workshop_permissions'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if row and row[0]:
|
||||
try:
|
||||
configured = json.loads(row[0])
|
||||
if isinstance(configured, dict) and role in configured:
|
||||
return configured[role]
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return _DEFAULT_WORKSHOP_PERMISSIONS.get(role, {'statuses': [], 'actions': []})
|
||||
|
||||
|
||||
def _get_all_workshop_permissions(conn):
|
||||
"""Return effective workshop permissions for every known role."""
|
||||
result = {}
|
||||
for role in _DEFAULT_WORKSHOP_PERMISSIONS:
|
||||
result[role] = _get_workshop_permissions(conn, role)
|
||||
return result
|
||||
|
||||
|
||||
@config_bp.route('/branches', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_branches():
|
||||
@@ -444,6 +520,50 @@ def save_role_permissions_config():
|
||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions/workshop', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
def get_workshop_permissions_config():
|
||||
"""Return the configured workshop permissions (statuses + actions) per role."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
return jsonify({
|
||||
'roles': _get_all_workshop_permissions(conn),
|
||||
'statuses': [{'key': s, 'label': s.replace('_', ' ').title()} for s in _WORKSHOP_STATUSES],
|
||||
'actions': _WORKSHOP_ACTIONS,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions/workshop', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def save_workshop_permissions_config():
|
||||
"""Save the workshop permission mapping per role."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
return jsonify({'error': 'Solo administradores pueden editar permisos de roles'}), 403
|
||||
data = request.get_json() or {}
|
||||
if 'roles' not in data:
|
||||
return jsonify({'error': 'roles object required'}), 400
|
||||
|
||||
for role, cfg in data['roles'].items():
|
||||
if role not in _DEFAULT_WORKSHOP_PERMISSIONS:
|
||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
||||
if not isinstance(cfg, dict):
|
||||
return jsonify({'error': f'config for {role} must be an object'}), 400
|
||||
if not isinstance(cfg.get('statuses', []), list) or not isinstance(cfg.get('actions', []), list):
|
||||
return jsonify({'error': f'statuses/actions for {role} must be lists'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(data['roles']),))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'ok': True, 'updated_roles': list(data['roles'].keys())})
|
||||
|
||||
|
||||
@config_bp.route('/employees/<int:emp_id>', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_employee(emp_id):
|
||||
|
||||
@@ -33,45 +33,43 @@ from services.service_order_engine import (
|
||||
update_status,
|
||||
)
|
||||
from tenant_db import get_tenant_conn
|
||||
from blueprints.config_bp import _get_workshop_permissions, _DEFAULT_WORKSHOP_PERMISSIONS
|
||||
|
||||
service_order_bp = Blueprint('service_orders', __name__, url_prefix='/pos/api/service-orders')
|
||||
|
||||
|
||||
# Roles allowed to view or edit service orders.
|
||||
# Counter (mostrador) creates/edits; workshop/mechanic only view restricted info.
|
||||
# Roles allowed to access the workshop module at all.
|
||||
_WORKSHOP_VIEW_ROLES = {'owner', 'admin', 'counter', 'cashier', 'workshop', 'mechanic'}
|
||||
_WORKSHOP_EDIT_ROLES = {'owner', 'admin', 'counter', 'cashier'}
|
||||
|
||||
# Statuses that mechanics are not allowed to see (single shared mechanic account).
|
||||
# 'autorizada' is intentionally NOT hidden because mechanics must work on authorized orders.
|
||||
# Delivery/logistics statuses are hidden for both workshop and mechanic.
|
||||
_MECHANIC_HIDDEN_STATUSES = {
|
||||
'cotizada', 'por_autorizar', 'autorizacion_parcial',
|
||||
'por_facturar', 'facturada',
|
||||
'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar'
|
||||
}
|
||||
|
||||
# Statuses hidden for workshop accounts (delivery/logistics only).
|
||||
_WORKSHOP_HIDDEN_STATUSES = {
|
||||
'por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar'
|
||||
}
|
||||
|
||||
|
||||
def _can_view_workshop():
|
||||
return g.employee_role in _WORKSHOP_VIEW_ROLES or 'workshop.view' in g.permissions
|
||||
|
||||
|
||||
def _can_edit_workshop():
|
||||
return g.employee_role in _WORKSHOP_EDIT_ROLES
|
||||
def _get_ws_perms():
|
||||
"""Return effective workshop permissions for the current user."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
return _get_workshop_permissions(conn, g.employee_role)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _is_restricted_workshop_viewer():
|
||||
return g.employee_role in ('workshop', 'mechanic')
|
||||
def _visible_statuses():
|
||||
if g.employee_role in ('owner', 'admin'):
|
||||
return list(_DEFAULT_WORKSHOP_PERMISSIONS['admin']['statuses'])
|
||||
return _get_ws_perms().get('statuses', [])
|
||||
|
||||
|
||||
def _redact_order_for_mechanic(order):
|
||||
"""Remove customer/commercial data from the order payload for mechanics."""
|
||||
if not _is_restricted_workshop_viewer():
|
||||
def _can_do_action(action):
|
||||
if g.employee_role in ('owner', 'admin'):
|
||||
return True
|
||||
return action in _get_ws_perms().get('actions', [])
|
||||
|
||||
|
||||
def _redact_order_for_restricted(order):
|
||||
"""Remove customer/commercial data when the role cannot view sensitive data."""
|
||||
if g.employee_role in ('owner', 'admin') or _can_do_action('view_customer_data'):
|
||||
return order
|
||||
sensitive = {
|
||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||
@@ -93,14 +91,14 @@ def _redact_order_for_mechanic(order):
|
||||
if k in sensitive:
|
||||
redacted[k] = redact_value(v)
|
||||
elif k == 'items' and isinstance(v, list):
|
||||
redacted[k] = [_redact_item_for_mechanic(it) for it in v]
|
||||
redacted[k] = [_redact_item_for_restricted(it) for it in v]
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def _redact_item_for_mechanic(item):
|
||||
"""Hide mechanic name from item rows for mechanics."""
|
||||
def _redact_item_for_restricted(item):
|
||||
"""Hide sensitive item rows for restricted viewers."""
|
||||
item = dict(item)
|
||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||
if k in item:
|
||||
@@ -118,14 +116,18 @@ def require_workshop_view(f):
|
||||
return decorated
|
||||
|
||||
|
||||
def require_workshop_edit(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
def require_workshop_action(action):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
if not _can_do_action(action):
|
||||
return jsonify({'error': f'Missing permissions: workshop.{action}'}), 403
|
||||
return f(*args, **kwargs)
|
||||
return decorated
|
||||
return decorator
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@@ -149,12 +151,11 @@ def list_orders():
|
||||
employee_id=employee_id, delivery_method=delivery_method,
|
||||
is_direct=is_direct, q=q, page=page, per_page=per_page
|
||||
)
|
||||
if _is_restricted_workshop_viewer():
|
||||
result['data'] = [_redact_order_for_mechanic(o) for o in result.get('data', [])]
|
||||
hidden_statuses = _MECHANIC_HIDDEN_STATUSES if g.employee_role == 'mechanic' else _WORKSHOP_HIDDEN_STATUSES
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
visible = _visible_statuses()
|
||||
result['data'] = [
|
||||
o for o in result.get('data', [])
|
||||
if o.get('status') not in hidden_statuses
|
||||
_redact_order_for_restricted(o) for o in result.get('data', [])
|
||||
if o.get('status') in visible
|
||||
]
|
||||
return jsonify(result)
|
||||
finally:
|
||||
@@ -204,7 +205,7 @@ def create_order_from_pos():
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('create_order')
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -244,28 +245,22 @@ def get_order(so_id):
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
# Workshop/mechanic accounts cannot view delivery/logistics (and mechanic also commercial) statuses.
|
||||
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
||||
if order.get('status') not in _visible_statuses():
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
if g.employee_role == 'workshop' and order.get('status') in _WORKSHOP_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
return jsonify(_redact_order_for_mechanic(order))
|
||||
return jsonify(_redact_order_for_restricted(order))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_workshop_view
|
||||
@require_workshop_action('edit_order')
|
||||
def update_order(so_id):
|
||||
data = request.get_json() or {}
|
||||
# Full edit remains for owner/admin/counter/cashier.
|
||||
# Mechanics can only update diagnosis_notes, repair_notes and the free-text mechanic_name.
|
||||
if g.employee_role == 'mechanic':
|
||||
allowed = {'diagnosis_notes', 'repair_notes', 'mechanic_name'}
|
||||
if not data or any(k not in allowed for k in data.keys()):
|
||||
return jsonify({'error': 'Solo puedes editar notas de diagnostico, reparacion y asignar mecanico'}), 403
|
||||
elif not _can_edit_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
ok = update_service_order(conn, so_id, data)
|
||||
@@ -277,7 +272,7 @@ def update_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('delete_order')
|
||||
def delete_order(so_id):
|
||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
@@ -294,16 +289,13 @@ def delete_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_workshop_view
|
||||
@require_workshop_action('change_status')
|
||||
def change_status(so_id):
|
||||
if not (_can_edit_workshop() or g.employee_role == 'mechanic'):
|
||||
return jsonify({'error': 'Missing permissions: workshop.edit'}), 403
|
||||
data = request.get_json() or {}
|
||||
new_status = data.get('status')
|
||||
if not new_status:
|
||||
return jsonify({'error': 'status is required'}), 400
|
||||
# Mechanics cannot move orders into commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and new_status in _MECHANIC_HIDDEN_STATUSES:
|
||||
if new_status not in _visible_statuses():
|
||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -322,7 +314,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_items')
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -334,7 +326,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_items')
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -348,7 +340,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_items')
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -361,7 +353,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_labor')
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -375,7 +367,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_labor')
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -389,7 +381,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_labor')
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -416,7 +408,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_items')
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -430,7 +422,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('add_items')
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -448,6 +440,7 @@ def release_order_item(so_id, item_id):
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-sale', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_workshop_action('convert_to_sale')
|
||||
def convert_order_to_sale(so_id):
|
||||
"""Convert a service order into a POS sale.
|
||||
|
||||
@@ -487,6 +480,7 @@ def convert_order_to_sale(so_id):
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
@require_workshop_action('convert_to_remission')
|
||||
def convert_order_to_remission(so_id):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
@@ -517,7 +511,7 @@ def convert_order_to_remission(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('assign_mechanic')
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -536,7 +530,7 @@ def assign_mechanic_endpoint(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/customers', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def create_customer_for_workshop():
|
||||
"""Create a customer directly from the workshop flow."""
|
||||
data = request.get_json() or {}
|
||||
@@ -570,7 +564,7 @@ def create_customer_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def create_vehicle_for_workshop():
|
||||
"""Create a fleet vehicle directly from the workshop flow.
|
||||
|
||||
@@ -685,7 +679,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -701,7 +695,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -716,7 +710,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -731,7 +725,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_workshop_edit
|
||||
@require_workshop_action('edit_order')
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -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()" />' +
|
||||
|
||||
@@ -854,7 +854,7 @@
|
||||
<div class="settings-card">
|
||||
<div class="form-group" style="max-width:320px;">
|
||||
<label class="form-label">Rol</label>
|
||||
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions()">
|
||||
<select class="form-input" id="cfg-perm-role" onchange="Config.renderRolePermissions(); Config.renderWorkshopPermissions();">
|
||||
<option value="">Selecciona un rol</option>
|
||||
<option value="admin">Administrador</option>
|
||||
<option value="cashier">Cajero</option>
|
||||
@@ -866,13 +866,30 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="role-permissions-container" style="margin-top:var(--space-4);">
|
||||
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
|
||||
<!-- Permission tabs -->
|
||||
<div class="tab-bar" style="margin-top:var(--space-4);border-bottom:1px solid var(--color-border);">
|
||||
<button type="button" class="tab-btn is-active" id="tab-perm-modules" onclick="Config.switchPermTab('modules')">Módulos</button>
|
||||
<button type="button" class="tab-btn" id="tab-perm-workshop" onclick="Config.switchPermTab('workshop')">Taller</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:var(--space-4);">
|
||||
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
|
||||
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
|
||||
<div id="perm-panel-modules" class="tab-panel is-active" style="margin-top:var(--space-4);">
|
||||
<div id="role-permissions-container">
|
||||
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos.</p>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-4);">
|
||||
<button class="btn btn--primary" id="btn-save-role-permissions" onclick="Config.saveRolePermissions()">Guardar permisos del rol</button>
|
||||
<span id="role-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="perm-panel-workshop" class="tab-panel" style="margin-top:var(--space-4);display:none;">
|
||||
<div id="workshop-permissions-container">
|
||||
<p style="color:var(--color-text-muted);">Selecciona un rol para ver y editar sus permisos de Taller.</p>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-4);">
|
||||
<button class="btn btn--primary" id="btn-save-workshop-permissions" onclick="Config.saveWorkshopPermissions()">Guardar permisos de Taller</button>
|
||||
<span id="workshop-permissions-status" style="font-size:var(--text-caption);color:var(--color-text-muted);margin-left:var(--space-3);"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1021,7 +1038,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=45" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=40" defer></script>
|
||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
|
||||
@@ -499,7 +499,7 @@
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=45" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=56" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=57" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
81
scripts/migrate_workshop_permissions.py
Normal file
81
scripts/migrate_workshop_permissions.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Initialize workshop_permissions tenant_config for all existing tenants.
|
||||
|
||||
Uses the same defaults defined in pos/blueprints/config_bp.py. Safe to run
|
||||
multiple times (it overwrites with defaults).
|
||||
|
||||
Example:
|
||||
sudo -u postgres python3 scripts/migrate_workshop_permissions.py
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import psycopg2
|
||||
|
||||
# Mirror of pos/blueprints/config_bp.py defaults
|
||||
_WORKSHOP_STATUSES = [
|
||||
'por_revisar', 'en_revision', 'revisada', 'cotizada', 'por_autorizar',
|
||||
'autorizada', 'autorizacion_parcial', 'en_reparacion', 'reparada',
|
||||
'por_entregar', 'entregado', 'por_enviar', 'enviado',
|
||||
'por_facturar', 'facturada', 'por_recolectar', 'cancelada'
|
||||
]
|
||||
|
||||
_ALL_ACTIONS = [
|
||||
'create_order', 'edit_order', 'delete_order', 'assign_mechanic',
|
||||
'add_items', 'add_labor', 'change_status', 'convert_to_sale',
|
||||
'convert_to_remission', 'view_customer_data', 'view_prices', 'view_notes',
|
||||
]
|
||||
|
||||
_DEFAULT_WORKSHOP_PERMISSIONS = {
|
||||
'owner': {'statuses': _WORKSHOP_STATUSES, 'actions': _ALL_ACTIONS},
|
||||
'admin': {'statuses': _WORKSHOP_STATUSES, 'actions': _ALL_ACTIONS},
|
||||
'manager': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a != 'delete_order']},
|
||||
'counter': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a != 'delete_order']},
|
||||
'cashier': {'statuses': _WORKSHOP_STATUSES, 'actions': [a for a in _ALL_ACTIONS if a not in ('delete_order', 'convert_to_sale')]},
|
||||
'workshop': {
|
||||
'statuses': [s for s in _WORKSHOP_STATUSES if s not in ('por_entregar', 'entregado', 'por_enviar', 'enviado', 'por_recolectar')],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
'mechanic': {
|
||||
'statuses': ['por_revisar', 'en_revision', 'revisada', 'en_reparacion', 'reparada', 'autorizada', 'cancelada'],
|
||||
'actions': ['change_status', 'add_labor', 'view_notes']
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_tenant_dbs(master_db='nexus_autoparts'):
|
||||
conn = psycopg2.connect(f"postgresql://postgres@/{master_db}")
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT db_name FROM tenants WHERE is_active = true")
|
||||
dbs = [r[0] for r in cur.fetchall() if r[0]]
|
||||
cur.close(); conn.close()
|
||||
return dbs
|
||||
|
||||
|
||||
def migrate(db_name):
|
||||
conn = psycopg2.connect(f"postgresql://postgres@/{db_name}")
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('workshop_permissions', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(_DEFAULT_WORKSHOP_PERMISSIONS),))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
dbs = get_tenant_dbs()
|
||||
print(f"Migrating {len(dbs)} tenant(s)...")
|
||||
for db in dbs:
|
||||
try:
|
||||
migrate(db)
|
||||
print(f" OK {db}")
|
||||
except Exception as e:
|
||||
print(f" ERR {db}: {e}")
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user