feat: cashier/counter reports, service-order & remission flows, Rached migration utils
- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
This commit is contained in:
38
pos/app.py
38
pos/app.py
@@ -10,24 +10,20 @@ def create_app():
|
||||
from middleware_tenant import resolve_tenant
|
||||
app.before_request(resolve_tenant)
|
||||
|
||||
# ─── Server-side guard: workshop/mechanic users only see /pos/workshop ──────
|
||||
@app.before_request
|
||||
def restrict_workshop_users():
|
||||
path = request.path
|
||||
if not path.startswith('/pos/'):
|
||||
return
|
||||
if path == '/pos/workshop' or path.startswith('/pos/static/') or path.startswith('/pos/api/') or path == '/pos/sw.js' or path == '/pos/login' or path.startswith('/pos/login'):
|
||||
return
|
||||
role = request.cookies.get('pos_role', '').lower()
|
||||
if role in ('workshop', 'mechanic'):
|
||||
return redirect('/pos/workshop')
|
||||
# NOTE: Page-level routing guards are handled client-side by app-init.js
|
||||
# using the employee's current permissions; API endpoints enforce their own
|
||||
# permission checks via @require_auth.
|
||||
|
||||
# ─── PWA: Service Worker must be served from /pos/ scope ──────
|
||||
@app.route('/pos/sw.js')
|
||||
def pos_sw():
|
||||
from flask import send_from_directory
|
||||
return send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript')
|
||||
from flask import send_from_directory, make_response
|
||||
response = make_response(send_from_directory('static/pwa', 'sw.js',
|
||||
mimetype='application/javascript'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
# Register blueprints
|
||||
from blueprints.auth_bp import auth_bp
|
||||
@@ -192,7 +188,11 @@ def create_app():
|
||||
|
||||
@app.route('/pos/dashboard')
|
||||
def pos_dashboard():
|
||||
return render_template('dashboard.html')
|
||||
response = make_response(render_template('dashboard.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/config')
|
||||
def pos_config():
|
||||
@@ -234,6 +234,14 @@ def create_app():
|
||||
def pos_historical_sales():
|
||||
return render_template('historical_sales.html')
|
||||
|
||||
@app.route('/pos/remission-notes')
|
||||
def pos_remission_notes():
|
||||
response = make_response(render_template('remission_notes.html'))
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@app.route('/pos/static/<path:filename>')
|
||||
def pos_static(filename):
|
||||
return send_from_directory('static', filename)
|
||||
|
||||
@@ -8,6 +8,7 @@ from datetime import datetime, timezone, timedelta
|
||||
from flask import Blueprint, request, jsonify, g, make_response
|
||||
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
||||
from tenant_db import get_tenant_conn, get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
||||
|
||||
@@ -158,6 +159,68 @@ def login_pin():
|
||||
return response
|
||||
|
||||
|
||||
@auth_bp.route('/refresh', methods=['POST'])
|
||||
@require_auth()
|
||||
def refresh_token():
|
||||
"""Reissue the JWT with the employee's current permissions from the DB.
|
||||
|
||||
This lets permission changes take effect without forcing a full re-login.
|
||||
The original expiration time is preserved.
|
||||
"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 401
|
||||
|
||||
tenant_id = payload.get('tenant_id')
|
||||
employee_id = payload.get('employee_id')
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
||||
FROM employees e
|
||||
WHERE e.id = %s AND e.is_active = true
|
||||
""",
|
||||
(employee_id,)
|
||||
)
|
||||
emp = cur.fetchone()
|
||||
if not emp:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Employee not found or inactive'}), 404
|
||||
|
||||
cur.execute(
|
||||
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
||||
(emp[0],)
|
||||
)
|
||||
permissions = [r[0] for r in cur.fetchall()]
|
||||
cur.close(); conn.close()
|
||||
|
||||
new_payload = {
|
||||
'tenant_id': tenant_id,
|
||||
'employee_id': emp[0],
|
||||
'name': emp[1],
|
||||
'role': emp[2],
|
||||
'branch_id': emp[3],
|
||||
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
||||
'permissions': permissions,
|
||||
'device_id': payload.get('device_id', 'unknown'),
|
||||
'type': 'pos_access',
|
||||
'exp': payload.get('exp'),
|
||||
'iat': datetime.now(timezone.utc),
|
||||
}
|
||||
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
||||
return jsonify({
|
||||
'token': token,
|
||||
'employee': {
|
||||
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
||||
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
||||
},
|
||||
'permissions': permissions
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
|
||||
@auth_bp.route('/employees', methods=['GET'])
|
||||
def list_login_employees(tenant_id=None):
|
||||
@@ -191,7 +254,7 @@ def list_login_employees(tenant_id=None):
|
||||
name = row[1]
|
||||
parts = name.split()
|
||||
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
||||
employees.append({
|
||||
'id': row[0],
|
||||
'name': name,
|
||||
|
||||
@@ -3,16 +3,26 @@
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.audit import log_action
|
||||
|
||||
cashregister_bp = Blueprint('cashregister', __name__, url_prefix='/pos/api/register')
|
||||
|
||||
|
||||
# Roles expected to operate a cash register even without the explicit pos.sell permission.
|
||||
_REGISTER_ROLES = {'owner', 'admin', 'cashier', 'counter'}
|
||||
|
||||
|
||||
def _can_operate_register():
|
||||
return g.employee_role in _REGISTER_ROLES or 'pos.sell' in g.permissions
|
||||
|
||||
|
||||
@cashregister_bp.route('/open', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def open_register():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Open a cash register session.
|
||||
|
||||
Body: {register_number: int, opening_amount: float}
|
||||
@@ -84,7 +94,7 @@ def open_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/current', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def current_register():
|
||||
"""Get the current open register for this employee."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -115,8 +125,10 @@ def current_register():
|
||||
|
||||
|
||||
@cashregister_bp.route('/movement', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cash_movement():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Record a cash in/out movement with mandatory reason.
|
||||
|
||||
Body: {type: 'in'|'out', amount: float, reason: str}
|
||||
@@ -273,8 +285,10 @@ def _compute_register_summary(conn, register_id):
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-x', methods=['GET'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_x():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Partial cut (corte X): read-only summary without closing the register."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
@@ -301,8 +315,10 @@ def cut_x():
|
||||
|
||||
|
||||
@cashregister_bp.route('/cut-z', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
@require_auth()
|
||||
def cut_z():
|
||||
if not _can_operate_register():
|
||||
return jsonify({'error': 'Missing permissions: pos.sell'}), 403
|
||||
"""Final cut (corte Z): close the register.
|
||||
|
||||
Body: {closing_amount: float} (the amount physically counted in the register)
|
||||
@@ -379,12 +395,18 @@ def cut_z():
|
||||
|
||||
|
||||
@cashregister_bp.route('/history', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def register_history():
|
||||
"""List closed registers with summary.
|
||||
|
||||
Query params: date_from, date_to, employee_id, page, per_page
|
||||
|
||||
Permission rules:
|
||||
- owner/admin and users with pos.view can query any employee.
|
||||
- Cashiers/counters without pos.view can only query their own registers.
|
||||
"""
|
||||
can_view_all = g.employee_role in ('owner', 'admin') or has_permission('pos.view')
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -402,6 +424,9 @@ def register_history():
|
||||
date_to = request.args.get('date_to')
|
||||
employee_id = request.args.get('employee_id')
|
||||
|
||||
if not can_view_all:
|
||||
employee_id = g.employee_id
|
||||
|
||||
if date_from:
|
||||
where_clauses.append("cr.closed_at >= %s")
|
||||
params.append(date_from)
|
||||
@@ -576,3 +601,89 @@ def daily_summary():
|
||||
'movements_out': movements['out'],
|
||||
'registers': registers,
|
||||
})
|
||||
|
||||
|
||||
@cashregister_bp.route('/<int:register_id>/sales', methods=['GET'])
|
||||
@require_auth()
|
||||
def register_sales(register_id):
|
||||
"""List the sales associated with a specific cash register (cash cut).
|
||||
|
||||
Returns sales details and a summary by payment method. Accessible to
|
||||
owners/admins, users with pos.view, or the employee who operated the register.
|
||||
"""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT employee_id, branch_id FROM cash_registers WHERE id = %s",
|
||||
(register_id,)
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register not found'}), 404
|
||||
|
||||
register_employee_id, register_branch_id = row
|
||||
|
||||
can_view = (
|
||||
g.employee_role in ('owner', 'admin') or
|
||||
has_permission('pos.view') or
|
||||
register_employee_id == g.employee_id
|
||||
)
|
||||
if not can_view:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
if g.branch_id and register_branch_id and register_branch_id != g.branch_id:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Register belongs to another branch'}), 403
|
||||
|
||||
where = "s.register_id = %s"
|
||||
params = [register_id]
|
||||
if g.branch_id:
|
||||
where += " AND s.branch_id = %s"
|
||||
params.append(g.branch_id)
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT s.id, s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given, s.status,
|
||||
s.created_at, c.name as customer_name, e.name as employee_name
|
||||
FROM sales s
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
""", params)
|
||||
|
||||
sales = []
|
||||
summary = {'total': 0.0, 'count': 0, 'by_method': {}}
|
||||
for r in cur.fetchall():
|
||||
sale = {
|
||||
'id': r[0], 'sale_type': r[1], 'payment_method': r[2],
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
'discount_total': float(r[4]) if r[4] else 0,
|
||||
'tax_total': float(r[5]) if r[5] else 0,
|
||||
'total': float(r[6]) if r[6] else 0,
|
||||
'amount_paid': float(r[7]) if r[7] else 0,
|
||||
'change_given': float(r[8]) if r[8] else 0,
|
||||
'status': r[9], 'created_at': str(r[10]),
|
||||
'customer_name': r[11], 'employee_name': r[12]
|
||||
}
|
||||
sales.append(sale)
|
||||
if sale['status'] == 'completed':
|
||||
summary['total'] += sale['total']
|
||||
summary['count'] += 1
|
||||
m = sale['payment_method'] or 'Otro'
|
||||
summary['by_method'][m] = (summary['by_method'].get(m, 0) + sale['total'])
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'register_id': register_id,
|
||||
'sales': sales,
|
||||
'summary': {
|
||||
'total': round(summary['total'], 2),
|
||||
'count': summary['count'],
|
||||
'by_method': {k: round(v, 2) for k, v in summary['by_method'].items()}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# /home/Autopartes/pos/blueprints/config_bp.py
|
||||
"""Config blueprint: tenant configuration, branches, theming."""
|
||||
|
||||
import json
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
@@ -8,6 +9,109 @@ from tenant_db import get_tenant_conn
|
||||
config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config')
|
||||
|
||||
|
||||
# Default permission set per role. Can be overridden per tenant via role_permissions config.
|
||||
_DEFAULT_ROLE_PERMISSIONS = {
|
||||
'owner': [], # owner bypasses permission checks
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'workshop.view', 'workshop.edit',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view', 'pos.remission',
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create',
|
||||
'workshop.view', 'workshop.edit', 'workshop.add_items',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel'],
|
||||
'counter': ['pos.remission', 'pos.view',
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create'],
|
||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||
'accountant': ['accounting.view', 'accounting.create',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'customers.view',
|
||||
'fleet.view'],
|
||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||
'mechanic': ['workshop.view'],
|
||||
'sales': ['pos.sell', 'pos.discount', 'pos.view', 'catalog.view',
|
||||
'customers.view', 'customers.create'],
|
||||
}
|
||||
|
||||
|
||||
_AVAILABLE_PERMISSIONS = [
|
||||
{'module': 'Punto de Venta', 'permissions': [
|
||||
{'key': 'pos.sell', 'label': 'Realizar ventas'},
|
||||
{'key': 'pos.view', 'label': 'Ver ventas/cotizaciones'},
|
||||
{'key': 'pos.discount', 'label': 'Aplicar descuentos'},
|
||||
{'key': 'pos.cancel', 'label': 'Cancelar ventas'},
|
||||
{'key': 'pos.remission', 'label': 'Notas de remisión'},
|
||||
]},
|
||||
{'module': 'Inventario', 'permissions': [
|
||||
{'key': 'inventory.view', 'label': 'Ver inventario'},
|
||||
{'key': 'inventory.create', 'label': 'Crear artículos'},
|
||||
{'key': 'inventory.edit', 'label': 'Editar/eliminar artículos'},
|
||||
]},
|
||||
{'module': 'Catálogo', 'permissions': [
|
||||
{'key': 'catalog.view', 'label': 'Ver catálogo'},
|
||||
]},
|
||||
{'module': 'Clientes', 'permissions': [
|
||||
{'key': 'customers.view', 'label': 'Ver clientes'},
|
||||
{'key': 'customers.create', 'label': 'Crear/editar clientes'},
|
||||
]},
|
||||
{'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'},
|
||||
]},
|
||||
{'module': 'Facturación', 'permissions': [
|
||||
{'key': 'invoicing.view', 'label': 'Ver facturas'},
|
||||
{'key': 'invoicing.create', 'label': 'Crear facturas'},
|
||||
{'key': 'invoicing.cancel', 'label': 'Cancelar facturas'},
|
||||
]},
|
||||
{'module': 'Configuración', 'permissions': [
|
||||
{'key': 'config.edit', 'label': 'Editar configuración'},
|
||||
{'key': 'config.edit_prices', 'label': 'Modificar precios'},
|
||||
]},
|
||||
{'module': 'Contabilidad', 'permissions': [
|
||||
{'key': 'accounting.view', 'label': 'Ver contabilidad'},
|
||||
{'key': 'accounting.create', 'label': 'Crear movimientos contables'},
|
||||
]},
|
||||
{'module': 'Reportes', 'permissions': [
|
||||
{'key': 'reports.view', 'label': 'Ver reportes'},
|
||||
{'key': 'reports.financial', 'label': 'Reportes financieros'},
|
||||
]},
|
||||
{'module': 'Flotillas', 'permissions': [
|
||||
{'key': 'fleet.view', 'label': 'Ver flotillas'},
|
||||
{'key': 'fleet.create', 'label': 'Crear flotillas'},
|
||||
{'key': 'fleet.edit', 'label': 'Editar flotillas'},
|
||||
{'key': 'fleet.delete', 'label': 'Eliminar flotillas'},
|
||||
]},
|
||||
]
|
||||
|
||||
|
||||
def _get_role_permissions(conn, role):
|
||||
"""Return configured permissions for a role, falling back to defaults."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'role_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 list(configured.get(role, []))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return list(_DEFAULT_ROLE_PERMISSIONS.get(role, []))
|
||||
|
||||
|
||||
@config_bp.route('/branches', methods=['GET'])
|
||||
@require_auth()
|
||||
def list_branches():
|
||||
@@ -250,7 +354,7 @@ def create_employee():
|
||||
nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise'
|
||||
return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403
|
||||
|
||||
valid_roles = ['admin', 'cashier', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
valid_roles = ['admin', 'cashier', 'counter', 'warehouse', 'accountant', 'workshop', 'mechanic']
|
||||
if data['role'] not in valid_roles:
|
||||
return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400
|
||||
|
||||
@@ -265,33 +369,8 @@ def create_employee():
|
||||
data['role'], data.get('branch_id'), data.get('max_discount_pct', 0)))
|
||||
emp_id = cur.fetchone()[0]
|
||||
|
||||
# Set default permissions by role
|
||||
role_permissions = {
|
||||
'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost',
|
||||
'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer',
|
||||
'catalog.view', 'catalog.edit',
|
||||
'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit',
|
||||
'invoicing.view', 'invoicing.create',
|
||||
'reports.view', 'reports.financial',
|
||||
'config.view', 'config.edit', 'config.edit_prices',
|
||||
'workshop.view', 'workshop.edit',
|
||||
'fleet.view', 'fleet.create', 'fleet.edit', 'fleet.delete'],
|
||||
'cashier': ['pos.sell', 'pos.discount', 'pos.cancel',
|
||||
'catalog.view',
|
||||
'inventory.view', 'inventory.create',
|
||||
'customers.view', 'customers.create'],
|
||||
'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit',
|
||||
'inventory.adjust', 'inventory.transfer', 'catalog.view'],
|
||||
'accountant': ['accounting.view', 'accounting.create',
|
||||
'invoicing.view', 'invoicing.create', 'invoicing.cancel',
|
||||
'reports.view', 'reports.financial',
|
||||
'customers.view',
|
||||
'fleet.view'],
|
||||
'workshop': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||
'mechanic': ['workshop.view', 'workshop.edit', 'workshop.add_items'],
|
||||
}
|
||||
|
||||
for perm in role_permissions.get(data['role'], []):
|
||||
# Set default permissions by role (configurable per tenant)
|
||||
for perm in _get_role_permissions(conn, data['role']):
|
||||
cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
@@ -307,6 +386,61 @@ def create_employee():
|
||||
return jsonify({'id': emp_id, 'message': 'Employee created'}), 201
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_role_permissions_config():
|
||||
"""Return the configured permissions for each role and the available permission list."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
configured = {}
|
||||
for role in _DEFAULT_ROLE_PERMISSIONS:
|
||||
configured[role] = _get_role_permissions(conn, role)
|
||||
return jsonify({
|
||||
'roles': configured,
|
||||
'available': _AVAILABLE_PERMISSIONS,
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@config_bp.route('/role-permissions', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def save_role_permissions_config():
|
||||
"""Save the permission mapping per role. Only owner/admin can edit."""
|
||||
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, perms in data['roles'].items():
|
||||
if role not in _DEFAULT_ROLE_PERMISSIONS:
|
||||
return jsonify({'error': f'Invalid role: {role}'}), 400
|
||||
if not isinstance(perms, list):
|
||||
return jsonify({'error': f'permissions for {role} must be a list'}), 400
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES ('role_permissions', %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", (json.dumps(data['roles']),))
|
||||
|
||||
# Apply the new permissions to existing employees of each affected role
|
||||
for role, perms in data['roles'].items():
|
||||
cur.execute("DELETE FROM employee_permissions WHERE employee_id IN (SELECT id FROM employees WHERE role = %s)", (role,))
|
||||
cur.execute("SELECT id FROM employees WHERE role = %s", (role,))
|
||||
emp_ids = [r[0] for r in cur.fetchall()]
|
||||
for emp_id in emp_ids:
|
||||
for perm in perms:
|
||||
cur.execute(
|
||||
"INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING",
|
||||
(emp_id, perm)
|
||||
)
|
||||
|
||||
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):
|
||||
@@ -767,7 +901,7 @@ def update_whatsapp_config():
|
||||
|
||||
|
||||
@config_bp.route('/modules', methods=['GET'])
|
||||
@require_auth('config.view')
|
||||
@require_auth()
|
||||
def get_modules():
|
||||
"""Get enabled modules for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -820,6 +954,35 @@ def update_modules():
|
||||
}})
|
||||
|
||||
|
||||
@config_bp.route('/counter-remission', methods=['GET'])
|
||||
@require_auth()
|
||||
def get_counter_remission_config():
|
||||
"""Get counter remission note feature flag for this tenant."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'counter_remission_enabled'")
|
||||
row = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'enabled': str(row[0]).lower() == 'true' if row else False})
|
||||
|
||||
|
||||
@config_bp.route('/counter-remission', methods=['PUT'])
|
||||
@require_auth('config.edit')
|
||||
def update_counter_remission_config():
|
||||
"""Enable/disable counter remission notes for this tenant."""
|
||||
data = request.get_json() or {}
|
||||
enabled = 'true' if data.get('enabled') else 'false'
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
|
||||
""", ('counter_remission_enabled', enabled))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'enabled': enabled == 'true'})
|
||||
|
||||
|
||||
@config_bp.route('/onboarding-status', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_onboarding_status():
|
||||
|
||||
@@ -10,10 +10,20 @@ from services.audit import log_action
|
||||
customers_bp = Blueprint('customers', __name__, url_prefix='/pos/api/customers')
|
||||
|
||||
|
||||
def _can_view_customers():
|
||||
"""Taller/counter employees need customer autocomplete even without customers.view."""
|
||||
return g.employee_role == 'owner' or 'customers.view' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||
|
||||
|
||||
def _can_create_customer():
|
||||
"""Taller/counter employees can create customers on the fly from an order."""
|
||||
return g.employee_role == 'owner' or 'customers.create' in g.permissions or g.employee_role in ('workshop', 'mechanic', 'counter')
|
||||
|
||||
|
||||
# ─── Customer CRUD ─────────────────────────────
|
||||
|
||||
@customers_bp.route('', methods=['GET'])
|
||||
@require_auth('customers.view')
|
||||
@require_auth()
|
||||
def list_customers():
|
||||
"""Search/list customers. Supports autocomplete-style search by name, RFC, phone.
|
||||
|
||||
@@ -23,6 +33,8 @@ def list_customers():
|
||||
per_page: items per page (default 50, max 200)
|
||||
branch_id: filter by branch (default: current user's branch)
|
||||
"""
|
||||
if not _can_view_customers():
|
||||
return jsonify({'error': 'Missing permissions: customers.view'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -219,13 +231,15 @@ def get_customer_purchases(customer_id):
|
||||
|
||||
|
||||
@customers_bp.route('', methods=['POST'])
|
||||
@require_auth('customers.create')
|
||||
@require_auth()
|
||||
def create_customer():
|
||||
"""Create a new customer.
|
||||
|
||||
Body: {name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp, email,
|
||||
phone, address, price_tier, credit_limit, vehicle_info}
|
||||
"""
|
||||
if not _can_create_customer():
|
||||
return jsonify({'error': 'Missing permissions: customers.create'}), 403
|
||||
data = request.get_json() or {}
|
||||
if not data.get('name'):
|
||||
return jsonify({'error': 'name is required'}), 400
|
||||
|
||||
@@ -103,8 +103,13 @@ def list_items():
|
||||
# branch_id no longer filters inventory rows (shared catalog).
|
||||
# It is used only to show per-branch stock.
|
||||
if search:
|
||||
where_clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s)")
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
# Search also matches alternate / alias SKUs.
|
||||
where_clauses.append(
|
||||
"(i.part_number ILIKE %s OR i.name ILIKE %s OR i.barcode ILIKE %s "
|
||||
"OR EXISTS (SELECT 1 FROM inventory_sku_aliases a "
|
||||
"WHERE a.inventory_id = i.id AND a.is_active = true AND a.sku ILIKE %s))"
|
||||
)
|
||||
params.extend([f'%{search}%', f'%{search}%', f'%{search}%', f'%{search}%'])
|
||||
if category:
|
||||
where_clauses.append("i.category_id = %s")
|
||||
params.append(int(category))
|
||||
@@ -490,6 +495,7 @@ def bulk_import_items():
|
||||
'marca': 'brand', 'precio': 'price', 'costo': 'cost',
|
||||
'cantidad': 'stock', 'existencia': 'stock', 'inventario': 'stock',
|
||||
'ubicacion': 'location', 'categoria': 'category',
|
||||
'sku_secundario': 'sku_secondary', 'sku_alt': 'sku_secondary', 'sku_alternativo': 'sku_secondary',
|
||||
'fabricante': 'make', 'vehiculo': 'make', 'auto': 'make',
|
||||
'modelo': 'model', 'anio': 'year', 'ano': 'year',
|
||||
'motor': 'engine', 'codigo_motor': 'engine_code',
|
||||
@@ -599,7 +605,20 @@ def bulk_import_items():
|
||||
created_ids.append(item_id)
|
||||
created += 1
|
||||
|
||||
# ---------- 2. Vehicle compatibility ----------
|
||||
# ---------- 2. Secondary SKU alias ----------
|
||||
sku_secondary = str(row.get('sku_secondary', '')).strip()
|
||||
if sku_secondary:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO inventory_sku_aliases (inventory_id, sku, label)
|
||||
VALUES (%s, %s, %s)
|
||||
ON CONFLICT DO NOTHING
|
||||
""",
|
||||
(item_id, sku_secondary, 'Secundario')
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# ---------- 3. Vehicle compatibility ----------
|
||||
make = str(row.get('make', '')).strip()
|
||||
model = str(row.get('model', '')).strip()
|
||||
year_str = str(row.get('year', '')).strip()
|
||||
|
||||
@@ -13,7 +13,8 @@ from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
process_sale, cancel_sale, calculate_totals,
|
||||
get_price_for_customer, get_margin_info
|
||||
get_price_for_customer, get_margin_info,
|
||||
create_remission_note, pay_pending_sale
|
||||
)
|
||||
from services.inventory_engine import get_stock
|
||||
from services.audit import log_action
|
||||
@@ -22,6 +23,15 @@ from config import JWT_SECRET
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
|
||||
|
||||
def _tenant_allows_negative_stock(conn):
|
||||
"""Return True if the tenant explicitly allows selling below zero stock."""
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = 'allow_negative_stock'")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
return row is not None and str(row[0]).lower() in ('true', '1', 'yes')
|
||||
|
||||
|
||||
def _enrich_items(cur, items, customer_id=None):
|
||||
"""Look up inventory data for items that lack unit_price/tax_rate.
|
||||
|
||||
@@ -104,17 +114,19 @@ def create_sale():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
# Verify stock availability per item for the active branch
|
||||
# (skipped for tenants configured to allow negative stock / oversell)
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
if not _tenant_allows_negative_stock(conn):
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
sale = process_sale(conn, data)
|
||||
@@ -131,8 +143,89 @@ def create_sale():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales/remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
def create_remission():
|
||||
"""Create a counter remission note (pending payment, reserved stock)."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
|
||||
branch_id = data.get('branch_id', g.branch_id)
|
||||
if not _tenant_allows_negative_stock(conn):
|
||||
for item in data.get('items', []):
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if inv_id:
|
||||
available = get_stock(conn, inv_id, branch_id)
|
||||
if available < qty:
|
||||
conn.close()
|
||||
return jsonify({
|
||||
'error': f'Insufficient stock for item {inv_id}. Available: {available}, requested: {qty}'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
sale = create_remission_note(conn, {
|
||||
'tenant_id': g.tenant_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': data.get('customer_id'),
|
||||
'items': data.get('items', []),
|
||||
'notes': data.get('notes'),
|
||||
'register_id': data.get('register_id'),
|
||||
'currency': data.get('currency', 'MXN'),
|
||||
'exchange_rate': data.get('exchange_rate'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
})
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 201
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/pay', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def pay_sale(sale_id):
|
||||
"""Pay a pending counter remission note.
|
||||
|
||||
Body: {
|
||||
payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto',
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}],
|
||||
register_id: int,
|
||||
reference: str
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
sale = pay_pending_sale(conn, sale_id, {
|
||||
'payment_method': data.get('payment_method', 'efectivo'),
|
||||
'amount_paid': data.get('amount_paid', 0),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'register_id': data.get('register_id'),
|
||||
'reference': data.get('reference', ''),
|
||||
})
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify(sale), 200
|
||||
except ValueError as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/sales', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
@require_auth()
|
||||
def list_sales():
|
||||
"""List sales with filters.
|
||||
|
||||
@@ -146,6 +239,13 @@ def list_sales():
|
||||
page: int (default 1)
|
||||
per_page: int (default 50, max 200)
|
||||
"""
|
||||
sale_type = request.args.get('sale_type')
|
||||
is_remission = sale_type == 'counter_remission'
|
||||
can_view_all = g.employee_role == 'owner' or has_permission('pos.view')
|
||||
can_view_remissions = is_remission and (has_permission('pos.sell') or has_permission('pos.remission'))
|
||||
if not (can_view_all or can_view_remissions):
|
||||
return jsonify({'error': 'Missing permissions: pos.view'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -160,6 +260,10 @@ def list_sales():
|
||||
employee_id = request.args.get('employee_id')
|
||||
customer_id = request.args.get('customer_id')
|
||||
status = request.args.get('status')
|
||||
sale_type = request.args.get('sale_type')
|
||||
q = request.args.get('q')
|
||||
customer_q = request.args.get('customer')
|
||||
courier_id = request.args.get('courier_id')
|
||||
register_id = request.args.get('register_id')
|
||||
|
||||
if date_from:
|
||||
@@ -177,6 +281,18 @@ def list_sales():
|
||||
if status:
|
||||
where_clauses.append("s.status = %s")
|
||||
params.append(status)
|
||||
if sale_type:
|
||||
where_clauses.append("s.sale_type = %s")
|
||||
params.append(sale_type)
|
||||
if q:
|
||||
where_clauses.append("(s.id::text ILIKE %s OR c.name ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%'])
|
||||
if customer_q:
|
||||
where_clauses.append("c.name ILIKE %s")
|
||||
params.append(f'%{customer_q}%')
|
||||
if courier_id:
|
||||
where_clauses.append("s.courier_id = %s")
|
||||
params.append(int(courier_id))
|
||||
if register_id:
|
||||
where_clauses.append("s.register_id = %s")
|
||||
params.append(int(register_id))
|
||||
@@ -195,12 +311,14 @@ def list_sales():
|
||||
SELECT s.id, s.branch_id, s.customer_id, s.employee_id, s.register_id,
|
||||
s.sale_type, s.payment_method, s.subtotal, s.discount_total,
|
||||
s.tax_total, s.total, s.amount_paid, s.change_given,
|
||||
s.status, s.created_at,
|
||||
s.status, s.created_at, s.courier_id,
|
||||
e.name as employee_name,
|
||||
c.name as customer_name
|
||||
c.name as customer_name,
|
||||
co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE {where}
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
@@ -219,7 +337,9 @@ def list_sales():
|
||||
'amount_paid': float(r[11]) if r[11] else 0,
|
||||
'change_given': float(r[12]) if r[12] else 0,
|
||||
'status': r[13], 'created_at': str(r[14]),
|
||||
'employee_name': r[15], 'customer_name': r[16],
|
||||
'courier_id': r[15],
|
||||
'employee_name': r[16], 'customer_name': r[17],
|
||||
'courier_name': r[18],
|
||||
})
|
||||
|
||||
cur.close()
|
||||
@@ -1475,6 +1595,7 @@ def convert_quotation(quot_id):
|
||||
'register_id': data.get('register_id'),
|
||||
'amount_paid': data.get('amount_paid', 0),
|
||||
'payment_details': data.get('payment_details', []),
|
||||
'reference': data.get('reference', ''),
|
||||
'notes': f'Convertida de cotizacion #{quot_id}',
|
||||
'currency': quot_currency,
|
||||
'exchange_rate': quot_rate,
|
||||
@@ -2545,6 +2666,79 @@ def print_ticket(sale_id):
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
@pos_bp.route('/sales/<int:sale_id>/print-remission', methods=['POST'])
|
||||
@require_auth()
|
||||
def print_remission(sale_id):
|
||||
"""Generate printable data for a counter remission note."""
|
||||
from middleware import has_permission
|
||||
if not (has_permission('pos.remission') or has_permission('pos.sell')):
|
||||
return jsonify({'error': 'Missing permissions'}), 403
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT s.*, e.name as employee_name, c.name as customer_name, co.name as courier_name
|
||||
FROM sales s
|
||||
LEFT JOIN employees e ON s.employee_id = e.id
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
LEFT JOIN couriers co ON s.courier_id = co.id
|
||||
WHERE s.id = %s
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Sale not found'}), 404
|
||||
|
||||
cols = [desc[0] for desc in cur.description]
|
||||
sale = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'):
|
||||
if sale.get(k) is not None:
|
||||
sale[k] = float(sale[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT name, quantity, unit_price, subtotal
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'name': r[0], 'quantity': r[1],
|
||||
'unit_price': float(r[2]) if r[2] else 0,
|
||||
'subtotal': float(r[3]) if r[3] else 0,
|
||||
})
|
||||
|
||||
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
|
||||
try:
|
||||
cur.execute("SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')")
|
||||
for rw in cur.fetchall():
|
||||
if rw[0] == 'business_name':
|
||||
business_info['name'] = rw[1]
|
||||
else:
|
||||
business_info[rw[0]] = rw[1]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
cur.close(); conn.close()
|
||||
|
||||
return jsonify({
|
||||
'folio': f'NR-{sale["id"]}',
|
||||
'date': str(sale.get('created_at', '')),
|
||||
'employee': sale.get('employee_name', ''),
|
||||
'customer': sale.get('customer_name', ''),
|
||||
'courier': sale.get('courier_name', ''),
|
||||
'items': items,
|
||||
'subtotal': sale.get('subtotal', 0),
|
||||
'discount_total': sale.get('discount_total', 0),
|
||||
'tax_total': sale.get('tax_total', 0),
|
||||
'total': sale.get('total', 0),
|
||||
'status': sale.get('status', ''),
|
||||
'business_name': business_info.get('name', ''),
|
||||
'business_rfc': business_info.get('rfc', ''),
|
||||
'business_address': business_info.get('address', ''),
|
||||
})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
|
||||
@@ -4,6 +4,7 @@ Prefix: /pos/api/service-orders
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
from middleware import require_auth
|
||||
@@ -11,6 +12,7 @@ from services.service_order_engine import (
|
||||
add_item,
|
||||
add_labor,
|
||||
assign_mechanic,
|
||||
convert_to_remission,
|
||||
convert_to_sale,
|
||||
create_service_catalog_item,
|
||||
create_service_order,
|
||||
@@ -35,8 +37,91 @@ from tenant_db import get_tenant_conn
|
||||
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.
|
||||
_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).
|
||||
_MECHANIC_HIDDEN_STATUSES = {
|
||||
'cotizada', 'por_autorizar', 'autorizada', 'autorizacion_parcial',
|
||||
'por_facturar', 'facturada'
|
||||
}
|
||||
|
||||
|
||||
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 _is_restricted_workshop_viewer():
|
||||
return g.employee_role in ('workshop', 'mechanic')
|
||||
|
||||
|
||||
def _redact_order_for_mechanic(order):
|
||||
"""Remove customer/commercial data from the order payload for mechanics."""
|
||||
if not _is_restricted_workshop_viewer():
|
||||
return order
|
||||
sensitive = {
|
||||
'customer_id', 'customer_name', 'customer_phone', 'customer_address',
|
||||
'workshop_name', 'vehicle_description', 'vehicle_plate', 'vehicle_make',
|
||||
'vehicle_model', 'delivery_method', 'courier_id', 'courier_name',
|
||||
'requires_invoice', 'employee_id', 'employee_name', 'estimated_cost',
|
||||
'final_cost', 'total_parts', 'total_labor', 'total',
|
||||
}
|
||||
|
||||
def redact_value(v):
|
||||
if isinstance(v, str):
|
||||
return '—'
|
||||
if isinstance(v, (int, float)):
|
||||
return 0
|
||||
return None
|
||||
|
||||
redacted = {}
|
||||
for k, v in order.items():
|
||||
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]
|
||||
else:
|
||||
redacted[k] = v
|
||||
return redacted
|
||||
|
||||
|
||||
def _redact_item_for_mechanic(item):
|
||||
"""Hide mechanic name from item rows for mechanics."""
|
||||
item = dict(item)
|
||||
for k in ('mechanic_id', 'unit_cost', 'unit_price'):
|
||||
if k in item:
|
||||
item[k] = None if not isinstance(item[k], (int, float)) else 0
|
||||
return item
|
||||
|
||||
|
||||
def require_workshop_view(f):
|
||||
@wraps(f)
|
||||
@require_auth()
|
||||
def decorated(*args, **kwargs):
|
||||
if not _can_view_workshop():
|
||||
return jsonify({'error': 'Missing permissions: workshop.view'}), 403
|
||||
return f(*args, **kwargs)
|
||||
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
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_orders():
|
||||
status = request.args.get('status')
|
||||
priority = request.args.get('priority')
|
||||
@@ -56,6 +141,14 @@ 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', [])]
|
||||
# Shared mechanic account can see all orders except commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic':
|
||||
result['data'] = [
|
||||
o for o in result.get('data', [])
|
||||
if o.get('status') not in _MECHANIC_HIDDEN_STATUSES
|
||||
]
|
||||
return jsonify(result)
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -88,6 +181,11 @@ def create_order_from_pos():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
so_id = result['service_order_id']
|
||||
for item in items:
|
||||
@@ -99,7 +197,7 @@ def create_order_from_pos():
|
||||
|
||||
|
||||
@service_order_bp.route('', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_order():
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -120,6 +218,11 @@ def create_order():
|
||||
'delivery_method': data.get('delivery_method'),
|
||||
'courier_id': data.get('courier_id'),
|
||||
'is_direct': data.get('is_direct', False),
|
||||
'requires_invoice': data.get('requires_invoice', False),
|
||||
'workshop_name': data.get('workshop_name'),
|
||||
'customer_address': data.get('customer_address'),
|
||||
'customer_phone': data.get('customer_phone'),
|
||||
'vehicle_description': data.get('vehicle_description'),
|
||||
})
|
||||
return jsonify(result), 201
|
||||
finally:
|
||||
@@ -127,22 +230,33 @@ def create_order():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def get_order(so_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
order = get_service_order(conn, so_id)
|
||||
if not order:
|
||||
return jsonify({'error': 'Service order not found'}), 404
|
||||
return jsonify(order)
|
||||
# Shared mechanic account cannot view commercial/closed statuses.
|
||||
if g.employee_role == 'mechanic' and order.get('status') in _MECHANIC_HIDDEN_STATUSES:
|
||||
return jsonify({'error': 'No tienes acceso a esta orden'}), 403
|
||||
return jsonify(_redact_order_for_mechanic(order))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
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)
|
||||
@@ -154,7 +268,7 @@ def update_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order(so_id):
|
||||
"""Soft-delete a service order. Restricted to owner/admin."""
|
||||
if g.employee_role not in ('owner', 'admin'):
|
||||
@@ -171,12 +285,17 @@ def delete_order(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/status', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_view
|
||||
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:
|
||||
return jsonify({'error': 'No puedes cambiar a este estatus'}), 403
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = update_status(
|
||||
@@ -194,7 +313,7 @@ def change_status(so_id):
|
||||
# ─── Items (Parts) ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_item(so_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -206,7 +325,7 @@ def add_order_item(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_item(item_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -220,7 +339,7 @@ def update_order_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_item(item_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -233,7 +352,7 @@ def delete_order_item(item_id):
|
||||
# ─── Labor ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/labor', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def add_order_labor(so_id):
|
||||
data = request.get_json() or {}
|
||||
if not data.get('description'):
|
||||
@@ -247,7 +366,7 @@ def add_order_labor(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_order_labor(labor_id):
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -261,7 +380,7 @@ def update_order_labor(labor_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/labor/<int:labor_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_order_labor(labor_id):
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -274,7 +393,7 @@ def delete_order_labor(labor_id):
|
||||
# ─── Kanban Summary ─────────────────────────────
|
||||
|
||||
@service_order_bp.route('/kanban/summary', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def kanban_summary():
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
@@ -288,7 +407,7 @@ def kanban_summary():
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/reserve', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def reserve_order_item(so_id, item_id):
|
||||
"""Reserve inventory for a service order item."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -302,7 +421,7 @@ def reserve_order_item(so_id, item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/items/<int:item_id>/release', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def release_order_item(so_id, item_id):
|
||||
"""Release a previous inventory reservation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -354,11 +473,42 @@ def convert_order_to_sale(so_id):
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Convert to remission note ────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/convert-to-remission', methods=['POST'])
|
||||
@require_auth('pos.remission')
|
||||
def convert_order_to_remission(so_id):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
Body: {
|
||||
register_id: int (optional),
|
||||
notes: str (optional)
|
||||
}
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
sale_payload = {
|
||||
'register_id': data.get('register_id'),
|
||||
'notes': data.get('notes'),
|
||||
}
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
try:
|
||||
result = convert_to_remission(
|
||||
conn, so_id, sale_payload, employee_id=g.employee_id
|
||||
)
|
||||
return jsonify(result), 201
|
||||
except ValueError as e:
|
||||
return jsonify({'error': str(e)}), 400
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── Mechanic assignment ──────────────────────────
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/assign-mechanic', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def assign_mechanic_endpoint(so_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
data = request.get_json() or {}
|
||||
@@ -377,7 +527,7 @@ def assign_mechanic_endpoint(so_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/customers', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_customer_for_workshop():
|
||||
"""Create a customer directly from the workshop flow."""
|
||||
data = request.get_json() or {}
|
||||
@@ -411,7 +561,7 @@ def create_customer_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/vehicles', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_vehicle_for_workshop():
|
||||
"""Create a fleet vehicle directly from the workshop flow.
|
||||
|
||||
@@ -456,7 +606,7 @@ def create_vehicle_for_workshop():
|
||||
|
||||
|
||||
@service_order_bp.route('/inventory-search', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def inventory_search():
|
||||
"""Search active inventory for attaching parts to a service order.
|
||||
|
||||
@@ -513,7 +663,7 @@ def inventory_search():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['GET'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_view
|
||||
def list_catalog():
|
||||
"""List reusable labor/service concepts."""
|
||||
active_only = request.args.get('active_only', 'true').lower() != 'false'
|
||||
@@ -526,7 +676,7 @@ def list_catalog():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog', methods=['POST'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def create_catalog_item():
|
||||
"""Create a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -542,7 +692,7 @@ def create_catalog_item():
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['PUT'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def update_catalog_item(item_id):
|
||||
"""Update a reusable labor concept."""
|
||||
data = request.get_json() or {}
|
||||
@@ -557,7 +707,7 @@ def update_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/service-catalog/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('workshop.edit')
|
||||
@require_workshop_edit
|
||||
def delete_catalog_item(item_id):
|
||||
"""Soft-delete a reusable labor concept."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
@@ -572,7 +722,7 @@ def delete_catalog_item(item_id):
|
||||
|
||||
|
||||
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
|
||||
@require_auth('workshop.view')
|
||||
@require_workshop_edit
|
||||
def print_service_order_ticket(so_id):
|
||||
"""Generate a printable ticket for a service order.
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ def require_auth(*required_permissions):
|
||||
# Check permissions
|
||||
if required_permissions:
|
||||
missing = set(required_permissions) - g.permissions
|
||||
# owner role bypasses all permission checks
|
||||
if g.employee_role != 'owner' and missing:
|
||||
# owner/admin roles bypass all permission checks
|
||||
if g.employee_role not in ('owner', 'admin') and missing:
|
||||
return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403
|
||||
|
||||
return f(*args, **kwargs)
|
||||
@@ -54,4 +54,4 @@ def require_auth(*required_permissions):
|
||||
|
||||
def has_permission(permission):
|
||||
"""Check if current user has a specific permission. Use inside a route."""
|
||||
return g.employee_role == 'owner' or permission in g.permissions
|
||||
return g.employee_role in ('owner', 'admin') or permission in g.permissions
|
||||
|
||||
@@ -57,6 +57,19 @@ MIGRATIONS = {
|
||||
"v4.8": "v4.8_workshop_permissions.sql",
|
||||
"v4.9": "v4.9_workshop_customers_view.sql",
|
||||
"v4.10": "v4.10_fleet_permissions.sql",
|
||||
"v4.11": "v4.11_clean_workshop_mechanic_permissions.sql",
|
||||
"v4.12": "v4.12_service_order_soft_delete.sql",
|
||||
"v4.13": "v4.13_fleet_vehicle_customer.sql",
|
||||
"v4.14": "v4.14_service_order_delivery_cleanup.sql",
|
||||
"v4.15": "v4.15_counter_remission.sql",
|
||||
"v4.16": "v4.16_remission_courier.sql",
|
||||
"v4.17": "v4.17_service_order_invoice.sql",
|
||||
"v4.18": "v4.18_rached_workshop.sql",
|
||||
"v4.19": "v4.19_counter_inventory_create.sql",
|
||||
"v4.20": "v4.20_cashier_sell_permissions.sql",
|
||||
"v4.21": "v4.21_cashier_workshop_permissions.sql",
|
||||
"v4.22": "v4.22_cashier_invoicing_permissions.sql",
|
||||
"v4.23": "v4.23_service_order_mechanic_name.sql",
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +125,10 @@ def apply_migration(db_name, version):
|
||||
def run_migrations():
|
||||
"""Apply pending migrations to all tenants."""
|
||||
tenants = get_all_tenants()
|
||||
sorted_versions = sorted(MIGRATIONS.keys())
|
||||
def _version_key(v):
|
||||
return tuple(int(x) for x in v.lstrip('v').split('.'))
|
||||
|
||||
sorted_versions = sorted(MIGRATIONS.keys(), key=_version_key)
|
||||
|
||||
print(f"Found {len(tenants)} active tenants")
|
||||
print(f"Available migrations: {sorted_versions}")
|
||||
@@ -120,8 +136,9 @@ def run_migrations():
|
||||
for tenant_id, db_name, name, current_version in tenants:
|
||||
print(f"\n[{name}] (db={db_name}, current={current_version})")
|
||||
|
||||
current_key = _version_key(current_version)
|
||||
for version in sorted_versions:
|
||||
if version <= current_version:
|
||||
if _version_key(version) <= current_key:
|
||||
continue
|
||||
|
||||
print(f" Applying {version}...", end=" ")
|
||||
|
||||
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
10
pos/migrations/v4.14_service_order_delivery_cleanup.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- Normalize service order delivery options to only "pickup" (mostrador) and "delivery" (a domicilio).
|
||||
-- Convert legacy "courier" records to pickup and clear courier_id when delivery is not "delivery".
|
||||
|
||||
UPDATE service_orders
|
||||
SET delivery_method = 'pickup'
|
||||
WHERE delivery_method = 'courier';
|
||||
|
||||
UPDATE service_orders
|
||||
SET courier_id = NULL
|
||||
WHERE delivery_method IS NULL OR delivery_method != 'delivery';
|
||||
18
pos/migrations/v4.15_counter_remission.sql
Normal file
18
pos/migrations/v4.15_counter_remission.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.15: Counter remission note support
|
||||
-- Ensures employees with role 'counter' have the base permissions needed to
|
||||
-- generate remission notes from the POS.
|
||||
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.permission
|
||||
FROM employees e
|
||||
CROSS JOIN (
|
||||
VALUES
|
||||
('pos.remission'),
|
||||
('pos.view'),
|
||||
('catalog.view'),
|
||||
('inventory.view'),
|
||||
('customers.view'),
|
||||
('customers.create')
|
||||
) AS p(permission)
|
||||
WHERE e.role = 'counter'
|
||||
ON CONFLICT (employee_id, permission) DO NOTHING;
|
||||
18
pos/migrations/v4.16_remission_courier.sql
Normal file
18
pos/migrations/v4.16_remission_courier.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.16: Add courier assignment to counter remission notes.
|
||||
|
||||
ALTER TABLE sales
|
||||
ADD COLUMN IF NOT EXISTS courier_id INTEGER;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'sales_courier_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE sales
|
||||
ADD CONSTRAINT sales_courier_id_fkey
|
||||
FOREIGN KEY (courier_id) REFERENCES couriers(id);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sales_courier_id ON sales(courier_id);
|
||||
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
6
pos/migrations/v4.17_service_order_invoice.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Service order invoice requirement flag
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS requires_invoice BOOLEAN DEFAULT FALSE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_requires_invoice
|
||||
ON service_orders(requires_invoice);
|
||||
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
32
pos/migrations/v4.18_rached_workshop.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- v4.18 Rached workshop fields
|
||||
-- Extends service orders and items to match the Rached legacy workshop flow.
|
||||
-- Applied to all tenants.
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 1. SERVICE_ORDERS: capture free-text customer/vehicle/workshop data
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_orders
|
||||
ADD COLUMN IF NOT EXISTS workshop_name VARCHAR(200),
|
||||
ADD COLUMN IF NOT EXISTS customer_address TEXT,
|
||||
ADD COLUMN IF NOT EXISTS customer_phone VARCHAR(50),
|
||||
ADD COLUMN IF NOT EXISTS vehicle_description VARCHAR(300);
|
||||
|
||||
COMMENT ON COLUMN service_orders.workshop_name IS 'Free-text workshop/customer alias (Rached "Taller")';
|
||||
COMMENT ON COLUMN service_orders.customer_address IS 'Address captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.customer_phone IS 'Phone captured or imported for the service order';
|
||||
COMMENT ON COLUMN service_orders.vehicle_description IS 'Free-text vehicle description (alternative to fleet_vehicles)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_workshop_name ON service_orders(workshop_name);
|
||||
CREATE INDEX IF NOT EXISTS idx_service_orders_vehicle_description ON service_orders(vehicle_description);
|
||||
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
-- 2. SERVICE_ORDER_ITEMS: mechanic per item + explicit observations
|
||||
-- ═════════════════════════════════════════════════════════════════════════════
|
||||
ALTER TABLE service_order_items
|
||||
ADD COLUMN IF NOT EXISTS mechanic_id INTEGER REFERENCES employees(id),
|
||||
ADD COLUMN IF NOT EXISTS observations TEXT;
|
||||
|
||||
COMMENT ON COLUMN service_order_items.mechanic_id IS 'Mechanic assigned to this specific line item';
|
||||
COMMENT ON COLUMN service_order_items.observations IS 'Line-item observations (Rached detail notes)';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_service_order_items_mechanic_id ON service_order_items(mechanic_id);
|
||||
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
11
pos/migrations/v4.19_counter_inventory_create.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- v4.19 — Grant inventory.create permission to existing counter employees
|
||||
-- so they can create items and record purchase entries.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, 'inventory.create'
|
||||
FROM employees e
|
||||
WHERE e.role = 'counter'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = 'inventory.create'
|
||||
);
|
||||
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
18
pos/migrations/v4.20_cashier_sell_permissions.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- v4.20 — Ensure existing cashier employees can sell and view quotations.
|
||||
-- The cashier default set already includes these permissions, but employees
|
||||
-- created before the fix may be missing them.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('pos.sell'),
|
||||
('pos.discount'),
|
||||
('pos.cancel'),
|
||||
('pos.view')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
15
pos/migrations/v4.21_cashier_workshop_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v4.21 — Allow existing cashier employees to create and edit service orders.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('workshop.view'),
|
||||
('workshop.edit'),
|
||||
('workshop.add_items')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
15
pos/migrations/v4.22_cashier_invoicing_permissions.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- v4.22 -- Enable invoicing for existing cashier employees.
|
||||
INSERT INTO employee_permissions (employee_id, permission)
|
||||
SELECT e.id, p.perm
|
||||
FROM employees e
|
||||
CROSS JOIN (VALUES
|
||||
('invoicing.view'),
|
||||
('invoicing.create'),
|
||||
('invoicing.cancel')
|
||||
) AS p(perm)
|
||||
WHERE e.role = 'cashier'
|
||||
AND e.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM employee_permissions ep
|
||||
WHERE ep.employee_id = e.id AND ep.permission = p.perm
|
||||
);
|
||||
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
2
pos/migrations/v4.23_service_order_mechanic_name.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- v4.23 -- Add free-text mechanic name for service orders.
|
||||
ALTER TABLE service_orders ADD COLUMN IF NOT EXISTS mechanic_name TEXT;
|
||||
@@ -230,6 +230,54 @@ def record_sale(conn, inventory_id, branch_id, quantity, sale_id=None, cost_at_t
|
||||
return op_id
|
||||
|
||||
|
||||
def record_reservation(conn, inventory_id, branch_id, quantity, sale_id=None,
|
||||
cost_at_time=None, remaining_stock=None):
|
||||
"""Reserve stock for a counter remission note (negative quantity).
|
||||
|
||||
The reserved quantity is deducted from available stock until the note is
|
||||
paid or cancelled.
|
||||
"""
|
||||
op_id = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RESERVE', -abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale', cost_at_time=cost_at_time,
|
||||
notes='Reserva por nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
|
||||
try:
|
||||
remaining = remaining_stock if remaining_stock is not None else get_stock(conn, inventory_id, branch_id)
|
||||
if remaining <= 0:
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT part_number, name FROM inventory WHERE id = %s", (inventory_id,))
|
||||
inv_row = cur.fetchone()
|
||||
cur.close()
|
||||
if inv_row:
|
||||
from services.push_service import notify_owner
|
||||
notify_owner(
|
||||
conn,
|
||||
'Stock en Cero',
|
||||
f'{inv_row[1] or inv_row[0]} se quedo sin existencias',
|
||||
'/pos'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return op_id
|
||||
|
||||
|
||||
def release_reservation(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Release a previous reservation (positive quantity)."""
|
||||
result = record_operation(
|
||||
conn, inventory_id, branch_id, 'REMISSION_RELEASE', abs(quantity),
|
||||
reference_id=sale_id, reference_type='sale',
|
||||
notes=notes or 'Liberacion de reserva de nota de remision'
|
||||
)
|
||||
invalidate_stock(inventory_id, branch_id)
|
||||
invalidate_stock(inventory_id, None)
|
||||
return result
|
||||
|
||||
|
||||
def record_return(conn, inventory_id, branch_id, quantity, sale_id=None, notes=None):
|
||||
"""Record a customer return (positive quantity)."""
|
||||
result = record_operation(
|
||||
|
||||
@@ -16,6 +16,8 @@ from flask import g
|
||||
from services.audit import log_action
|
||||
from services.inventory_engine import (
|
||||
record_sale as inventory_record_sale,
|
||||
record_reservation as inventory_record_reservation,
|
||||
release_reservation as inventory_release_reservation,
|
||||
record_operation,
|
||||
get_stock,
|
||||
get_stock_bulk,
|
||||
@@ -313,24 +315,34 @@ def process_sale(conn, sale_data):
|
||||
credit_limit = float(cust[0] or 0)
|
||||
credit_balance = float(cust[1] or 0)
|
||||
credit_available = credit_limit - credit_balance
|
||||
if totals['total'] > credit_available and credit_limit > 0:
|
||||
if totals['total'] > credit_available:
|
||||
raise ValueError(
|
||||
f"Insufficient credit. Available: ${credit_available:.2f}, "
|
||||
f"Required: ${totals['total']:.2f}"
|
||||
)
|
||||
|
||||
# Pending payment sale (e.g. "Pendiente")
|
||||
is_pending = payment_method == 'pendiente'
|
||||
if is_pending:
|
||||
amount_paid = 0.0
|
||||
sale_type = 'cash'
|
||||
|
||||
# Calculate change
|
||||
change_given = 0.0
|
||||
if sale_type == 'cash' and payment_method == 'efectivo':
|
||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||
|
||||
# SAT payment method codes
|
||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE'
|
||||
metodo_pago_sat = 'PPD' if sale_type == 'credit' or is_pending else 'PUE'
|
||||
forma_pago_map = {
|
||||
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
||||
'efectivo': '01', 'cheque': '02', 'transferencia': '03',
|
||||
'tarjeta': '04', 'mixto': '99', 'pendiente': '99', 'credito': '99'
|
||||
}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
# Determine sale status
|
||||
status = 'pending_payment' if is_pending else 'completed'
|
||||
|
||||
# Create sale record (with currency)
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
@@ -338,14 +350,14 @@ def process_sale(conn, sale_data):
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, device_id, notes, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, totals['subtotal'], totals['discount_total'],
|
||||
totals['tax_total'], totals['total'], amount_paid, change_given,
|
||||
metodo_pago_sat, forma_pago_sat,
|
||||
_safe_g('device_id'), notes,
|
||||
status, _safe_g('device_id'), notes,
|
||||
currency, exchange_rate
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
@@ -404,23 +416,26 @@ def process_sale(conn, sale_data):
|
||||
'subtotal': item['subtotal'],
|
||||
})
|
||||
|
||||
# Record payment on cash register (cash movements for efectivo)
|
||||
if register_id and payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
# Record payment on cash register (skip pending sales and zero-amount rows)
|
||||
if not is_pending:
|
||||
if register_id and payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
if amt <= 0:
|
||||
continue
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id and amount_paid > 0:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
elif register_id:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
""", (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate))
|
||||
|
||||
# Update customer credit balance if credit sale
|
||||
if sale_type == 'credit' and customer_id:
|
||||
@@ -430,6 +445,16 @@ def process_sale(conn, sale_data):
|
||||
WHERE id = %s
|
||||
""", (totals['total'], customer_id))
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'SALE', 'sale', sale_id,
|
||||
new_value={
|
||||
@@ -506,6 +531,8 @@ def process_sale(conn, sale_data):
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': sale_type,
|
||||
@@ -526,6 +553,342 @@ def process_sale(conn, sale_data):
|
||||
}
|
||||
|
||||
|
||||
def create_remission_note(conn, data):
|
||||
"""Create a counter remission note: reserve stock, no payment, pending status.
|
||||
|
||||
data: {
|
||||
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
|
||||
customer_id: int | null,
|
||||
notes: str,
|
||||
branch_id: int,
|
||||
register_id: int | null (optional)
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
items = data.get('items', [])
|
||||
customer_id = data.get('customer_id')
|
||||
notes = data.get('notes')
|
||||
branch_id = data.get('branch_id') or _safe_g('branch_id')
|
||||
register_id = data.get('register_id')
|
||||
employee_id = _safe_g('employee_id')
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
raise ValueError("Unsupported currency")
|
||||
exchange_rate = float(data.get('exchange_rate') or 1.0)
|
||||
if currency != 'MXN' and not exchange_rate:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
|
||||
if not branch_id:
|
||||
cur.execute("SELECT id FROM branches WHERE is_main = true AND is_active = true LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
branch_id = row[0] if row else None
|
||||
if not branch_id:
|
||||
raise ValueError("No hay sucursal activa disponible")
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in remission note")
|
||||
|
||||
inv_ids = [item.get('inventory_id') for item in items]
|
||||
cur.execute("""
|
||||
SELECT id, part_number, name, cost, price_1, price_2, price_3,
|
||||
tax_rate, branch_id, retail_price
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id
|
||||
FOR UPDATE
|
||||
""", (inv_ids,))
|
||||
inv_rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
stock_map = get_stock_bulk(conn, branch_id)
|
||||
if branch_id:
|
||||
cur.execute("""
|
||||
SELECT inventory_id, stock
|
||||
FROM inventory_stock
|
||||
WHERE branch_id = %s AND inventory_id = ANY(%s)
|
||||
FOR UPDATE
|
||||
""", (branch_id, inv_ids))
|
||||
stock_map = {r[0]: r[1] for r in cur.fetchall()}
|
||||
|
||||
enriched_items = []
|
||||
for item in items:
|
||||
inv_id = item.get('inventory_id')
|
||||
qty = int(item.get('quantity', 1))
|
||||
if qty <= 0:
|
||||
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
|
||||
inv = inv_rows.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found or inactive")
|
||||
current_stock = stock_map.get(inv_id, 0)
|
||||
unit_price = float(item.get('unit_price', inv[4]))
|
||||
discount_pct = float(item.get('discount_pct', 0))
|
||||
tax_rate = float(item.get('tax_rate', inv[7] or 0.16))
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
enriched_items.append({
|
||||
'inventory_id': inv_id,
|
||||
'part_number': inv[1],
|
||||
'name': inv[2],
|
||||
'quantity': qty,
|
||||
'unit_price': unit_price,
|
||||
'unit_cost': unit_cost,
|
||||
'discount_pct': discount_pct,
|
||||
'tax_rate': tax_rate,
|
||||
'branch_id': inv[8],
|
||||
'stock_before': current_stock,
|
||||
})
|
||||
|
||||
totals = calculate_totals(enriched_items)
|
||||
|
||||
cur.execute("""
|
||||
INSERT INTO sales
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, status, device_id, notes, currency, exchange_rate,
|
||||
courier_id)
|
||||
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission',
|
||||
%s, %s, %s, %s, 0, 0, 'pending_payment',
|
||||
%s, %s, %s, %s, %s)
|
||||
RETURNING id, created_at
|
||||
""", (
|
||||
branch_id, customer_id, employee_id, register_id,
|
||||
totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'],
|
||||
_safe_g('device_id'), notes, currency, exchange_rate,
|
||||
data.get('courier_id')
|
||||
))
|
||||
sale_id, created_at = cur.fetchone()
|
||||
|
||||
sale_items_data = []
|
||||
for item in totals['items']:
|
||||
inv = inv_rows.get(item['inventory_id'])
|
||||
retail_price = inv[9] if inv else None
|
||||
sale_items_data.append((
|
||||
sale_id, item['inventory_id'], item['part_number'], item['name'],
|
||||
item['quantity'], item['unit_price'], item.get('unit_cost', 0),
|
||||
item['discount_pct'], item['discount_amount'],
|
||||
item['tax_rate'], item['tax_amount'], item['subtotal'],
|
||||
retail_price, currency, exchange_rate
|
||||
))
|
||||
|
||||
cur.executemany("""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, discount_pct, discount_amount,
|
||||
tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", sale_items_data)
|
||||
|
||||
sale_items = []
|
||||
for item in totals['items']:
|
||||
stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0)
|
||||
remaining_after = stock_before - item['quantity']
|
||||
inventory_record_reservation(
|
||||
conn,
|
||||
item['inventory_id'],
|
||||
item.get('branch_id', branch_id),
|
||||
item['quantity'],
|
||||
sale_id=sale_id,
|
||||
cost_at_time=item.get('unit_cost'),
|
||||
remaining_stock=remaining_after
|
||||
)
|
||||
sale_items.append({
|
||||
'inventory_id': item['inventory_id'],
|
||||
'part_number': item['part_number'],
|
||||
'name': item['name'],
|
||||
'quantity': item['quantity'],
|
||||
'unit_price': item['unit_price'],
|
||||
'unit_cost': item.get('unit_cost', 0),
|
||||
'discount_pct': item['discount_pct'],
|
||||
'discount_amount': item['discount_amount'],
|
||||
'tax_rate': item['tax_rate'],
|
||||
'tax_amount': item['tax_amount'],
|
||||
'subtotal': item['subtotal'],
|
||||
})
|
||||
|
||||
log_action(conn, 'REMISSION_CREATED', 'sale', sale_id,
|
||||
new_value={
|
||||
'total': totals['total'],
|
||||
'items_count': len(sale_items),
|
||||
'customer_id': customer_id,
|
||||
})
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'branch_id': branch_id,
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
'employee_id': employee_id,
|
||||
'register_id': register_id,
|
||||
'sale_type': 'counter_remission',
|
||||
'payment_method': 'remission',
|
||||
'subtotal': totals['subtotal'],
|
||||
'discount_total': totals['discount_total'],
|
||||
'tax_total': totals['tax_total'],
|
||||
'total': totals['total'],
|
||||
'amount_paid': 0.0,
|
||||
'change_given': 0.0,
|
||||
'status': 'pending_payment',
|
||||
'courier_id': data.get('courier_id'),
|
||||
'items': sale_items,
|
||||
'created_at': str(created_at),
|
||||
'currency': currency,
|
||||
'exchange_rate': exchange_rate,
|
||||
}
|
||||
|
||||
|
||||
def pay_pending_sale(conn, sale_id, data):
|
||||
"""Pay a pending counter remission note and convert it into a completed sale.
|
||||
|
||||
data: {
|
||||
payment_method: str,
|
||||
amount_paid: float,
|
||||
payment_details: [{method, amount, reference}],
|
||||
register_id: int,
|
||||
reference: str
|
||||
}
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate
|
||||
FROM sales WHERE id = %s
|
||||
FOR UPDATE
|
||||
""", (sale_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise ValueError("Sale not found")
|
||||
(sale_id, branch_id, customer_id, employee_id, subtotal, tax_total, total, status, sale_type,
|
||||
currency, exchange_rate) = row
|
||||
|
||||
# Fetch customer info for ticket/receipt
|
||||
customer_name = None
|
||||
customer_rfc = None
|
||||
if customer_id:
|
||||
cur.execute("SELECT name, rfc FROM customers WHERE id = %s", (customer_id,))
|
||||
cust_row = cur.fetchone()
|
||||
if cust_row:
|
||||
customer_name = cust_row[0]
|
||||
customer_rfc = cust_row[1]
|
||||
|
||||
if status != 'pending_payment':
|
||||
raise ValueError("La nota no esta pendiente de pago")
|
||||
|
||||
payment_method = data.get('payment_method', 'efectivo')
|
||||
sale_type = 'cash'
|
||||
amount_paid = float(data.get('amount_paid', 0))
|
||||
payment_details = data.get('payment_details', [])
|
||||
register_id = data.get('register_id')
|
||||
reference = data.get('reference', '')
|
||||
|
||||
if register_id:
|
||||
cur.execute("SELECT status FROM cash_registers WHERE id = %s FOR UPDATE", (register_id,))
|
||||
reg = cur.fetchone()
|
||||
if not reg or reg[0] != 'open':
|
||||
raise ValueError("Cash register is not open")
|
||||
|
||||
totals = {'total': float(total)}
|
||||
change_given = 0.0
|
||||
if payment_method == 'efectivo':
|
||||
change_given = round(max(amount_paid - totals['total'], 0), 2)
|
||||
|
||||
forma_pago_map = {'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'}
|
||||
forma_pago_sat = forma_pago_map.get(payment_method, '99')
|
||||
|
||||
cur.execute("""
|
||||
UPDATE sales
|
||||
SET status = 'completed',
|
||||
sale_type = %s,
|
||||
payment_method = %s,
|
||||
amount_paid = %s,
|
||||
change_given = %s,
|
||||
register_id = COALESCE(%s, register_id),
|
||||
metodo_pago_sat = 'PUE',
|
||||
forma_pago_sat = %s
|
||||
WHERE id = %s
|
||||
""", (sale_type, payment_method, amount_paid, change_given, register_id, forma_pago_sat, sale_id))
|
||||
|
||||
if payment_details:
|
||||
for pd in payment_details:
|
||||
method = pd.get('method', payment_method)
|
||||
amt = float(pd.get('amount', 0))
|
||||
ref = pd.get('reference', '')
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, method, amt, ref, currency, exchange_rate))
|
||||
else:
|
||||
cur.execute("""
|
||||
INSERT INTO sale_payments
|
||||
(sale_id, register_id, method, amount, reference, currency, exchange_rate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (sale_id, register_id, payment_method, amount_paid, reference, currency, exchange_rate))
|
||||
|
||||
# Release reservation and record actual sale movement
|
||||
cur.execute("""
|
||||
SELECT inventory_id, quantity, unit_cost
|
||||
FROM sale_items WHERE sale_id = %s ORDER BY id
|
||||
""", (sale_id,))
|
||||
for inv_id, qty, cost in cur.fetchall():
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, branch_id, qty, sale_id=sale_id,
|
||||
notes='Pago de nota de remision'
|
||||
)
|
||||
inventory_record_sale(
|
||||
conn, inv_id, branch_id, qty,
|
||||
sale_id=sale_id, cost_at_time=float(cost) if cost else None
|
||||
)
|
||||
|
||||
# Accounting (non-blocking)
|
||||
try:
|
||||
total_mxn = to_mxn(float(total), currency, rate=exchange_rate, conn=conn)
|
||||
tax_mxn = to_mxn(float(tax_total or 0), currency, rate=exchange_rate, conn=conn)
|
||||
sub_mxn = to_mxn(float(subtotal or 0), currency, rate=exchange_rate, conn=conn)
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(unit_cost * quantity), 0)
|
||||
FROM sale_items WHERE sale_id = %s
|
||||
""", (sale_id,))
|
||||
cost_total = float(cur.fetchone()[0] or 0)
|
||||
record_sale_entry(conn, {
|
||||
'id': sale_id,
|
||||
'sale_type': sale_type,
|
||||
'total': total_mxn,
|
||||
'tax_total': tax_mxn,
|
||||
'subtotal': sub_mxn,
|
||||
'cost_total': cost_total,
|
||||
'payment_method': payment_method,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
log_action(conn, 'REMISSION_PAID', 'sale', sale_id,
|
||||
old_value={'status': 'pending_payment', 'total': totals['total']},
|
||||
new_value={'status': 'completed', 'payment_method': payment_method})
|
||||
|
||||
cur.close()
|
||||
return {
|
||||
'id': sale_id,
|
||||
'status': 'completed',
|
||||
'payment_method': payment_method,
|
||||
'amount_paid': amount_paid,
|
||||
'change_given': change_given,
|
||||
'total': totals['total'],
|
||||
'customer_id': customer_id,
|
||||
'customer_name': customer_name,
|
||||
'customer_rfc': customer_rfc,
|
||||
}
|
||||
|
||||
|
||||
def cancel_sale(conn, sale_id, reason):
|
||||
"""Cancel a sale: validate permissions, reverse inventory, update credit.
|
||||
|
||||
@@ -567,15 +930,15 @@ def cancel_sale(conn, sale_id, reason):
|
||||
if s_status == 'cancelled':
|
||||
raise ValueError("Sale is already cancelled")
|
||||
|
||||
# Permission check: cashiers can only cancel own sales within 30 min
|
||||
# Permission check: non-admin employees can only cancel their own docs within 30 min
|
||||
role = _safe_g('employee_role', 'cashier')
|
||||
emp_id = _safe_g('employee_id')
|
||||
|
||||
if role == 'cashier':
|
||||
if role not in ('owner', 'admin'):
|
||||
if s_emp_id != emp_id:
|
||||
raise ValueError("Cashiers can only cancel their own sales")
|
||||
raise ValueError("Solo puedes cancelar tus propias notas/ventas")
|
||||
if datetime.utcnow() - s_created > timedelta(minutes=30):
|
||||
raise ValueError("Cashiers can only cancel sales within 30 minutes of creation")
|
||||
raise ValueError("Solo puedes cancelar dentro de los primeros 30 minutos")
|
||||
|
||||
# Get sale items for inventory reversal
|
||||
cur.execute("""
|
||||
@@ -584,14 +947,23 @@ def cancel_sale(conn, sale_id, reason):
|
||||
""", (sale_id,))
|
||||
sale_items = cur.fetchall()
|
||||
|
||||
# Reverse inventory: create RETURN operations (positive quantity)
|
||||
from services.inventory_engine import record_return
|
||||
for inv_id, qty, cost in sale_items:
|
||||
record_return(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||
)
|
||||
if s_status == 'pending_payment':
|
||||
# Pending remission: release reservation
|
||||
for inv_id, qty, cost in sale_items:
|
||||
inventory_release_reservation(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion nota de remision #{sale_id}: {reason}"
|
||||
)
|
||||
else:
|
||||
# Completed sale: create RETURN operations (positive quantity)
|
||||
from services.inventory_engine import record_return
|
||||
for inv_id, qty, cost in sale_items:
|
||||
record_return(
|
||||
conn, inv_id, s_branch, qty,
|
||||
sale_id=sale_id,
|
||||
notes=f"Cancelacion venta #{sale_id}: {reason}"
|
||||
)
|
||||
|
||||
# Update sale status
|
||||
cur.execute("""
|
||||
@@ -627,7 +999,7 @@ def cancel_sale(conn, sale_id, reason):
|
||||
|
||||
# Audit log
|
||||
log_action(conn, 'CANCEL', 'sale', sale_id,
|
||||
old_value={'status': 'completed', 'total': float(s_total)},
|
||||
old_value={'status': s_status, 'total': float(s_total)},
|
||||
new_value={'status': 'cancelled', 'reason': reason})
|
||||
|
||||
# Push notification to owner/admin (best-effort, non-blocking)
|
||||
|
||||
@@ -8,15 +8,43 @@ from datetime import datetime
|
||||
|
||||
from services import inventory_engine
|
||||
|
||||
# Rached workshop statuses (applies to all tenants).
|
||||
ORDER_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',
|
||||
]
|
||||
TERMINAL_STATUSES = {'entregado', 'facturada', 'cancelada'}
|
||||
|
||||
# Allow moving from any non-terminal status to any other non-terminal status,
|
||||
# plus cancellation. Terminal statuses cannot change.
|
||||
VALID_TRANSITIONS = {
|
||||
'received': ['diagnosis', 'cancelled'],
|
||||
'diagnosis': ['waiting_parts', 'repair', 'cancelled'],
|
||||
'waiting_parts': ['repair', 'cancelled'],
|
||||
'repair': ['ready', 'cancelled'],
|
||||
'ready': ['delivered', 'cancelled'],
|
||||
'delivered': [],
|
||||
'cancelled': [],
|
||||
status: [s for s in ORDER_STATUSES if s != status]
|
||||
for status in ORDER_STATUSES
|
||||
}
|
||||
for terminal in TERMINAL_STATUSES:
|
||||
VALID_TRANSITIONS[terminal] = []
|
||||
|
||||
# Legacy statuses (kept valid for imported/old data) cannot transition anywhere.
|
||||
_LEGACY_STATUSES = {
|
||||
'received', 'diagnosis', 'waiting_parts', 'repair', 'quality_check', 'ready', 'delivered'
|
||||
}
|
||||
for legacy in _LEGACY_STATUSES:
|
||||
VALID_TRANSITIONS.setdefault(legacy, [])
|
||||
|
||||
|
||||
def _generate_order_number(conn):
|
||||
@@ -45,6 +73,18 @@ def _generate_order_number(conn):
|
||||
return f"{prefix}{new_num}"
|
||||
|
||||
|
||||
_VALID_DELIVERY_METHODS = {'pickup', 'delivery', 'courier'}
|
||||
|
||||
|
||||
def _normalize_delivery(data):
|
||||
"""Restrict delivery_method to allowed values and clear courier when not applicable."""
|
||||
dm = data.get('delivery_method')
|
||||
if dm not in _VALID_DELIVERY_METHODS:
|
||||
data['delivery_method'] = None
|
||||
if data.get('delivery_method') not in ('delivery', 'courier'):
|
||||
data['courier_id'] = None
|
||||
|
||||
|
||||
def create_service_order(conn, data):
|
||||
"""Create a new service order.
|
||||
|
||||
@@ -52,9 +92,10 @@ def create_service_order(conn, data):
|
||||
customer_id, vehicle_id, branch_id, priority,
|
||||
reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct
|
||||
delivery_method, courier_id, is_direct, requires_invoice
|
||||
}
|
||||
"""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
order_number = _generate_order_number(conn)
|
||||
|
||||
@@ -62,19 +103,23 @@ def create_service_order(conn, data):
|
||||
INSERT INTO service_orders
|
||||
(tenant_id, branch_id, customer_id, vehicle_id, order_number, status,
|
||||
priority, reception_notes, estimated_cost, estimated_completion,
|
||||
employee_id, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct)
|
||||
VALUES (%s, %s, %s, %s, %s, 'received', %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s)
|
||||
employee_id, mechanic_name, mileage_in, fuel_level, created_by,
|
||||
delivery_method, courier_id, is_direct, requires_invoice,
|
||||
workshop_name, customer_address, customer_phone, vehicle_description)
|
||||
VALUES (%s, %s, %s, %s, %s, 'por_revisar', %s, %s, %s, %s, %s, %s, %s, %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
data.get('tenant_id'), data.get('branch_id'), data.get('customer_id'),
|
||||
data.get('vehicle_id'), order_number,
|
||||
data.get('priority', 'normal'), data.get('reception_notes'),
|
||||
data.get('estimated_cost'), data.get('estimated_completion'),
|
||||
data.get('employee_id'), data.get('mileage_in'),
|
||||
data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('delivery_method'), data.get('courier_id'), data.get('is_direct', False),
|
||||
data.get('employee_id'), data.get('mechanic_name'),
|
||||
data.get('mileage_in'), data.get('fuel_level'), data.get('created_by'),
|
||||
data.get('delivery_method'), data.get('courier_id'),
|
||||
data.get('is_direct', False), data.get('requires_invoice', False),
|
||||
data.get('workshop_name'), data.get('customer_address'),
|
||||
data.get('customer_phone'), data.get('vehicle_description'),
|
||||
))
|
||||
so_id = cur.fetchone()[0]
|
||||
|
||||
@@ -82,7 +127,7 @@ def create_service_order(conn, data):
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_status_history
|
||||
(service_order_id, new_status, changed_by, notes)
|
||||
VALUES (%s, 'received', %s, 'Orden creada')
|
||||
VALUES (%s, 'por_revisar', %s, 'Orden creada')
|
||||
""", (so_id, data.get('created_by')))
|
||||
|
||||
conn.commit()
|
||||
@@ -95,7 +140,7 @@ def get_service_order(conn, so_id):
|
||||
cur.execute("""
|
||||
SELECT so.id, so.order_number, so.status, so.priority,
|
||||
so.customer_id, c.name as customer_name, c.phone as customer_phone,
|
||||
c.address as customer_address,
|
||||
c.address as customer_address, c.price_tier as customer_price_tier,
|
||||
so.vehicle_id, fv.plate as vehicle_plate, fv.make as vehicle_make, fv.model as vehicle_model,
|
||||
so.branch_id, b.name as branch_name, b.address as branch_address, b.phone as branch_phone,
|
||||
so.reception_notes, so.diagnosis_notes, so.repair_notes,
|
||||
@@ -103,10 +148,12 @@ def get_service_order(conn, so_id):
|
||||
so.estimated_completion, so.actual_completion, so.delivered_at,
|
||||
so.mileage_in, so.mileage_out, so.fuel_level,
|
||||
so.employee_id, e.name as employee_name,
|
||||
so.mechanic_name,
|
||||
so.created_by, creator.name as created_by_name,
|
||||
so.created_at, so.updated_at,
|
||||
so.delivery_method, so.courier_id, co.name as courier_name, so.is_direct,
|
||||
so.sale_id
|
||||
so.requires_invoice, so.sale_id,
|
||||
so.workshop_name, so.customer_address, so.customer_phone, so.vehicle_description
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
@@ -124,28 +171,33 @@ def get_service_order(conn, so_id):
|
||||
so = {
|
||||
'id': row[0], 'order_number': row[1], 'status': row[2], 'priority': row[3],
|
||||
'customer_id': row[4], 'customer_name': row[5], 'customer_phone': row[6],
|
||||
'customer_address': row[7],
|
||||
'vehicle_id': row[8], 'vehicle_plate': row[9], 'vehicle_make': row[10], 'vehicle_model': row[11],
|
||||
'branch_id': row[12], 'branch_name': row[13], 'branch_address': row[14], 'branch_phone': row[15],
|
||||
'reception_notes': row[16], 'diagnosis_notes': row[17],
|
||||
'repair_notes': row[18], 'delivery_notes': row[19],
|
||||
'estimated_cost': float(row[20]) if row[20] else None,
|
||||
'final_cost': float(row[21]) if row[21] else None,
|
||||
'estimated_completion': str(row[22]) if row[22] else None,
|
||||
'actual_completion': str(row[23]) if row[23] else None,
|
||||
'delivered_at': str(row[24]) if row[24] else None,
|
||||
'mileage_in': row[25], 'mileage_out': row[26], 'fuel_level': row[27],
|
||||
'employee_id': row[28], 'employee_name': row[29],
|
||||
'created_by': row[30], 'created_by_name': row[31],
|
||||
'created_at': str(row[32]), 'updated_at': str(row[33]),
|
||||
'delivery_method': row[34], 'courier_id': row[35], 'courier_name': row[36],
|
||||
'is_direct': bool(row[37]) if row[37] is not None else False,
|
||||
'sale_id': row[38],
|
||||
'customer_address': row[7], 'customer_price_tier': row[8],
|
||||
'vehicle_id': row[9], 'vehicle_plate': row[10], 'vehicle_make': row[11], 'vehicle_model': row[12],
|
||||
'branch_id': row[13], 'branch_name': row[14], 'branch_address': row[15], 'branch_phone': row[16],
|
||||
'reception_notes': row[17], 'diagnosis_notes': row[18],
|
||||
'repair_notes': row[19], 'delivery_notes': row[20],
|
||||
'estimated_cost': float(row[21]) if row[21] else None,
|
||||
'final_cost': float(row[22]) if row[22] else None,
|
||||
'estimated_completion': str(row[23]) if row[23] else None,
|
||||
'actual_completion': str(row[24]) if row[24] else None,
|
||||
'delivered_at': str(row[25]) if row[25] else None,
|
||||
'mileage_in': row[26], 'mileage_out': row[27], 'fuel_level': row[28],
|
||||
'employee_id': row[29], 'employee_name': row[30],
|
||||
'mechanic_name': row[31],
|
||||
'created_by': row[32], 'created_by_name': row[33],
|
||||
'created_at': str(row[34]), 'updated_at': str(row[35]),
|
||||
'delivery_method': row[36], 'courier_id': row[37], 'courier_name': row[38],
|
||||
'is_direct': bool(row[39]) if row[39] is not None else False,
|
||||
'requires_invoice': bool(row[40]) if row[40] is not None else False,
|
||||
'sale_id': row[41],
|
||||
'workshop_name': row[42], 'customer_address': row[43],
|
||||
'customer_phone': row[44], 'vehicle_description': row[45],
|
||||
}
|
||||
|
||||
# Items
|
||||
cur.execute("""
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes
|
||||
SELECT id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations
|
||||
FROM service_order_items
|
||||
WHERE service_order_id = %s
|
||||
ORDER BY id
|
||||
@@ -161,6 +213,7 @@ def get_service_order(conn, so_id):
|
||||
'unit_cost': float(r[5]) if r[5] else None,
|
||||
'unit_price': price,
|
||||
'status': r[7], 'notes': r[8],
|
||||
'mechanic_id': r[9], 'observations': r[10],
|
||||
})
|
||||
total_parts += qty * price
|
||||
|
||||
@@ -238,8 +291,11 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
where_clauses.append("so.is_direct = %s")
|
||||
params.append(is_direct)
|
||||
if q:
|
||||
where_clauses.append("(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s)")
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
where_clauses.append(
|
||||
"(so.order_number ILIKE %s OR c.name ILIKE %s OR fv.plate ILIKE %s OR "
|
||||
"so.workshop_name ILIKE %s OR so.vehicle_description ILIKE %s)"
|
||||
)
|
||||
params.extend([f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%', f'%{q}%'])
|
||||
|
||||
where_clauses.append("so.is_deleted = false")
|
||||
where = " AND ".join(where_clauses)
|
||||
@@ -259,14 +315,18 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
so.branch_id, b.name as branch_name,
|
||||
so.estimated_cost, so.final_cost,
|
||||
so.delivery_method, co.name as courier_name, so.is_direct,
|
||||
so.sale_id, so.created_at,
|
||||
creator.name as created_by_name
|
||||
so.requires_invoice, so.sale_id, so.created_at,
|
||||
creator.name as created_by_name,
|
||||
so.employee_id, mech.name as employee_name,
|
||||
so.mechanic_name,
|
||||
so.workshop_name, so.vehicle_description
|
||||
FROM service_orders so
|
||||
LEFT JOIN customers c ON so.customer_id = c.id
|
||||
LEFT JOIN fleet_vehicles fv ON so.vehicle_id = fv.id
|
||||
LEFT JOIN branches b ON so.branch_id = b.id
|
||||
LEFT JOIN couriers co ON so.courier_id = co.id
|
||||
LEFT JOIN employees creator ON so.created_by = creator.id
|
||||
LEFT JOIN employees mech ON so.employee_id = mech.id
|
||||
WHERE {where}
|
||||
ORDER BY
|
||||
CASE so.priority
|
||||
@@ -292,8 +352,12 @@ def list_service_orders(conn, status=None, branch_id=None, customer_id=None,
|
||||
'final_cost': final,
|
||||
'delivery_method': r[14], 'courier_name': r[15],
|
||||
'is_direct': bool(r[16]) if r[16] is not None else False,
|
||||
'sale_id': r[17], 'created_at': str(r[18]),
|
||||
'created_by_name': r[19],
|
||||
'requires_invoice': bool(r[17]) if r[17] is not None else False,
|
||||
'sale_id': r[18], 'created_at': str(r[19]),
|
||||
'created_by_name': r[20],
|
||||
'employee_id': r[21], 'employee_name': r[22],
|
||||
'mechanic_name': r[23],
|
||||
'workshop_name': r[24], 'vehicle_description': r[25],
|
||||
'total': round(final or estimated, 2),
|
||||
'paid': 0.0, # to be computed if needed
|
||||
})
|
||||
@@ -323,9 +387,9 @@ def update_status(conn, so_id, new_status, changed_by=None, notes=None):
|
||||
# Update status
|
||||
extra_sets = []
|
||||
extra_vals = []
|
||||
if new_status == 'ready':
|
||||
if new_status == 'reparada':
|
||||
extra_sets.append("actual_completion = NOW()")
|
||||
if new_status == 'delivered':
|
||||
if new_status == 'entregado':
|
||||
extra_sets.append("delivered_at = NOW()")
|
||||
extra_sets.append("delivered_by = %s")
|
||||
extra_vals.append(changed_by)
|
||||
@@ -354,14 +418,16 @@ def add_item(conn, so_id, item_data):
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO service_order_items
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
(service_order_id, inventory_id, part_number, name, quantity, unit_cost, unit_price, status, notes,
|
||||
mechanic_id, observations)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""", (
|
||||
so_id, item_data.get('inventory_id'), item_data.get('part_number'),
|
||||
item_data.get('name'), item_data.get('quantity', 1),
|
||||
item_data.get('unit_cost'), item_data.get('unit_price'),
|
||||
item_data.get('status', 'pending'), item_data.get('notes'),
|
||||
item_data.get('mechanic_id'), item_data.get('observations'),
|
||||
))
|
||||
item_id = cur.fetchone()[0]
|
||||
conn.commit()
|
||||
@@ -371,7 +437,7 @@ def add_item(conn, so_id, item_data):
|
||||
|
||||
def update_item(conn, item_id, data):
|
||||
cur = conn.cursor()
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes']
|
||||
allowed = ['part_number', 'name', 'quantity', 'unit_cost', 'unit_price', 'status', 'notes', 'mechanic_id', 'observations']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -453,12 +519,14 @@ def remove_labor(conn, labor_id):
|
||||
|
||||
def update_service_order(conn, so_id, data):
|
||||
"""Update general service order fields."""
|
||||
_normalize_delivery(data)
|
||||
cur = conn.cursor()
|
||||
allowed = ['customer_id', 'vehicle_id', 'branch_id', 'priority',
|
||||
'reception_notes', 'diagnosis_notes', 'repair_notes',
|
||||
'delivery_notes', 'estimated_cost', 'estimated_completion',
|
||||
'employee_id', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct']
|
||||
'employee_id', 'mechanic_name', 'mileage_in', 'mileage_out', 'fuel_level', 'final_cost',
|
||||
'delivery_method', 'courier_id', 'is_direct', 'requires_invoice',
|
||||
'workshop_name', 'customer_address', 'customer_phone', 'vehicle_description']
|
||||
sets = []
|
||||
vals = []
|
||||
for field in allowed:
|
||||
@@ -491,15 +559,15 @@ def get_kanban_summary(conn, branch_id=None):
|
||||
GROUP BY status
|
||||
""", params)
|
||||
|
||||
summary = {status: 0 for status in VALID_TRANSITIONS if status != 'cancelled'}
|
||||
summary = {status: 0 for status in ORDER_STATUSES if status != 'cancelada'}
|
||||
for r in cur.fetchall():
|
||||
summary[r[0]] = r[1]
|
||||
|
||||
# Overdue orders (estimated_completion passed and not ready/delivered)
|
||||
# Overdue orders (estimated_completion passed and not delivered/invoiced)
|
||||
cur.execute(f"""
|
||||
SELECT count(*) FROM service_orders
|
||||
WHERE estimated_completion < NOW()
|
||||
AND status NOT IN ('ready', 'delivered', 'cancelled')
|
||||
AND status NOT IN ('entregado', 'facturada', 'cancelada')
|
||||
AND is_deleted = false
|
||||
{branch_filter}
|
||||
""", params)
|
||||
@@ -812,6 +880,152 @@ def convert_to_sale(conn, so_id, sale_data, employee_id=None):
|
||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||
|
||||
|
||||
def convert_to_remission(conn, so_id, sale_data, employee_id=None):
|
||||
"""Convert a service order into a counter remission note (pending payment).
|
||||
|
||||
sale_data keys:
|
||||
register_id: int (optional)
|
||||
notes: str (optional)
|
||||
|
||||
Returns dict with sale_id, total, items_count.
|
||||
"""
|
||||
cur = conn.cursor()
|
||||
so = get_service_order(conn, so_id)
|
||||
if not so:
|
||||
cur.close()
|
||||
raise ValueError("Service order not found")
|
||||
if so["status"] == "cancelled":
|
||||
cur.close()
|
||||
raise ValueError("Cannot convert a cancelled service order")
|
||||
if so.get("sale_id"):
|
||||
cur.close()
|
||||
raise ValueError("Service order already converted")
|
||||
|
||||
branch_id = so["branch_id"]
|
||||
customer_id = so["customer_id"]
|
||||
|
||||
# Build sale items from SO parts and labor
|
||||
sale_items = []
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
qty = int(item.get("quantity", 1))
|
||||
unit_price = float(item.get("unit_price") or 0)
|
||||
unit_cost = float(item.get("unit_cost") or 0)
|
||||
sale_items.append({
|
||||
"inventory_id": item.get("inventory_id"),
|
||||
"part_number": item.get("part_number") or "PART",
|
||||
"name": item.get("name") or "Refaccion",
|
||||
"quantity": qty,
|
||||
"unit_price": unit_price,
|
||||
"unit_cost": unit_cost,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
for labor in so.get("labor", []):
|
||||
if labor.get("status") == "cancelled":
|
||||
continue
|
||||
sale_items.append({
|
||||
"inventory_id": None,
|
||||
"part_number": "SERV",
|
||||
"name": labor.get("description") or "Mano de obra",
|
||||
"quantity": 1,
|
||||
"unit_price": float(labor.get("total_cost") or 0),
|
||||
"unit_cost": 0,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
if not sale_items:
|
||||
cur.close()
|
||||
raise ValueError("No items or labor to invoice")
|
||||
|
||||
subtotal = 0.0
|
||||
tax_total = 0.0
|
||||
for item in sale_items:
|
||||
item_subtotal = item["quantity"] * item["unit_price"]
|
||||
item_tax = item_subtotal * item["tax_rate"]
|
||||
item["subtotal"] = item_subtotal
|
||||
item["tax_amount"] = item_tax
|
||||
subtotal += item_subtotal
|
||||
tax_total += item_tax
|
||||
|
||||
total = subtotal + tax_total
|
||||
register_id = sale_data.get("register_id")
|
||||
notes = sale_data.get("notes") or f"Nota de remision desde orden {so['order_number']}"
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sales
|
||||
(branch_id, customer_id, employee_id, register_id, sale_type,
|
||||
payment_method, subtotal, discount_total, tax_total, total,
|
||||
amount_paid, change_given, metodo_pago_sat, forma_pago_sat,
|
||||
status, notes)
|
||||
VALUES (%s, %s, %s, %s, 'counter_remission', 'remission', %s, %s, %s, %s, %s, %s, 'PPD', '99', 'pending_payment', %s)
|
||||
RETURNING id, created_at
|
||||
""",
|
||||
(
|
||||
branch_id,
|
||||
customer_id,
|
||||
employee_id,
|
||||
register_id,
|
||||
subtotal,
|
||||
0,
|
||||
tax_total,
|
||||
total,
|
||||
0,
|
||||
0,
|
||||
notes,
|
||||
),
|
||||
)
|
||||
sale_id, _created_at = cur.fetchone()
|
||||
|
||||
for item in sale_items:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, unit_cost, tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sale_id,
|
||||
item["inventory_id"],
|
||||
item["part_number"],
|
||||
item["name"],
|
||||
item["quantity"],
|
||||
item["unit_price"],
|
||||
item["unit_cost"],
|
||||
item["tax_rate"],
|
||||
item["tax_amount"],
|
||||
item["subtotal"],
|
||||
),
|
||||
)
|
||||
|
||||
# Reserve inventory for parts
|
||||
for item in so.get("items", []):
|
||||
if item.get("status") == "cancelled":
|
||||
continue
|
||||
inventory_id = item.get("inventory_id")
|
||||
qty = int(item.get("quantity", 0))
|
||||
if inventory_id and qty > 0:
|
||||
inventory_engine.record_reservation(
|
||||
conn,
|
||||
inventory_id,
|
||||
branch_id,
|
||||
qty,
|
||||
sale_id=sale_id,
|
||||
cost_at_time=float(item.get("unit_cost") or 0),
|
||||
notes=f"Reserva nota de remision orden {so['order_number']}"
|
||||
)
|
||||
|
||||
# Link order to sale (the remission note)
|
||||
cur.execute("UPDATE service_orders SET sale_id = %s WHERE id = %s", (sale_id, so_id))
|
||||
|
||||
conn.commit()
|
||||
cur.close()
|
||||
return {"sale_id": sale_id, "total": total, "items_count": len(sale_items)}
|
||||
|
||||
|
||||
def assign_mechanic(conn, so_id, employee_id):
|
||||
"""Assign a mechanic/technician to a service order."""
|
||||
cur = conn.cursor()
|
||||
|
||||
@@ -713,18 +713,23 @@
|
||||
}
|
||||
|
||||
.pago-tabs {
|
||||
display: flex; border-bottom: 2px solid var(--color-border); padding: 0 var(--space-6);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-bottom: none;
|
||||
}
|
||||
.pago-tab {
|
||||
padding: var(--space-3) var(--space-5); font-family: var(--font-body);
|
||||
font-size: var(--text-body-sm); font-weight: var(--font-weight-semibold);
|
||||
background: transparent; border: none; color: var(--color-text-muted);
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px; transition: var(--transition-fast);
|
||||
display: flex; align-items: center; gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-2); font-family: var(--font-body);
|
||||
font-size: var(--text-caption); font-weight: var(--font-weight-semibold);
|
||||
background: var(--color-surface); border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md); color: var(--color-text-muted);
|
||||
cursor: pointer; transition: var(--transition-fast);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
text-align: center; min-height: 44px;
|
||||
}
|
||||
.pago-tab:hover { color: var(--color-text-primary); }
|
||||
.pago-tab.active { color: var(--color-text-accent); border-bottom-color: var(--color-primary); }
|
||||
.pago-tab:hover { background: var(--color-surface-2); color: var(--color-text-primary); }
|
||||
.pago-tab.active { background: var(--color-primary); border-color: var(--color-primary); color: #fff; }
|
||||
|
||||
.tab-content { padding: var(--space-6); display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
195
pos/static/css/remission_notes.css
Normal file
195
pos/static/css/remission_notes.css
Normal file
@@ -0,0 +1,195 @@
|
||||
/* Remission notes page — matches the Nexus POS design system */
|
||||
|
||||
/* Scrollable page content area */
|
||||
.page-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
.page-content::-webkit-scrollbar { width: 6px; }
|
||||
.page-content::-webkit-scrollbar-track { background: var(--scrollbar-track); }
|
||||
.page-content::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: var(--radius-full); }
|
||||
|
||||
/* Filters card */
|
||||
.filters-card {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
[data-theme="modern"] .filters-card {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
|
||||
/* Date input reuse select-filter styling */
|
||||
.select-filter[type="date"] {
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
/* Status badges for remission notes */
|
||||
.badge--pending_payment { background: var(--color-primary-muted); color: var(--color-primary); }
|
||||
.badge--completed { background: rgba(34, 197, 94, 0.15); color: var(--color-success); }
|
||||
.badge--cancelled { background: rgba(115, 115, 115, 0.15); color: var(--color-text-muted); }
|
||||
|
||||
/* Action buttons in table */
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-1);
|
||||
padding: 0 var(--space-2);
|
||||
height: 28px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-caption);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
transition: var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.action-btn svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
stroke: currentColor;
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.action-btn--ghost {
|
||||
background: var(--btn-ghost-bg);
|
||||
color: var(--btn-ghost-text);
|
||||
border-color: var(--btn-ghost-border);
|
||||
}
|
||||
.action-btn--ghost:hover {
|
||||
background: var(--color-surface-2);
|
||||
border-color: var(--color-border-strong);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.action-btn--primary {
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border-color: var(--btn-primary-border);
|
||||
}
|
||||
.action-btn--primary:hover { background: var(--btn-primary-bg-hover); }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
padding: var(--space-10) var(--space-6);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal, 1050);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.modal-overlay.is-open { display: flex; }
|
||||
|
||||
.modal {
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
[data-theme="modern"] .modal {
|
||||
background: var(--color-bg-overlay);
|
||||
}
|
||||
|
||||
.modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.modal__title {
|
||||
font-family: var(--font-heading);
|
||||
font-size: var(--text-h6);
|
||||
font-weight: var(--heading-weight-primary);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
.modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: var(--space-1);
|
||||
}
|
||||
.modal__close:hover { color: var(--color-text-primary); }
|
||||
|
||||
.modal__body {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.modal__footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Ticket preview (monospace) */
|
||||
.ticket-preview {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
.ticket-preview__center { text-align: center; }
|
||||
.ticket-preview__bold { font-weight: 700; }
|
||||
.ticket-preview__line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.ticket-preview__divider {
|
||||
border-top: 1px dashed var(--color-border-strong);
|
||||
margin: var(--space-3) 0;
|
||||
}
|
||||
.ticket-preview__items { margin: var(--space-3) 0; }
|
||||
.ticket-preview__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.ticket-preview__footer {
|
||||
text-align: center;
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 1024px) {
|
||||
.page-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
.toolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.search-box, .select-filter {
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.toolbar__spacer { display: none; }
|
||||
}
|
||||
@@ -947,6 +947,30 @@ body {
|
||||
.badge--delivered { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.badge--cancelled { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.badge--pending { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||
.badge--por_revisar { background: rgba(59, 130, 246, 0.12); color: #3b82f6; }
|
||||
.badge--en_revision { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.badge--revisada { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
.badge--cotizada { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
|
||||
.badge--autorizada { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.badge--autorizacion_parcial { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.badge--en_reparacion { background: rgba(245, 166, 35, 0.18); color: #d97706; }
|
||||
.badge--reparada { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
|
||||
.badge--por_entregar { background: rgba(59, 130, 246, 0.16); color: #2563eb; }
|
||||
.badge--entregado { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
.badge--por_enviar { background: rgba(99, 102, 241, 0.16); color: #4f46e5; }
|
||||
.badge--enviado { background: rgba(14, 165, 233, 0.12); color: #0ea5e9; }
|
||||
.badge--por_facturar { background: rgba(168, 85, 247, 0.12); color: #a855f7; }
|
||||
.badge--facturada { background: rgba(236, 72, 153, 0.12); color: #ec4899; }
|
||||
.badge--por_recolectar { background: rgba(100, 116, 139, 0.12); color: #64748b; }
|
||||
.badge--cancelada { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
.badge--revisando { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.badge--revisado { background: rgba(139, 92, 246, 0.12); color: #8b5cf6; }
|
||||
.badge--cotizado { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||
.badge--por_autorizar { background: rgba(249, 115, 22, 0.12); color: #f97316; }
|
||||
.badge--autorizado { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
|
||||
.badge--reparado { background: rgba(20, 184, 166, 0.12); color: #14b8a6; }
|
||||
.badge--cancelado { background: rgba(239, 68, 68, 0.12); color: #ef4444; }
|
||||
|
||||
.badge--normal { background: rgba(148, 163, 184, 0.12); color: #94a3b8; }
|
||||
.badge--high { background: rgba(245, 166, 35, 0.12); color: #f5a623; }
|
||||
|
||||
@@ -182,10 +182,85 @@
|
||||
permissions: payload.permissions || []
|
||||
};
|
||||
|
||||
// ─── Restrict workshop/mechanic users to the workshop view only ───
|
||||
if ((role === 'workshop' || role === 'mechanic') && window.location.pathname !== '/pos/workshop') {
|
||||
window.location.replace('/pos/workshop');
|
||||
return;
|
||||
// ─── Page guard based on role + permissions ───
|
||||
function isPageAllowed(pagePath, userRole, userPerms) {
|
||||
if (userRole === 'owner' || userRole === 'admin') return true;
|
||||
|
||||
// Workshop/mechanic accounts always see Taller; extra modules depend on permissions.
|
||||
if (userRole === 'workshop' || userRole === 'mechanic') {
|
||||
var allowed = ['/pos/workshop'];
|
||||
var permMap = {
|
||||
'customers.view': '/pos/customers',
|
||||
'inventory.view': '/pos/inventory',
|
||||
'catalog.view': '/pos/catalog',
|
||||
'pos.sell': '/pos/sale',
|
||||
'pos.view': '/pos/sale',
|
||||
'pos.remission': '/pos/remission-notes',
|
||||
'invoicing.view': '/pos/invoicing',
|
||||
'quotations.view': '/pos/quotations',
|
||||
'accounting.view': '/pos/accounting',
|
||||
'reports.view': '/pos/reports',
|
||||
'config.view': '/pos/config',
|
||||
'config.edit': '/pos/config'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (userPerms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
allowed.push(permMap[p]);
|
||||
}
|
||||
}
|
||||
return allowed.indexOf(pagePath) !== -1;
|
||||
}
|
||||
|
||||
// Counter: fixed module set (no dashboard).
|
||||
if (userRole === 'counter') {
|
||||
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/reports'].indexOf(pagePath) !== -1;
|
||||
}
|
||||
|
||||
// Cashier: fixed module set (no dashboard).
|
||||
if (userRole === 'cashier') {
|
||||
return ['/pos/sale','/pos/catalog','/pos/inventory','/pos/customers','/pos/workshop','/pos/remission-notes','/pos/invoicing','/pos/reports'].indexOf(pagePath) !== -1;
|
||||
}
|
||||
|
||||
// Any other role (accountant, warehouse, sales, etc.) keeps the previous permissive behavior.
|
||||
return true;
|
||||
}
|
||||
|
||||
function enforcePageGuard(userRole, userPerms) {
|
||||
if (isPageAllowed(path, userRole, userPerms)) return true;
|
||||
var fallback = '/pos/catalog';
|
||||
if (userRole === 'counter' || userRole === 'cashier') fallback = '/pos/sale';
|
||||
else if (userRole === 'workshop' || userRole === 'mechanic') fallback = '/pos/workshop';
|
||||
window.location.replace(fallback);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Refresh permissions/token from server before enforcing the guard ───
|
||||
// This makes permission changes effective without requiring a full re-login.
|
||||
try {
|
||||
fetch('/pos/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
}).then(function(r) {
|
||||
if (r.ok) return r.json();
|
||||
return null;
|
||||
}).then(function(data) {
|
||||
if (data && data.token) {
|
||||
localStorage.setItem('pos_token', data.token);
|
||||
localStorage.setItem('pos_employee', JSON.stringify(data.employee));
|
||||
token = data.token;
|
||||
window.POS_USER.token = data.token;
|
||||
window.POS_USER.permissions = data.permissions || [];
|
||||
window.POS_USER.branchId = data.employee.branch_id;
|
||||
}
|
||||
if (!enforcePageGuard(window.POS_USER.role, window.POS_USER.permissions)) return;
|
||||
if (typeof window.renderSidebar === 'function') {
|
||||
window.renderSidebar(window.POS_USER.modules || JSON.parse(localStorage.getItem('pos_modules') || '{}'));
|
||||
}
|
||||
}).catch(function() {
|
||||
enforcePageGuard(role, payload.permissions || []);
|
||||
});
|
||||
} catch(e) {
|
||||
enforcePageGuard(role, payload.permissions || []);
|
||||
}
|
||||
|
||||
// ─── Preload enabled modules for sidebar filtering ───
|
||||
|
||||
@@ -9,6 +9,8 @@ const Config = (() => {
|
||||
|
||||
// Cache for branches (used by employee modal selector)
|
||||
let _branches = [];
|
||||
let _rolePermissions = {};
|
||||
let _availablePermissions = [];
|
||||
|
||||
function token() {
|
||||
return localStorage.getItem('pos_token') || '';
|
||||
@@ -108,6 +110,7 @@ const Config = (() => {
|
||||
owner: 'Dueno',
|
||||
admin: 'Admin',
|
||||
cashier: 'Cajero',
|
||||
counter: 'Mostrador',
|
||||
warehouse: 'Almacenista',
|
||||
accountant: 'Contador',
|
||||
workshop: 'Taller',
|
||||
@@ -118,6 +121,7 @@ const Config = (() => {
|
||||
owner: 'badge--owner',
|
||||
admin: 'badge--blue',
|
||||
cashier: 'badge--green',
|
||||
counter: 'badge--gray',
|
||||
warehouse: 'badge--yellow',
|
||||
accountant: 'badge--purple',
|
||||
workshop: 'badge--orange',
|
||||
@@ -961,6 +965,15 @@ const Config = (() => {
|
||||
} catch (e) {
|
||||
console.error('Config.loadModules:', e);
|
||||
}
|
||||
try {
|
||||
var res2 = await fetch(API + '/counter-remission', { headers: headers() });
|
||||
if (!res2.ok) return;
|
||||
var d2 = await res2.json();
|
||||
var cbCr = document.getElementById('cfg-module-counter-remission');
|
||||
if (cbCr) cbCr.checked = d2.enabled === true;
|
||||
} catch (e) {
|
||||
console.error('Config.loadCounterRemission:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveModules() {
|
||||
@@ -968,7 +981,8 @@ const Config = (() => {
|
||||
var cbMp = document.getElementById('cfg-module-marketplace');
|
||||
var cbMeli = document.getElementById('cfg-module-meli');
|
||||
var cbCat = document.getElementById('cfg-module-catalog');
|
||||
if (!cbWa && !cbMp && !cbMeli && !cbCat) return;
|
||||
var cbCr = document.getElementById('cfg-module-counter-remission');
|
||||
if (!cbWa && !cbMp && !cbMeli && !cbCat && !cbCr) return;
|
||||
try {
|
||||
var data = {
|
||||
whatsapp: cbWa ? cbWa.checked : true,
|
||||
@@ -986,12 +1000,112 @@ const Config = (() => {
|
||||
throw new Error(err.error || 'Save failed');
|
||||
}
|
||||
localStorage.setItem('pos_modules', JSON.stringify(data));
|
||||
if (cbCr) {
|
||||
var res2 = await fetch(API + '/counter-remission', {
|
||||
method: 'PUT',
|
||||
headers: headers(),
|
||||
body: JSON.stringify({ enabled: cbCr.checked })
|
||||
});
|
||||
if (!res2.ok) {
|
||||
var err2 = await res2.json().catch(function() { return { error: res2.statusText }; });
|
||||
throw new Error(err2.error || 'Save failed');
|
||||
}
|
||||
}
|
||||
toast('Módulos actualizados');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tab navigation
|
||||
// -------------------------------------------------------------------------
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.cfg-tab-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.settings-section[data-tab]').forEach(function(sec) {
|
||||
var isActive = sec.dataset.tab === tab;
|
||||
sec.classList.toggle('active', isActive);
|
||||
sec.style.display = isActive ? '' : 'none';
|
||||
});
|
||||
try { localStorage.setItem('pos_config_tab', tab); } catch(e) {}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Role permissions editor
|
||||
// -------------------------------------------------------------------------
|
||||
async function loadRolePermissions() {
|
||||
try {
|
||||
var res = await fetch(API + '/role-permissions', { headers: headers() });
|
||||
if (!res.ok) return;
|
||||
var data = await res.json();
|
||||
_rolePermissions = data.roles || {};
|
||||
_availablePermissions = data.available || [];
|
||||
renderRolePermissions();
|
||||
} catch (e) {
|
||||
console.error('Config.loadRolePermissions:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRolePermissions() {
|
||||
var container = document.getElementById('role-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.</p>';
|
||||
return;
|
||||
}
|
||||
var current = _rolePermissions[role] || [];
|
||||
var html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:var(--space-4);">';
|
||||
_availablePermissions.forEach(function(group) {
|
||||
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);">' + escapeHtml(group.module) + '</h4>';
|
||||
group.permissions.forEach(function(p) {
|
||||
var checked = current.indexOf(p.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-perm-key="' + escapeHtml(p.key) + '" ' + checked + ' style="width:auto;" />';
|
||||
html += '<span>' + escapeHtml(p.label) + '</span>';
|
||||
html += '</label>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
async function saveRolePermissions() {
|
||||
var roleSel = document.getElementById('cfg-perm-role');
|
||||
var status = document.getElementById('role-permissions-status');
|
||||
if (!roleSel || !roleSel.value) {
|
||||
if (status) status.textContent = 'Selecciona un rol';
|
||||
return;
|
||||
}
|
||||
var role = roleSel.value;
|
||||
var selected = [];
|
||||
document.querySelectorAll('#role-permissions-container input[data-perm-key]').forEach(function(cb) {
|
||||
if (cb.checked) selected.push(cb.dataset.permKey);
|
||||
});
|
||||
var payload = { roles: {} };
|
||||
payload.roles[role] = selected;
|
||||
try {
|
||||
var res = await fetch(API + '/role-permissions', {
|
||||
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');
|
||||
_rolePermissions[role] = selected;
|
||||
if (status) status.textContent = 'Permisos guardados y aplicados a empleados existentes';
|
||||
setTimeout(function() { if (status) status.textContent = ''; }, 4000);
|
||||
} catch (e) {
|
||||
if (status) status.textContent = e.message;
|
||||
toast(e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -1044,6 +1158,11 @@ const Config = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
// Show permissions tab only for owner/admin
|
||||
var isAdmin = user.role === 'owner' || user.role === 'admin';
|
||||
var permTabBtn = document.querySelector('.cfg-tab-btn--permissions');
|
||||
if (permTabBtn) permTabBtn.style.display = isAdmin ? '' : 'none';
|
||||
|
||||
// Load real data in parallel
|
||||
loadBranches();
|
||||
loadEmployees();
|
||||
@@ -1053,6 +1172,15 @@ const Config = (() => {
|
||||
loadAllowedBrands();
|
||||
loadModules();
|
||||
loadReceiptConfig();
|
||||
if (isAdmin) loadRolePermissions();
|
||||
|
||||
// Activate default or stored tab
|
||||
var defaultTab = 'general';
|
||||
try {
|
||||
var storedTab = localStorage.getItem('pos_config_tab');
|
||||
if (storedTab) defaultTab = storedTab;
|
||||
} catch(e) {}
|
||||
switchTab(defaultTab);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
@@ -1074,7 +1202,8 @@ const Config = (() => {
|
||||
loadVehicleCompatSource, saveVehicleCompatSource,
|
||||
loadModules, saveModules,
|
||||
loadReceiptConfig, saveReceiptConfig, handleReceiptLogo, removeReceiptLogo,
|
||||
openModal, closeModal, openBranchModal, editBranch
|
||||
openModal, closeModal, openBranchModal, editBranch,
|
||||
switchTab, loadRolePermissions, renderRolePermissions, saveRolePermissions
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
@@ -10,6 +10,7 @@ var I18N = {
|
||||
'catalog': 'Catalogo',
|
||||
'inventory': 'Inventario',
|
||||
'diagrams': 'Diagramas',
|
||||
'remission_notes': 'Notas de Remisión',
|
||||
'customers': 'Clientes',
|
||||
'invoicing': 'Facturacion',
|
||||
'accounting': 'Contabilidad',
|
||||
@@ -166,6 +167,7 @@ var I18N = {
|
||||
'catalog': 'Catalog',
|
||||
'inventory': 'Inventory',
|
||||
'diagrams': 'Diagrams',
|
||||
'remission_notes': 'Remission Notes',
|
||||
'customers': 'Customers',
|
||||
'invoicing': 'Invoicing',
|
||||
'accounting': 'Accounting',
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
var userRole = (user.role || '').toLowerCase();
|
||||
var userPerms = user.permissions || [];
|
||||
var canEditPrices = userRole === 'owner' || userRole === 'admin' || userPerms.indexOf('config.edit_prices') !== -1;
|
||||
var canCreateItem = userRole === 'owner' || userRole === 'admin' || userRole === 'counter' || userRole === 'cashier' || userRole === 'warehouse' || userPerms.indexOf('inventory.create') !== -1;
|
||||
var canEditItem = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
|
||||
var canImportItems = userRole === 'owner' || userRole === 'admin' || userRole === 'warehouse' || userPerms.indexOf('inventory.edit') !== -1;
|
||||
|
||||
// Hide toolbar actions the user is not allowed to use
|
||||
(function applyInventoryPermissions() {
|
||||
var headerNew = document.getElementById('btnHeaderNewProduct');
|
||||
var stockNew = document.getElementById('btnStockNewProduct');
|
||||
var headerImport = document.getElementById('btnHeaderImport');
|
||||
if (headerNew) headerNew.style.display = canCreateItem ? '' : 'none';
|
||||
if (stockNew) stockNew.style.display = canCreateItem ? '' : 'none';
|
||||
if (headerImport) headerImport.style.display = canImportItems ? '' : 'none';
|
||||
})();
|
||||
|
||||
// Load compatibility source setting
|
||||
(function loadCompatSource() {
|
||||
@@ -186,11 +199,11 @@
|
||||
'<td>' + esc(it.location) + '</td>' +
|
||||
'<td>' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();viewHistory(' + it.id + ')">Historial</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' +
|
||||
'<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' +
|
||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();showEditItemModal(' + it.id + ')">Editar</button> ' : '') +
|
||||
(canCreateItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-accent);" onclick="event.stopPropagation();showPurchaseModalForItem(' + it.id + ')">Entrada</button> ' : '') +
|
||||
(userPerms.indexOf('marketplace.manage') !== -1 || userRole === 'owner' || userRole === 'admin' ? '<button class="btn btn--sm btn--meli" onclick="event.stopPropagation();publishToMeli(' + it.id + ')">ML</button> ' : '') +
|
||||
'<button class="btn btn--ghost btn--sm" onclick="event.stopPropagation();printBarcode(\'' + esc(it.barcode) + '\',\'' + esc(it.part_number) + '\',\'' + esc(it.name) + '\')">Etiqueta</button> ' +
|
||||
'<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' +
|
||||
(canEditItem ? '<button class="btn btn--ghost btn--sm" style="color:var(--color-error);" onclick="event.stopPropagation();deleteItem(' + it.id + ')">Eliminar</button>' : '') +
|
||||
'</td></tr>';
|
||||
}
|
||||
|
||||
@@ -544,6 +557,28 @@
|
||||
}
|
||||
window.submitBulkImport = submitBulkImport;
|
||||
|
||||
function downloadBulkImportTemplate() {
|
||||
var headers = [
|
||||
'numero_de_parte','nombre','marca','precio','cantidad','costo',
|
||||
'sku_secundario','descripcion','categoria','fabricante','modelo','anio','motor','codigo_motor'
|
||||
];
|
||||
var example = [
|
||||
'EJ-001','Filtro de aceite','ACDelco','150.00','10','90.00',
|
||||
'EJ001-ALT','Filtro para sedan','Filtros','Nissan','Sentra','2020','1.8','MR18DE'
|
||||
];
|
||||
var csv = [headers.join(','), example.join(',')].join('\n');
|
||||
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
var url = URL.createObjectURL(blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'plantilla_inventario.csv';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
window.downloadBulkImportTemplate = downloadBulkImportTemplate;
|
||||
|
||||
// =====================================================================
|
||||
// PURCHASE / ENTRADA (purchaseModal)
|
||||
// =====================================================================
|
||||
|
||||
@@ -83,7 +83,12 @@
|
||||
localStorage.setItem('pos_employee', JSON.stringify(result.data.employee));
|
||||
localStorage.setItem('pos_tenant_id', tenantId);
|
||||
document.cookie = 'pos_role=' + (result.data.employee.role || '') + '; path=/pos; SameSite=Lax';
|
||||
window.location.href = '/pos/catalog';
|
||||
var role = (result.data.employee.role || '').toLowerCase();
|
||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||
window.location.href = '/pos/workshop';
|
||||
} else {
|
||||
window.location.href = '/pos/catalog';
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
errorEl.textContent = 'Error de conexion';
|
||||
|
||||
@@ -30,7 +30,12 @@ const POS = (() => {
|
||||
let canEditPrice = false;
|
||||
let canCreateWorkshopOrder = false;
|
||||
let canCreateLayaway = false;
|
||||
let canCreateRemission = false;
|
||||
let counterRemissionEnabled = false;
|
||||
let currentPerms = [];
|
||||
let receiptConfig = {};
|
||||
let couriers = [];
|
||||
let selectedCourierId = null;
|
||||
|
||||
// Currency-aware formatter: reads pos_currency from localStorage
|
||||
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
|
||||
@@ -38,6 +43,16 @@ const POS = (() => {
|
||||
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
|
||||
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
// Price based on customer tier: base price (price_1) with tier discount.
|
||||
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||
function priceForTier(basePrice, tier) {
|
||||
const p = parseFloat(basePrice) || 0;
|
||||
const t = parseInt(tier, 10) || 1;
|
||||
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||
return p;
|
||||
}
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
@@ -66,6 +81,10 @@ const POS = (() => {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
function show(selector) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) el.style.display = '';
|
||||
}
|
||||
if (!canCancel) {
|
||||
hide('#btnCancelSale');
|
||||
hide('#fkeyEsc');
|
||||
@@ -77,6 +96,25 @@ const POS = (() => {
|
||||
hide('[title="Orden de servicio"]');
|
||||
}
|
||||
if (!canCreateLayaway) hide('[onclick="POS.createLayaway()"]');
|
||||
|
||||
// Counter remission workflow
|
||||
if (canCreateRemission) {
|
||||
hide('#btnCobrar');
|
||||
hide('.fkey[onclick="POS.checkout()"]');
|
||||
|
||||
hide('[onclick="POS.createLayaway()"]');
|
||||
hide('[onclick="POS.saveQuotation()"]');
|
||||
show('#btnRemission');
|
||||
show('#courierSelectField');
|
||||
} else {
|
||||
hide('#btnRemission');
|
||||
hide('#courierSelectField');
|
||||
}
|
||||
if (!currentPerms.includes('pos.sell')) {
|
||||
hide('#btnPayRemission');
|
||||
} else {
|
||||
show('#btnPayRemission');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Init ────────────────────────────
|
||||
@@ -87,14 +125,26 @@ const POS = (() => {
|
||||
document.getElementById('employeeName').textContent = payload.name || 'Empleado';
|
||||
document.getElementById('branchName').textContent = payload.branch_name || '';
|
||||
const perms = payload.permissions || [];
|
||||
currentPerms = perms;
|
||||
const employeeRole = payload.role || '';
|
||||
canViewCost = perms.includes('pos.view_cost');
|
||||
canCancel = perms.includes('pos.cancel');
|
||||
canDiscount = perms.includes('pos.discount');
|
||||
canEditPrice = perms.includes('config.edit_prices');
|
||||
canEditPrice = perms.includes('config.edit_prices') || employeeRole === 'cashier' || employeeRole === 'counter';
|
||||
canCreateWorkshopOrder = perms.includes('workshop.edit');
|
||||
canCreateLayaway = perms.includes('pos.sell');
|
||||
employeeMaxDiscount = payload.max_discount_pct || 100;
|
||||
|
||||
// Counter remission feature
|
||||
try {
|
||||
const crCfg = await api('/pos/api/config/counter-remission');
|
||||
counterRemissionEnabled = crCfg.enabled === true;
|
||||
} catch (e) {
|
||||
counterRemissionEnabled = false;
|
||||
}
|
||||
// Counter remission workflow applies only to the counter role.
|
||||
canCreateRemission = counterRemissionEnabled && employeeRole === 'counter' && perms.includes('pos.remission');
|
||||
|
||||
// Show cost/margin columns and toggle button if permission
|
||||
if (canViewCost) {
|
||||
document.getElementById('thCost').style.display = '';
|
||||
@@ -138,6 +188,7 @@ const POS = (() => {
|
||||
// Load current register and receipt config
|
||||
await loadRegister();
|
||||
await loadReceiptConfig();
|
||||
await loadCouriers();
|
||||
|
||||
// Setup event listeners
|
||||
setupKeyboard();
|
||||
@@ -157,8 +208,10 @@ const POS = (() => {
|
||||
currentRegister = null;
|
||||
document.getElementById('registerInfo').innerHTML =
|
||||
'<span style="color:var(--color-error);cursor:pointer;" onclick="POS.showOpenRegisterModal()" title="Clic para abrir caja">⚠ Sin caja abierta — Clic para abrir</span>';
|
||||
// Force open register modal on first load
|
||||
showOpenRegisterModal();
|
||||
// Force open register modal on first load only for users who can sell
|
||||
if (currentPerms.includes('pos.sell')) {
|
||||
showOpenRegisterModal();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Register check failed:', e);
|
||||
@@ -174,6 +227,24 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCouriers() {
|
||||
try {
|
||||
const data = await api('/pos/api/logistics/couriers');
|
||||
couriers = data.couriers || [];
|
||||
const sel = document.getElementById('remissionCourier');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">-- Sin repartidor --</option>' +
|
||||
couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
sel.addEventListener('change', () => {
|
||||
selectedCourierId = sel.value ? parseInt(sel.value, 10) : null;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not load couriers:', e);
|
||||
couriers = [];
|
||||
}
|
||||
}
|
||||
|
||||
function rc(key, fallback) {
|
||||
const v = receiptConfig[key];
|
||||
return v !== undefined && v !== null && v !== '' ? v : fallback;
|
||||
@@ -570,26 +641,11 @@ const POS = (() => {
|
||||
if (data.data.length === 0) {
|
||||
container.innerHTML = '<div style="padding:20px;text-align:center;color:var(--color-text-muted);">Sin resultados</div>';
|
||||
if (window.BarcodeFeedback) BarcodeFeedback.error();
|
||||
} else if (data.data.length === 1 && q.length >= 8) {
|
||||
// Auto-select single result on barcode scan (long codes)
|
||||
const item = data.data[0];
|
||||
let price = item.price_1;
|
||||
if (currentCustomer) {
|
||||
const tier = currentCustomer.price_tier || 1;
|
||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
}
|
||||
addFromSearch(item, price);
|
||||
input.value = '';
|
||||
hideSearchResults();
|
||||
return;
|
||||
} else {
|
||||
let html = '';
|
||||
data.data.forEach(item => {
|
||||
let price = item.price_1;
|
||||
if (currentCustomer) {
|
||||
const tier = currentCustomer.price_tier || 1;
|
||||
price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
}
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
const price = priceForTier(item.price_1, tier);
|
||||
html += `<div style="padding:var(--space-3) var(--space-4);border-bottom:1px solid var(--color-border);cursor:pointer;display:flex;justify-content:space-between;align-items:center;transition:var(--transition-fast);" onmouseover="this.style.background='var(--color-primary-muted)'" onmouseout="this.style.background=''" onclick='POS.addFromSearch(${JSON.stringify(item).replace(/'/g, "'")}, ${price})'>
|
||||
<div>
|
||||
<div style="font-weight:var(--font-weight-semibold);">${item.name}</div>
|
||||
@@ -684,7 +740,7 @@ const POS = (() => {
|
||||
const tier = currentCustomer ? (currentCustomer.price_tier || 1) : 1;
|
||||
cart.forEach(item => {
|
||||
if (item.price_1 > 0) {
|
||||
item.unit_price = tier === 3 ? item.price_3 : tier === 2 ? item.price_2 : item.price_1;
|
||||
item.unit_price = priceForTier(item.price_1, tier);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -880,9 +936,12 @@ const POS = (() => {
|
||||
transferencia: 'refPayment',
|
||||
tarjeta: 'refPayment',
|
||||
mixto: 'mixedPayment',
|
||||
credito: 'creditPayment',
|
||||
cheque: 'chequePayment',
|
||||
pendiente: 'pendingPayment',
|
||||
};
|
||||
|
||||
['cashPayment', 'refPayment', 'mixedPayment'].forEach(id => {
|
||||
['cashPayment', 'refPayment', 'mixedPayment', 'creditPayment', 'chequePayment', 'pendingPayment'].forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
const isActive = el.id === tabs[method];
|
||||
@@ -896,6 +955,12 @@ const POS = (() => {
|
||||
const ref = document.getElementById('paymentRef');
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
if (method === 'cheque') {
|
||||
const chequeAmount = document.getElementById('chequeAmount');
|
||||
if (chequeAmount) chequeAmount.value = fmt(getTotal());
|
||||
const chequeRef = document.getElementById('chequeRef');
|
||||
if (chequeRef) chequeRef.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function updateChange() {
|
||||
@@ -933,6 +998,7 @@ const POS = (() => {
|
||||
let amountPaid = 0;
|
||||
let paymentDetails = [];
|
||||
let reference = '';
|
||||
let saleType = 'cash';
|
||||
|
||||
if (paymentMethod === 'efectivo') {
|
||||
amountPaid = parseFloat(document.getElementById('cashReceived').value) || 0;
|
||||
@@ -952,6 +1018,20 @@ const POS = (() => {
|
||||
}
|
||||
});
|
||||
if (amountPaid < total) { alert(`Monto total insuficiente. Falta: ${fmt(total - amountPaid)}`); return; }
|
||||
} else if (paymentMethod === 'credito') {
|
||||
if (!currentCustomer) { alert('Seleccione un cliente para venta a crédito'); return; }
|
||||
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||
if (total > available) {
|
||||
alert(`Crédito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}`);
|
||||
return;
|
||||
}
|
||||
saleType = 'credit';
|
||||
amountPaid = 0;
|
||||
} else if (paymentMethod === 'cheque') {
|
||||
amountPaid = total;
|
||||
reference = document.getElementById('chequeRef').value.trim();
|
||||
} else if (paymentMethod === 'pendiente') {
|
||||
amountPaid = 0;
|
||||
}
|
||||
|
||||
const saleData = {
|
||||
@@ -964,7 +1044,7 @@ const POS = (() => {
|
||||
})),
|
||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||
payment_method: paymentMethod,
|
||||
sale_type: 'cash',
|
||||
sale_type: saleType,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
@@ -983,9 +1063,10 @@ const POS = (() => {
|
||||
const convertData = {
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
payment_method: paymentMethod,
|
||||
sale_type: 'cash',
|
||||
sale_type: saleType,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
reference: reference,
|
||||
};
|
||||
sale = await api('/pos/api/quotations/' + convertQuoteId + '/convert', {
|
||||
method: 'POST',
|
||||
@@ -1020,22 +1101,13 @@ const POS = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Credit Sale ─────────────────────
|
||||
async function creditSale() {
|
||||
if (cart.length === 0) { alert('Carrito vacio'); return; }
|
||||
if (!currentCustomer) { alert('Seleccione un cliente para venta a credito'); return; }
|
||||
if (!currentRegister) { alert('No hay caja abierta.'); return; }
|
||||
// ─── Counter Remission Note ────────────
|
||||
async function createRemissionNote() {
|
||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||
if (!canCreateRemission) { showToast('No tienes permiso para generar notas de remision'); return; }
|
||||
|
||||
const total = getTotal();
|
||||
const available = (currentCustomer.credit_limit || 0) - (currentCustomer.credit_balance || 0);
|
||||
|
||||
if (currentCustomer.credit_limit > 0 && total > available) {
|
||||
if (!confirm(`Credito insuficiente. Disponible: ${fmt(available)}, Total: ${fmt(total)}. Continuar?`)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const saleData = {
|
||||
const noteData = {
|
||||
items: cart.map(item => ({
|
||||
inventory_id: item.inventory_id,
|
||||
quantity: item.quantity,
|
||||
@@ -1043,19 +1115,21 @@ const POS = (() => {
|
||||
discount_pct: item.discount_pct,
|
||||
tax_rate: item.tax_rate,
|
||||
})),
|
||||
customer_id: currentCustomer.id,
|
||||
payment_method: 'credito',
|
||||
sale_type: 'credit',
|
||||
customer_id: currentCustomer ? currentCustomer.id : null,
|
||||
notes: 'Nota de remision generada desde mostrador',
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
amount_paid: 0,
|
||||
courier_id: selectedCourierId,
|
||||
};
|
||||
|
||||
try {
|
||||
const sale = await api('/pos/api/sales', {
|
||||
const sale = await api('/pos/api/sales/remission', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(saleData),
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (selectedCourierId) {
|
||||
const courier = couriers.find(c => c.id === selectedCourierId);
|
||||
sale.courier_name = courier ? courier.name : '';
|
||||
}
|
||||
lastSaleId = sale.id;
|
||||
lastSaleData = sale;
|
||||
try { sessionStorage.setItem('pos_last_sale_id', sale.id); } catch(e) {}
|
||||
@@ -1063,12 +1137,128 @@ const POS = (() => {
|
||||
cart = [];
|
||||
selectedRow = -1;
|
||||
clearCustomer();
|
||||
selectedCourierId = null;
|
||||
const sel = document.getElementById('remissionCourier');
|
||||
if (sel) sel.value = '';
|
||||
renderCart();
|
||||
showToast(`Nota de remision NR-${sale.id} generada`);
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
alert('Error al generar nota de remision: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function openPayRemissionModal() {
|
||||
if (!currentPerms.includes('pos.sell')) { showToast('No tienes permiso para cobrar notas'); return; }
|
||||
document.getElementById('payRemissionFolio').value = '';
|
||||
document.getElementById('payRemissionDetail').innerHTML = '';
|
||||
document.getElementById('payRemissionResult').innerHTML = '';
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
document.getElementById('payRemissionModal').classList.add('open');
|
||||
setTimeout(() => document.getElementById('payRemissionFolio').focus(), 100);
|
||||
}
|
||||
|
||||
function closePayRemissionModal() {
|
||||
document.getElementById('payRemissionModal').classList.remove('open');
|
||||
}
|
||||
|
||||
let pendingRemissionToPay = null;
|
||||
|
||||
async function searchRemissionToPay() {
|
||||
const folio = parseInt(document.getElementById('payRemissionFolio').value, 10);
|
||||
if (!folio) { showToast('Ingresa un folio valido'); return; }
|
||||
pendingRemissionToPay = null;
|
||||
try {
|
||||
const sale = await api('/pos/api/sales/' + folio);
|
||||
if (sale.status !== 'pending_payment') {
|
||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">La venta ${folio} no esta pendiente de pago</div>`;
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
pendingRemissionToPay = sale;
|
||||
let itemsHtml = (sale.items || []).map(it => `
|
||||
<div class="ticket-line">
|
||||
<span class="qty">${it.quantity}</span>
|
||||
<span class="name">${it.name || ''}</span>
|
||||
<span class="subtotal">${fmt(it.subtotal || 0)}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('payRemissionDetail').innerHTML = `
|
||||
<div class="info-row"><span>Cliente:</span><span>${sale.customer_name || 'Publico General'}</span></div>
|
||||
<div class="info-row"><span>Vendedor:</span><span>${sale.employee_name || ''}</span></div>
|
||||
<div class="info-row"><span>Total:</span><span class="grand">${fmt(sale.total)}</span></div>
|
||||
<hr class="divider">
|
||||
${itemsHtml}
|
||||
`;
|
||||
document.getElementById('payRemissionActions').style.display = '';
|
||||
} catch (e) {
|
||||
document.getElementById('payRemissionDetail').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||||
document.getElementById('payRemissionActions').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmPayRemission() {
|
||||
if (!pendingRemissionToPay) return;
|
||||
const sale = pendingRemissionToPay;
|
||||
const paymentMethod = document.getElementById('payRemissionMethod').value;
|
||||
let amountPaid = parseFloat(sale.total);
|
||||
let paymentDetails = [];
|
||||
let reference = '';
|
||||
|
||||
if (paymentMethod === 'efectivo') {
|
||||
const received = parseFloat(document.getElementById('payRemissionReceived').value) || 0;
|
||||
if (received < sale.total) { alert('Monto insuficiente'); return; }
|
||||
amountPaid = received;
|
||||
} else if (paymentMethod === 'mixto') {
|
||||
const rows = document.querySelectorAll('#payRemissionMixed .mixed-row');
|
||||
let sum = 0;
|
||||
rows.forEach(row => {
|
||||
const method = row.querySelector('select').value;
|
||||
const amount = parseFloat(row.querySelector('.mixed-amount').value) || 0;
|
||||
const ref = row.querySelectorAll('input')[1]?.value || '';
|
||||
if (amount > 0) {
|
||||
paymentDetails.push({ method, amount, reference: ref });
|
||||
sum += amount;
|
||||
}
|
||||
});
|
||||
if (sum < sale.total) { alert(`Monto total insuficiente. Falta: ${fmt(sale.total - sum)}`); return; }
|
||||
amountPaid = sum;
|
||||
} else {
|
||||
reference = document.getElementById('payRemissionReference').value.trim();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await api('/pos/api/sales/' + sale.id + '/pay', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payment_method: paymentMethod,
|
||||
amount_paid: amountPaid,
|
||||
payment_details: paymentDetails,
|
||||
register_id: currentRegister ? currentRegister.id : null,
|
||||
reference: reference,
|
||||
}),
|
||||
});
|
||||
closePayRemissionModal();
|
||||
showToast(`Nota NR-${sale.id} pagada`);
|
||||
// Refresh sale object to print paid ticket
|
||||
const updated = await api('/pos/api/sales/' + sale.id);
|
||||
lastSaleId = updated.id;
|
||||
lastSaleData = updated;
|
||||
showTicket(updated);
|
||||
} catch (e) {
|
||||
alert('Error al cobrar nota: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePayRemissionMethod() {
|
||||
const method = document.getElementById('payRemissionMethod').value;
|
||||
const cashEl = document.getElementById('payRemissionCash');
|
||||
const refEl = document.getElementById('payRemissionRef');
|
||||
const mixedEl = document.getElementById('payRemissionMixed');
|
||||
if (cashEl) cashEl.style.display = method === 'efectivo' ? '' : 'none';
|
||||
if (refEl) refEl.style.display = (method === 'transferencia' || method === 'tarjeta') ? '' : 'none';
|
||||
if (mixedEl) mixedEl.style.display = method === 'mixto' ? '' : 'none';
|
||||
}
|
||||
|
||||
// ─── Quotation ───────────────────────
|
||||
async function saveQuotation() {
|
||||
if (cart.length === 0) { showToast('Carrito vacio'); return; }
|
||||
@@ -1284,8 +1474,9 @@ const POS = (() => {
|
||||
hour: '2-digit', minute: '2-digit'
|
||||
});
|
||||
|
||||
const customerName = currentCustomer ? currentCustomer.name : 'Publico General';
|
||||
const customerRfc = currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '';
|
||||
const isRemission = sale.status === 'pending_payment';
|
||||
const customerName = sale.customer_name || (currentCustomer ? currentCustomer.name : 'Publico General');
|
||||
const customerRfc = sale.customer_rfc || (currentCustomer && currentCustomer.rfc ? currentCustomer.rfc : '');
|
||||
|
||||
let itemsHtml = '';
|
||||
(sale.items || []).forEach(item => {
|
||||
@@ -1333,13 +1524,14 @@ const POS = (() => {
|
||||
</div>
|
||||
<hr class="divider-double">
|
||||
<div class="folio-line">
|
||||
<span>VENTA: V-${sale.id}</span>
|
||||
<span>${isRemission ? 'NOTA DE REMISION' : 'VENTA'}: ${isRemission ? 'NR' : 'V'}-${sale.id}</span>
|
||||
<span>${dateStr}</span>
|
||||
</div>
|
||||
<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;">
|
||||
<span>Cliente: ${customerName}</span>
|
||||
${customerRfc ? `<span>RFC: ${customerRfc}</span>` : ''}
|
||||
</div>
|
||||
${isRemission && sale.courier_name ? `<div class="ticket-row" style="font-size: 9px; color: #555; margin-bottom: 4px;"><span>Repartidor: ${sale.courier_name}</span></div>` : ''}
|
||||
<hr class="divider">
|
||||
<div class="item-line-wide" style="font-weight: bold; font-size: 9px; color: #555; text-transform: uppercase;">
|
||||
<span class="qty">Cant</span>
|
||||
@@ -1363,6 +1555,12 @@ const POS = (() => {
|
||||
${showPayment ? `
|
||||
<hr class="divider">
|
||||
<div class="payment-section">
|
||||
${isRemission ? `
|
||||
<div class="ticket-row" style="font-weight: bold; color: #b91c1c;">
|
||||
<span>Estado:</span><span>PENDIENTE DE PAGO</span>
|
||||
</div>
|
||||
<div style="font-size: 9px; text-align: center; margin-top: 4px;">Presente esta nota en caja para pagar</div>
|
||||
` : `
|
||||
<div class="ticket-row">
|
||||
<span>Forma de pago:</span><span>${sale.payment_method || paymentMethod}</span>
|
||||
</div>
|
||||
@@ -1373,11 +1571,13 @@ const POS = (() => {
|
||||
<div class="ticket-row" style="font-weight: bold;">
|
||||
<span>Cambio:</span><span>${fmt(sale.change_given || 0)}</span>
|
||||
</div>` : ''}
|
||||
`}
|
||||
</div>` : ''}
|
||||
<hr class="divider">
|
||||
<div class="footer-section">
|
||||
<div class="thanks">${thanksMsg}</div>
|
||||
${footerMsg ? `<div>${footerMsg}</div>` : ''}
|
||||
<div class="thanks">${isRemission ? 'Gracias por su preferencia' : thanksMsg}</div>
|
||||
${footerMsg && !isRemission ? `<div>${footerMsg}</div>` : ''}
|
||||
${isRemission ? '<div style="font-size: 9px;">Conserve esta nota para el pago</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1387,7 +1587,7 @@ const POS = (() => {
|
||||
const preview = document.getElementById('ticketPreviewContent');
|
||||
if (preview) preview.innerHTML = ticketHtml;
|
||||
const modalHeader = document.querySelector('#ticketModal .modal-header h3');
|
||||
if (modalHeader) modalHeader.textContent = 'Ticket de Venta';
|
||||
if (modalHeader) modalHeader.textContent = isRemission ? 'Nota de Remision' : 'Ticket de Venta';
|
||||
|
||||
document.getElementById('ticketModal').classList.add('open');
|
||||
}
|
||||
@@ -1592,7 +1792,9 @@ const POS = (() => {
|
||||
showNewCustomerModal, closeNewCustomerModal, saveNewCustomer,
|
||||
checkout, confirmPayment, closePaymentModal,
|
||||
selectPaymentMethod, updateChange, updateMixedTotal,
|
||||
creditSale, saveQuotation, createLayaway,
|
||||
createRemissionNote, openPayRemissionModal, closePayRemissionModal,
|
||||
searchRemissionToPay, confirmPayRemission, updatePayRemissionMethod,
|
||||
saveQuotation, createLayaway,
|
||||
createServiceOrder, closeServiceOrderModal, confirmServiceOrder, showServiceOrderTicket,
|
||||
showLastSale, openDrawer,
|
||||
showTicket, closeTicketModal, printTicket,
|
||||
|
||||
@@ -54,7 +54,20 @@ const Reports = (() => {
|
||||
}
|
||||
|
||||
// Track which tabs have been loaded
|
||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false };
|
||||
var loaded = { ventas: false, inventario: false, clientes: false, financieros: false, historico: false, cortes: false };
|
||||
|
||||
function currentUser() {
|
||||
return (typeof window.POS_USER !== 'undefined') ? window.POS_USER : {};
|
||||
}
|
||||
function hasPerm(p) {
|
||||
var u = currentUser();
|
||||
return (u.role === 'owner' || u.role === 'admin') || (u.permissions || []).indexOf(p) !== -1;
|
||||
}
|
||||
function isLimitedUser() {
|
||||
var u = currentUser();
|
||||
var r = (u.role || '').toLowerCase();
|
||||
return (r === 'cashier' || r === 'counter') && !hasPerm('pos.view');
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Theme switcher
|
||||
@@ -86,6 +99,7 @@ const Reports = (() => {
|
||||
else if (id === 'clientes') loadClientes();
|
||||
else if (id === 'financieros') loadFinancieros();
|
||||
else if (id === 'historico') loadHistorico();
|
||||
else if (id === 'cortes') loadCortes();
|
||||
}
|
||||
}
|
||||
window.switchTab = switchTab;
|
||||
@@ -737,6 +751,153 @@ const Reports = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// TAB 6: CORTES DE CAJA
|
||||
// =========================================================================
|
||||
async function loadCortes() {
|
||||
loaded.cortes = true;
|
||||
var dateFrom = document.getElementById('cortes-date-from').value;
|
||||
var dateTo = document.getElementById('cortes-date-to').value;
|
||||
var employeeId = document.getElementById('cortes-employee').value;
|
||||
|
||||
// Cashiers/counters without pos.view can only see their own cuts
|
||||
var u = currentUser();
|
||||
if (isLimitedUser()) {
|
||||
employeeId = u.employeeId || '';
|
||||
var empSel = document.getElementById('cortes-employee');
|
||||
if (empSel) empSel.value = employeeId;
|
||||
}
|
||||
|
||||
var kpiEl = document.getElementById('cortes-kpis');
|
||||
var detalleEl = document.getElementById('cortes-detalle');
|
||||
var ventasDetalleEl = document.getElementById('corte-ventas-detalle');
|
||||
kpiEl.innerHTML = spinner();
|
||||
detalleEl.innerHTML = spinner();
|
||||
if (ventasDetalleEl) ventasDetalleEl.style.display = 'none';
|
||||
|
||||
var params = new URLSearchParams();
|
||||
if (dateFrom) params.set('date_from', dateFrom);
|
||||
if (dateTo) params.set('date_to', dateTo);
|
||||
if (employeeId) params.set('employee_id', employeeId);
|
||||
params.set('per_page', '200');
|
||||
|
||||
try {
|
||||
var data = await apiFetch('/pos/api/register/history?' + params.toString());
|
||||
var regs = data.data || [];
|
||||
|
||||
var totalEsperado = 0;
|
||||
var totalCierre = 0;
|
||||
var totalDiferencia = 0;
|
||||
regs.forEach(function(r) {
|
||||
totalEsperado += r.expected_amount || 0;
|
||||
totalCierre += r.closing_amount || 0;
|
||||
totalDiferencia += r.difference || 0;
|
||||
});
|
||||
|
||||
kpiEl.innerHTML =
|
||||
kpiCard('Cortes', fmtInt(regs.length), 'en el periodo') +
|
||||
kpiCard('Ventas esperadas', '$' + fmt(totalEsperado), 'total acumulado') +
|
||||
kpiCard('Cierre real', '$' + fmt(totalCierre), 'efectivo contado') +
|
||||
kpiCard('Diferencia', '$' + fmt(totalDiferencia), totalDiferencia >= 0 ? 'sobrante' : 'faltante');
|
||||
|
||||
var cHtml = '<div class="table-card__header"><span class="table-card__title">Cortes de Caja</span>' +
|
||||
'<span class="pill pill--muted">' + (data.pagination ? data.pagination.total : regs.length) + ' registros</span></div>';
|
||||
cHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||
'<th>Caja</th><th>Empleado</th><th>Apertura</th><th>Cierre</th>' +
|
||||
'<th class="align-right">Monto Apertura</th><th class="align-right">Esperado</th>' +
|
||||
'<th class="align-right">Cierre Real</th><th class="align-right">Diferencia</th>' +
|
||||
'<th>Acciones</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
regs.forEach(function(r) {
|
||||
var diffColor = r.difference < 0 ? 'color:var(--color-error)' :
|
||||
r.difference > 0 ? 'color:var(--color-warning)' : 'color:var(--color-success)';
|
||||
cHtml += '<tr data-register-id="' + r.id + '" style="cursor:pointer" onclick="Reports.showCorteVentas(' + r.id + ')">' +
|
||||
'<td class="td-mono">#' + r.register_number + '</td>' +
|
||||
'<td class="td-strong">' + (r.employee_name || '--') + '</td>' +
|
||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.opened_at) + '</td>' +
|
||||
'<td style="color:var(--color-text-muted)">' + fmtDateTime(r.closed_at) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.opening_amount) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.expected_amount) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(r.closing_amount) + '</td>' +
|
||||
'<td class="align-right td-mono" style="' + diffColor + '">$' + fmt(r.difference) + '</td>' +
|
||||
'<td><button class="btn btn-sm btn-ghost" onclick="event.stopPropagation(); Reports.showCorteVentas(' + r.id + ')">Ver ventas</button></td></tr>';
|
||||
});
|
||||
cHtml += '</tbody></table></div>';
|
||||
detalleEl.innerHTML = regs.length ? cHtml : emptyMsg('No hay cortes de caja en el periodo seleccionado');
|
||||
} catch (err) {
|
||||
kpiEl.innerHTML = errorMsg('Error cargando cortes de caja: ' + err.message);
|
||||
detalleEl.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Detail: sales for a selected cash cut
|
||||
// -------------------------------------------------------------------------
|
||||
async function showCorteVentas(registerId) {
|
||||
var panel = document.getElementById('corte-ventas-detalle');
|
||||
if (!panel) return;
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = spinner();
|
||||
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
|
||||
var methodLabels = {
|
||||
'cash': 'Efectivo', 'card': 'Tarjeta', 'transfer': 'Transferencia',
|
||||
'credit': 'Crédito', 'mixed': 'Mixto', 'efectivo': 'Efectivo',
|
||||
'tarjeta': 'Tarjeta', 'transferencia': 'Transferencia'
|
||||
};
|
||||
|
||||
try {
|
||||
var data = await apiFetch('/pos/api/register/' + registerId + '/sales');
|
||||
var sales = data.sales || [];
|
||||
var summary = data.summary || {};
|
||||
|
||||
var total = summary.total || 0;
|
||||
var count = summary.count || 0;
|
||||
var byMethod = summary.by_method || {};
|
||||
|
||||
var hHtml = '<div class="table-card__header"><span class="table-card__title">Ventas del corte #' + registerId + '</span>' +
|
||||
'<span class="pill pill--muted">' + count + ' ventas · $' + fmt(total) + '</span></div>';
|
||||
|
||||
// Summary by payment method
|
||||
var methods = Object.entries(byMethod).sort(function(a, b) { return b[1] - a[1]; });
|
||||
if (methods.length) {
|
||||
hHtml += '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;padding:var(--space-4) var(--space-5);border-bottom:1px solid var(--color-border);">';
|
||||
methods.forEach(function(m) {
|
||||
var label = methodLabels[m[0]] || m[0];
|
||||
hHtml += '<div class="pill pill--muted">' + label + ': <strong>$' + fmt(m[1]) + '</strong></div>';
|
||||
});
|
||||
hHtml += '</div>';
|
||||
}
|
||||
|
||||
hHtml += '<div class="table-wrap"><table class="data-table"><thead><tr>' +
|
||||
'<th># Venta</th><th>Fecha</th><th>Cliente</th><th>Método</th>' +
|
||||
'<th class="align-right">Subtotal</th><th class="align-right">Desc.</th>' +
|
||||
'<th class="align-right">Total</th><th>Estado</th>' +
|
||||
'</tr></thead><tbody>';
|
||||
sales.forEach(function(s) {
|
||||
var statusPill = s.status === 'completed' ? 'pill--success' :
|
||||
s.status === 'cancelled' ? 'pill--error' : 'pill--warning';
|
||||
var statusLabel = s.status === 'completed' ? 'Completada' :
|
||||
s.status === 'cancelled' ? 'Cancelada' : s.status;
|
||||
var method = methodLabels[s.payment_method] || s.payment_method || '--';
|
||||
hHtml += '<tr><td class="td-mono">' + s.id + '</td>' +
|
||||
'<td>' + fmtDateTime(s.created_at) + '</td>' +
|
||||
'<td>' + (s.customer_name || 'Mostrador') + '</td>' +
|
||||
'<td>' + method + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(s.subtotal) + '</td>' +
|
||||
'<td class="align-right td-mono">$' + fmt(s.discount_total) + '</td>' +
|
||||
'<td class="align-right td-mono-accent">$' + fmt(s.total) + '</td>' +
|
||||
'<td><span class="pill ' + statusPill + '">' + statusLabel + '</span></td></tr>';
|
||||
});
|
||||
hHtml += '</tbody></table></div>';
|
||||
|
||||
panel.innerHTML = sales.length ? hHtml : emptyMsg('No hay ventas registradas en este corte');
|
||||
} catch (err) {
|
||||
panel.innerHTML = errorMsg('Error cargando ventas del corte: ' + err.message);
|
||||
}
|
||||
}
|
||||
window.showCorteVentas = showCorteVentas;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Init
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -753,6 +914,9 @@ const Reports = (() => {
|
||||
updateClock();
|
||||
setInterval(updateClock, 1000);
|
||||
|
||||
var u = currentUser();
|
||||
var limited = isLimitedUser();
|
||||
|
||||
// Set default date range: first day of current month to today
|
||||
var now = new Date();
|
||||
var firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
@@ -761,6 +925,49 @@ const Reports = (() => {
|
||||
if (fromEl) fromEl.value = firstDay.toISOString().substring(0, 10);
|
||||
if (toEl) toEl.value = now.toISOString().substring(0, 10);
|
||||
|
||||
// Set default date range for cortes de caja
|
||||
var cortesFrom = document.getElementById('cortes-date-from');
|
||||
var cortesTo = document.getElementById('cortes-date-to');
|
||||
if (cortesFrom) cortesFrom.value = firstDay.toISOString().substring(0, 10);
|
||||
if (cortesTo) cortesTo.value = now.toISOString().substring(0, 10);
|
||||
|
||||
// Populate cajero filter for cortes de caja (admins/owners only see it)
|
||||
var empSel = document.getElementById('cortes-employee');
|
||||
if (empSel && !limited) {
|
||||
apiFetch('/pos/api/config/employees?per_page=200').then(function(data) {
|
||||
(data.data || []).forEach(function(e) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name;
|
||||
empSel.appendChild(opt);
|
||||
});
|
||||
}).catch(function() {});
|
||||
} else if (empSel && limited) {
|
||||
empSel.value = u.employeeId || '';
|
||||
}
|
||||
|
||||
// For cashiers/counters without pos.view, limit the page to "Mis cortes"
|
||||
if (limited) {
|
||||
['ventas', 'inventario', 'clientes', 'financieros', 'historico'].forEach(function(id) {
|
||||
var btn = document.querySelector('.tab-btn[onclick="switchTab(\'' + id + '\', this)"]');
|
||||
if (btn) btn.style.display = 'none';
|
||||
});
|
||||
var empFilter = document.getElementById('cortes-employee-filter');
|
||||
if (empFilter) empFilter.style.display = 'none';
|
||||
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('is-active'); });
|
||||
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('is-active'); });
|
||||
var cortesPanel = document.getElementById('panel-cortes');
|
||||
var cortesBtn = document.querySelector('.tab-btn[onclick="switchTab(\'cortes\', this)"]');
|
||||
if (cortesPanel) cortesPanel.classList.add('is-active');
|
||||
if (cortesBtn) {
|
||||
cortesBtn.classList.add('is-active');
|
||||
var svg = '<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1" y="3" width="13" height="10" rx="1"/><path d="M4 7h7M4 10h5"/><circle cx="11" cy="10" r="1.5" fill="currentColor"/></svg>';
|
||||
cortesBtn.innerHTML = svg + '<span>Mis cortes de caja</span>';
|
||||
}
|
||||
loadCortes();
|
||||
}
|
||||
|
||||
// Populate financial period selectors
|
||||
var monthSel = document.getElementById('fin-month');
|
||||
var yearSel = document.getElementById('fin-year');
|
||||
@@ -784,15 +991,18 @@ const Reports = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// Load the default active tab (ventas)
|
||||
loadVentas();
|
||||
// Load the default active tab (ventas) only for privileged users
|
||||
if (!limited) {
|
||||
loadVentas();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
|
||||
return {
|
||||
init, setTheme, switchTab,
|
||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, fmt
|
||||
loadVentas, loadInventario, loadClientes, loadFinancieros, loadHistorico, loadCortes,
|
||||
showCorteVentas, fmt
|
||||
};
|
||||
// Register Cmd+K items
|
||||
if (typeof registerCmdKItem === "function") {
|
||||
|
||||
@@ -41,10 +41,32 @@ window.renderSidebar = function(modulesOverride) {
|
||||
function itemAllowed(id) {
|
||||
if (role === 'owner' || role === 'admin') return true;
|
||||
if (role === 'workshop' || role === 'mechanic') {
|
||||
return id === 'workshop';
|
||||
// Taller siempre visible; el resto depende de los permisos asignados.
|
||||
var allowed = ['workshop'];
|
||||
var permMap = {
|
||||
'customers.view': 'customers',
|
||||
'inventory.view': 'inventory',
|
||||
'catalog.view': 'catalog',
|
||||
'pos.sell': 'pos',
|
||||
'pos.view': 'pos',
|
||||
'pos.remission': 'remission_notes',
|
||||
'invoicing.view': 'invoicing',
|
||||
'quotations.view': 'quotations',
|
||||
'accounting.view': 'accounting',
|
||||
'reports.view': 'reports'
|
||||
};
|
||||
for (var p in permMap) {
|
||||
if (perms.indexOf(p) !== -1 && allowed.indexOf(permMap[p]) === -1) {
|
||||
allowed.push(permMap[p]);
|
||||
}
|
||||
}
|
||||
return allowed.indexOf(id) !== -1;
|
||||
}
|
||||
if (role === 'counter') {
|
||||
return ['pos','catalog','inventory','customers','workshop','remission_notes','reports'].indexOf(id) !== -1;
|
||||
}
|
||||
if (role === 'cashier') {
|
||||
return ['dashboard','pos','catalog','inventory','customers'].indexOf(id) !== -1;
|
||||
return ['pos','catalog','inventory','customers','workshop','remission_notes','invoicing','reports'].indexOf(id) !== -1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -58,6 +80,7 @@ window.renderSidebar = function(modulesOverride) {
|
||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||
{ label: _t('nav_management'), items: [
|
||||
{ id: 'customers', name: _t('customers'), href: '/pos/customers', icon: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>' },
|
||||
{ id: 'remission_notes', name: _t('remission_notes'), href: '/pos/remission-notes', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ id: 'workshop', name: 'Taller', href: '/pos/workshop', icon: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>' },
|
||||
{ id: 'quotations', name: 'Cotizaciones', href: '/pos/quotations', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="15" x2="15" y2="15"/><line x1="12" y1="12" x2="12" y2="18"/>' },
|
||||
moduleEnabled('marketplace') ? { id: 'marketplace', name: 'Marketplace', href: '/pos/marketplace', icon: '<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>' } : null,
|
||||
@@ -65,7 +88,6 @@ window.renderSidebar = function(modulesOverride) {
|
||||
{ id: 'invoicing', name: _t('invoicing'), href: '/pos/invoicing', icon: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/>' },
|
||||
{ id: 'accounting', name: _t('accounting'), href: '/pos/accounting', icon: '<line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>' },
|
||||
{ id: 'reports', name: _t('reports'), href: '/pos/reports', icon: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>' },
|
||||
hasPerm('fleet.view') ? { id: 'fleet', name: _t('fleet'), href: '/pos/fleet', icon: '<path d="M1 13h22M1 13l2-6h6l2 6M9 7h6l2 6M15 13l2-6M5 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zM19 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4z"/>' } : null,
|
||||
moduleEnabled('whatsapp') ? { id: 'whatsapp', name: _t('whatsapp'), href: '/pos/whatsapp', icon: '<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>' } : null,
|
||||
].filter(Boolean).filter(function(i){ return itemAllowed(i.id); })},
|
||||
{ label: _t('nav_system'), items: [
|
||||
@@ -132,14 +154,14 @@ window.renderSidebar = function(modulesOverride) {
|
||||
+ '</div>';
|
||||
|
||||
// Replace existing sidebar
|
||||
var existing = document.querySelector('aside.sidebar, .sidebar, #sidebar');
|
||||
var existing = document.querySelector('.pos-sidebar, aside.sidebar, .sidebar, #sidebar');
|
||||
if (existing) {
|
||||
existing.className = 'pos-sidebar';
|
||||
existing.className = 'pos-sidebar sidebar';
|
||||
existing.innerHTML = sidebarHtml;
|
||||
existing.removeAttribute('style');
|
||||
} else {
|
||||
var el = document.createElement('aside');
|
||||
el.className = 'pos-sidebar';
|
||||
el.className = 'pos-sidebar sidebar';
|
||||
el.innerHTML = sidebarHtml;
|
||||
document.body.insertBefore(el, document.body.firstChild);
|
||||
}
|
||||
|
||||
@@ -30,47 +30,99 @@ var Workshop = (function() {
|
||||
|
||||
var user = window.POS_USER || {};
|
||||
var role = (user.role || '').toLowerCase();
|
||||
var hidePrices = role === 'workshop' || role === 'mechanic';
|
||||
var isRestricted = role === 'workshop' || role === 'mechanic';
|
||||
var isMechanic = role === 'mechanic';
|
||||
var hidePrices = isRestricted;
|
||||
var perms = user.permissions || [];
|
||||
var canEdit = role === 'owner' || role === 'admin' || perms.indexOf('workshop.edit') !== -1;
|
||||
// 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' || perms.indexOf('pos.sell') !== -1;
|
||||
var canSell = role === 'owner' || role === 'admin' || role === 'counter' || role === 'cashier' || perms.indexOf('pos.sell') !== -1;
|
||||
var canChangeStatus = canEdit || isMechanic;
|
||||
|
||||
var COLUMNS = [
|
||||
{key: 'received', label: 'Recibido'},
|
||||
{key: 'diagnosis', label: 'Diagnóstico'},
|
||||
{key: 'waiting_parts', label: 'Espera refacciones'},
|
||||
{key: 'repair', label: 'En reparación'},
|
||||
{key: 'ready', label: 'Listo'},
|
||||
{key: 'delivered', label: 'Entregado'},
|
||||
{key: 'por_revisar', label: 'Por revisar'},
|
||||
{key: 'en_revision', label: 'En revisión'},
|
||||
{key: 'revisada', label: 'Revisada'},
|
||||
{key: 'cotizada', label: 'Cotizada'},
|
||||
{key: 'por_autorizar', label: 'Por autorizar'},
|
||||
{key: 'autorizada', label: 'Autorizada'},
|
||||
{key: 'autorizacion_parcial', label: 'Autorización parcial'},
|
||||
{key: 'en_reparacion', label: 'En reparación'},
|
||||
{key: 'reparada', label: 'Reparada'},
|
||||
{key: 'por_entregar', label: 'Por entregar'},
|
||||
{key: 'entregado', label: 'Entregado'},
|
||||
{key: 'por_enviar', label: 'Por enviar'},
|
||||
{key: 'enviado', label: 'Enviado'},
|
||||
{key: 'por_facturar', label: 'Por facturar'},
|
||||
{key: 'facturada', label: 'Facturada'},
|
||||
{key: 'por_recolectar', label: 'Por recolectar'},
|
||||
];
|
||||
|
||||
var STATUS_LABELS = {
|
||||
received: 'Recibido',
|
||||
diagnosis: 'Diagnóstico',
|
||||
waiting_parts: 'Espera refacciones',
|
||||
repair: 'En reparación',
|
||||
ready: 'Listo',
|
||||
delivered: 'Entregado',
|
||||
cancelled: 'Cancelado'
|
||||
por_revisar: 'Por revisar',
|
||||
en_revision: 'En revisión',
|
||||
revisada: 'Revisada',
|
||||
cotizada: 'Cotizada',
|
||||
por_autorizar: 'Por autorizar',
|
||||
autorizada: 'Autorizada',
|
||||
autorizacion_parcial: 'Autorización parcial',
|
||||
en_reparacion: 'En reparación',
|
||||
reparada: 'Reparada',
|
||||
por_entregar: 'Por entregar',
|
||||
entregado: 'Entregado',
|
||||
por_enviar: 'Por enviar',
|
||||
enviado: 'Enviado',
|
||||
por_facturar: 'Por facturar',
|
||||
facturada: 'Facturada',
|
||||
por_recolectar: 'Por recolectar',
|
||||
cancelada: 'Cancelada'
|
||||
};
|
||||
|
||||
var VALID_NEXT = {
|
||||
received: ['diagnosis', 'cancelled'],
|
||||
diagnosis: ['waiting_parts', 'repair', 'cancelled'],
|
||||
waiting_parts: ['repair', 'cancelled'],
|
||||
repair: ['ready', 'cancelled'],
|
||||
ready: ['delivered', 'cancelled'],
|
||||
delivered: [],
|
||||
cancelled: []
|
||||
por_revisar: ['en_revision', 'cancelada'],
|
||||
en_revision: ['revisada', 'por_revisar', 'cancelada'],
|
||||
revisada: ['cotizada', 'en_revision', 'cancelada'],
|
||||
cotizada: ['por_autorizar', 'revisada', 'cancelada'],
|
||||
por_autorizar: ['autorizada', 'autorizacion_parcial', 'cotizada', 'cancelada'],
|
||||
autorizada: ['en_reparacion', 'por_autorizar', 'cancelada'],
|
||||
autorizacion_parcial: ['en_reparacion', 'por_autorizar', 'cancelada'],
|
||||
en_reparacion: ['reparada', 'por_autorizar', 'cancelada'],
|
||||
reparada: ['por_entregar', 'en_reparacion', 'cancelada'],
|
||||
por_entregar: ['entregado', 'por_enviar', 'reparada', 'cancelada'],
|
||||
por_enviar: ['enviado', 'por_entregar', 'cancelada'],
|
||||
enviado: ['entregado', 'por_enviar', 'cancelada'],
|
||||
por_recolectar: ['por_revisar', 'cancelada'],
|
||||
por_facturar: ['facturada', 'cancelada'],
|
||||
entregado: ['por_facturar'],
|
||||
facturada: [],
|
||||
cancelada: []
|
||||
};
|
||||
|
||||
var DELIVERY_LABELS = {
|
||||
pickup: 'Pasa cliente',
|
||||
pickup: 'Mostrador',
|
||||
delivery: 'Envío a domicilio',
|
||||
courier: 'Motociclista'
|
||||
};
|
||||
|
||||
var ITEM_STATUS_LABELS = {
|
||||
por_revisar: 'Por revisar',
|
||||
revisando: 'Revisando',
|
||||
revisado: 'Revisado',
|
||||
cotizado: 'Cotizado',
|
||||
por_autorizar: 'Por autorizar',
|
||||
autorizado: 'Autorizado',
|
||||
en_reparacion: 'En reparación',
|
||||
reparado: 'Reparado',
|
||||
por_entregar: 'Por entregar',
|
||||
entregado: 'Entregado',
|
||||
por_enviar: 'Por enviar',
|
||||
enviado: 'Enviado',
|
||||
cancelado: 'Cancelado'
|
||||
};
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -88,6 +140,16 @@ var Workshop = (function() {
|
||||
return '$' + parseFloat(n).toLocaleString('es-MX', {minimumFractionDigits: 2, maximumFractionDigits: 2});
|
||||
}
|
||||
|
||||
// Price based on customer tier: base price (price_1) with tier discount.
|
||||
// Tier 2 = Taller (5% off), Tier 3 = Mayoreo (10% off), Tier 1 = base.
|
||||
function priceForTier(basePrice, tier) {
|
||||
var p = parseFloat(basePrice) || 0;
|
||||
var t = parseInt(tier, 10) || 1;
|
||||
if (t === 2) return Math.round(p * 0.95 * 100) / 100;
|
||||
if (t === 3) return Math.round(p * 0.90 * 100) / 100;
|
||||
return p;
|
||||
}
|
||||
|
||||
function fmtDate(d) {
|
||||
if (!d) return '—';
|
||||
var dt = new Date(d);
|
||||
@@ -126,6 +188,15 @@ var Workshop = (function() {
|
||||
if (savedView) currentView = savedView;
|
||||
bindFilters();
|
||||
bindDeliverySelect();
|
||||
if (!canCreate) {
|
||||
var btnNewOrder = document.getElementById('btnNewOrder');
|
||||
if (btnNewOrder) btnNewOrder.style.display = 'none';
|
||||
}
|
||||
if (isRestricted) {
|
||||
document.querySelectorAll('.restricted-hide').forEach(function(el) { el.style.display = 'none'; });
|
||||
var searchInput = document.getElementById('filterSearch');
|
||||
if (searchInput) searchInput.placeholder = 'Buscar orden';
|
||||
}
|
||||
if (hidePrices) {
|
||||
var btnCatalog = document.getElementById('btnCatalog');
|
||||
if (btnCatalog) btnCatalog.style.display = 'none';
|
||||
@@ -151,11 +222,19 @@ var Workshop = (function() {
|
||||
|
||||
function bindDeliverySelect() {
|
||||
var sel = document.getElementById('noDelivery');
|
||||
if (!sel) return;
|
||||
sel.addEventListener('change', function() {
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = sel.value === 'courier' ? 'block' : 'none';
|
||||
});
|
||||
if (sel) {
|
||||
sel.addEventListener('change', function() {
|
||||
var cf = document.getElementById('courierField');
|
||||
if (cf) cf.style.display = (sel.value === 'delivery' || sel.value === 'courier') ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
var eoSel = document.getElementById('eoDelivery');
|
||||
if (eoSel) {
|
||||
eoSel.addEventListener('change', function() {
|
||||
var cf = document.getElementById('eoCourierField');
|
||||
if (cf) cf.style.display = (eoSel.value === 'delivery' || eoSel.value === 'courier') ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function debounce(fn, ms) {
|
||||
@@ -172,9 +251,9 @@ var Workshop = (function() {
|
||||
fetch(API + '/kanban/summary', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
document.getElementById('statReceived').textContent = fmt(d.received || 0);
|
||||
document.getElementById('statRepair').textContent = fmt((d.repair || 0) + (d.diagnosis || 0) + (d.waiting_parts || 0) + (d.quality_check || 0));
|
||||
document.getElementById('statReady').textContent = fmt(d.ready || 0);
|
||||
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);
|
||||
document.getElementById('statOverdue').textContent = fmt(d.overdue || 0);
|
||||
})
|
||||
.catch(function() {});
|
||||
@@ -243,20 +322,33 @@ var Workshop = (function() {
|
||||
|
||||
function renderList() {
|
||||
var body = document.getElementById('listBody');
|
||||
var fullCols = 8;
|
||||
var restrictedCols = 4;
|
||||
var cols = isRestricted ? restrictedCols : fullCols;
|
||||
if (!orders.length) {
|
||||
body.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
||||
body.innerHTML = '<tr><td colspan="' + cols + '" style="text-align:center;padding:var(--space-4);">No se encontraron órdenes</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = orders.map(function(o) {
|
||||
var vehicle = esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || ''));
|
||||
var statusCell = '<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>';
|
||||
var actionCell = '<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>';
|
||||
var base = '<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||
statusCell +
|
||||
actionCell;
|
||||
if (isRestricted) {
|
||||
return '<tr>' + base + '</tr>';
|
||||
}
|
||||
var vehicle = esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—'));
|
||||
return '<tr>' +
|
||||
'<td>' + esc(o.branch_name || '—') + '</td>' +
|
||||
'<td><strong>' + esc(o.order_number) + '</strong></td>' +
|
||||
'<td>' + esc(o.customer_name || 'Cliente general') + '</td>' +
|
||||
'<td>' + esc(o.workshop_name || '—') + '</td>' +
|
||||
'<td>' + vehicle + '</td>' +
|
||||
'<td><span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></td>' +
|
||||
statusCell +
|
||||
'<td class="price-col" style="text-align:right;">' + fmtMoney(o.total) + '</td>' +
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="Workshop.openDetail(' + o.id + ')">Ver</button></td>' +
|
||||
actionCell +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
if (hidePrices) {
|
||||
@@ -287,7 +379,8 @@ var Workshop = (function() {
|
||||
function renderKanban() {
|
||||
var board = document.getElementById('kanbanBoard');
|
||||
board.innerHTML = '';
|
||||
COLUMNS.forEach(function(col) {
|
||||
var hiddenCols = isMechanic ? ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'] : [];
|
||||
COLUMNS.filter(function(col) { return hiddenCols.indexOf(col.key) === -1; }).forEach(function(col) {
|
||||
var colOrders = orders.filter(function(o) { return o.status === col.key; });
|
||||
var colEl = document.createElement('div');
|
||||
colEl.className = 'kanban-column';
|
||||
@@ -322,13 +415,22 @@ var Workshop = (function() {
|
||||
card.className = 'kanban-card';
|
||||
card.onclick = function() { openDetail(o.id); };
|
||||
var priceHtml = hidePrices ? '' : '<span>' + fmtMoney(o.estimated_cost || o.total) + '</span>';
|
||||
if (isRestricted) {
|
||||
card.innerHTML =
|
||||
'<div class="kanban-card__header">' +
|
||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="kanban-card__branch">' + esc(o.branch_name || '—') + '</div>';
|
||||
return card;
|
||||
}
|
||||
card.innerHTML =
|
||||
'<div class="kanban-card__header">' +
|
||||
' <span class="kanban-card__id">' + esc(o.order_number) + '</span>' +
|
||||
' <span class="kanban-card__priority badge badge--' + esc(o.priority) + '">' + esc(priorityLabel(o.priority)) + '</span>' +
|
||||
' <span class="kanban-card__priority badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="kanban-card__customer">' + esc(o.customer_name || 'Cliente general') + '</div>' +
|
||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||
'<div class="kanban-card__vehicle">' + esc(o.vehicle_description || o.vehicle_plate || 'Sin vehículo') + '</div>' +
|
||||
'<div class="kanban-card__meta">' +
|
||||
' <span class="kanban-card__mechanic">🔧 ' + esc(o.employee_name || 'Sin asignar') + '</span>' +
|
||||
priceHtml +
|
||||
@@ -355,47 +457,56 @@ var Workshop = (function() {
|
||||
var activeTab = document.querySelector('.so-tabs__btn.is-active');
|
||||
var selectedTab = activeTab ? activeTab.dataset.tab : 'service';
|
||||
|
||||
var mechanicAssignHtml = isMechanic ?
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' +
|
||||
'<input id="mechanicNameInput" class="form-input" style="width:auto;min-width:180px;" value="' + esc(o.mechanic_name || '') + '" placeholder="Nombre del mecánico" />' +
|
||||
' <button class="btn btn--sm btn--secondary" onclick="Workshop.saveMechanicName()">Guardar</button></div>' :
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Mecánico asignado:</span> ' + esc(o.mechanic_name || o.employee_name || 'Sin asignar') + '</div>';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail-header">' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Estatus:</span> <span class="badge badge--' + esc(o.status) + '">' + esc(STATUS_LABELS[o.status] || o.status) + '</span></div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Fecha:</span> ' + fmtDate(o.created_at) + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Registrado por:</span> ' + esc(o.created_by_name || '—') + '</div>' +
|
||||
' <div class="so-detail-header__row"><span class="so-detail__label">Sucursal:</span> ' + esc(o.branch_name || '—') + '</div>' +
|
||||
mechanicAssignHtml +
|
||||
'</div>' +
|
||||
|
||||
(isRestricted ? '' :
|
||||
'<div class="so-detail-info">' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Cliente</span><span class="so-detail__value">' + esc(o.customer_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Taller</span><span class="so-detail__value">' + esc(o.workshop_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Dirección</span><span class="so-detail__value">' + esc(o.customer_address || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Teléfono</span><span class="so-detail__value">' + esc(o.customer_phone || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc((o.vehicle_plate || '—') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vehículo</span><span class="so-detail__value">' + esc(o.vehicle_description || (o.vehicle_plate ? (o.vehicle_plate + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')).trim() : '—')) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Vía de entrega</span><span class="so-detail__value">' + esc(DELIVERY_LABELS[o.delivery_method] || o.delivery_method || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Motociclista</span><span class="so-detail__value">' + esc(o.courier_name || '—') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Factura</span><span class="so-detail__value">' + (o.requires_invoice ? 'Sí requiere' : 'No requiere') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Mecánico</span><span class="so-detail__value">' + esc(o.employee_name || 'Sin asignar') + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Entrega estimada</span><span class="so-detail__value">' + fmtDate(o.estimated_completion) + '</span></div>' +
|
||||
' <div class="so-detail__field"><span class="so-detail__label">Kilometraje entrada</span><span class="so-detail__value">' + fmt(o.mileage_in) + '</span></div>' +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Presupuesto</span><span class="so-detail__value">' + fmtMoney(o.estimated_cost) + '</span></div>') +
|
||||
(hidePrices ? '' : '<div class="so-detail__field"><span class="so-detail__label">Total</span><span class="so-detail__value">' + fmtMoney(o.total) + '</span></div>') +
|
||||
'</div>' +
|
||||
'</div>') +
|
||||
|
||||
(isRestricted ? '' :
|
||||
'<div class="so-tabs">' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'service' ? 'is-active' : '') + '" data-tab="service" onclick="Workshop.switchTab(\'service\')">Orden de servicio</button>' +
|
||||
' <button class="so-tabs__btn ' + (selectedTab === 'articles' ? 'is-active' : '') + '" data-tab="articles" onclick="Workshop.switchTab(\'articles\')">Artículos</button>' +
|
||||
'</div>' +
|
||||
'</div>') +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' ? '' : 'style="display:none;"') + '>' +
|
||||
'<div class="so-tab-panel" id="tab-service" ' + (selectedTab === 'service' || isRestricted ? '' : 'style="display:none;"') + '>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Notas</h3>' +
|
||||
' <div class="so-notes-grid">' +
|
||||
' <label class="form-label">Recepción</label>' +
|
||||
' <textarea id="noteReception" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.reception_notes || '') + '</textarea>' +
|
||||
' <label class="form-label">Diagnóstico</label>' +
|
||||
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
|
||||
' <textarea id="noteDiagnosis" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.diagnosis_notes || '') + '</textarea>' +
|
||||
' <label class="form-label">Reparación</label>' +
|
||||
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
|
||||
' <textarea id="noteRepair" class="form-input" rows="2" ' + (canEdit || isMechanic ? '' : 'readonly') + '>' + esc(o.repair_notes || '') + '</textarea>' +
|
||||
' <label class="form-label">Entrega</label>' +
|
||||
' <textarea id="noteDelivery" class="form-input" rows="2" ' + (canEdit ? '' : 'readonly') + '>' + esc(o.delivery_notes || '') + '</textarea>' +
|
||||
' </div>' +
|
||||
(canEdit ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
|
||||
(canEdit || isMechanic ? ' <button class="btn btn--secondary" style="margin-top:var(--space-2);" onclick="Workshop.saveNotes()">Guardar notas</button>' : '') +
|
||||
' </div>' +
|
||||
' <div class="so-detail__section">' +
|
||||
' <h3>Bitácora</h3>' +
|
||||
@@ -403,16 +514,18 @@ var Workshop = (function() {
|
||||
' </div>' +
|
||||
'</div>' +
|
||||
|
||||
'<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
||||
(isRestricted ? '' : '<div class="so-tab-panel" id="tab-articles" ' + (selectedTab === 'articles' ? '' : 'style="display:none;"') + '>' +
|
||||
renderArticles(o) +
|
||||
'</div>';
|
||||
'</div>');
|
||||
|
||||
document.getElementById('detailBody').innerHTML = html;
|
||||
|
||||
// Footer actions
|
||||
var footer = document.getElementById('detailFooter');
|
||||
var allowedNext = VALID_NEXT[o.status] || [];
|
||||
var statusHtml = canEdit && allowedNext.length ?
|
||||
var allowedNext = (VALID_NEXT[o.status] || []).filter(function(s) {
|
||||
return !isMechanic || ['cotizada','por_autorizar','autorizada','autorizacion_parcial','por_facturar','facturada'].indexOf(s) === -1;
|
||||
});
|
||||
var statusHtml = canChangeStatus && allowedNext.length ?
|
||||
'<div class="so-detail__actions" style="margin-right:auto;">' +
|
||||
' <select class="form-input" id="statusSelect" style="width:auto;">' +
|
||||
'<option value="' + esc(o.status) + '" selected>' + esc(STATUS_LABELS[o.status] || o.status) + '</option>' +
|
||||
@@ -424,10 +537,11 @@ var Workshop = (function() {
|
||||
'<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>' : '') +
|
||||
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">' +
|
||||
(isRestricted ? '' : '<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 === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
|
||||
'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>' : '') +
|
||||
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
|
||||
}
|
||||
|
||||
@@ -439,93 +553,105 @@ var Workshop = (function() {
|
||||
|
||||
function renderBitacora(history) {
|
||||
if (!history.length) return '<p style="color:var(--color-text-muted);">Sin movimientos</p>';
|
||||
var vehicle = currentOrder ? (currentOrder.vehicle_description || (currentOrder.vehicle_plate ? currentOrder.vehicle_plate + ' ' + (currentOrder.vehicle_make || '') + ' ' + (currentOrder.vehicle_model || '') : null)) : null;
|
||||
var rows = history.map(function(h) {
|
||||
return '<tr>' +
|
||||
'<td><span class="badge badge--' + esc(h.new_status) + '">' + esc(STATUS_LABELS[h.new_status] || h.new_status) + '</span></td>' +
|
||||
'<td>' + fmtDate(h.created_at) + '</td>' +
|
||||
'<td>' + esc(h.changed_by_name || '—') + '</td>' +
|
||||
(isMechanic && vehicle ? '<td>' + esc(vehicle) + '</td>' : '') +
|
||||
'<td>' + esc(h.notes || '—') + '</td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th><th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
var vehicleTh = isMechanic && vehicle ? '<th>Vehículo</th>' : '';
|
||||
return '<table class="data-table bitacora-table"><thead><tr><th>Estatus</th><th>Fecha</th><th>Usuario</th>' + vehicleTh + '<th>Observaciones</th></tr></thead><tbody>' + rows + '</tbody></table>';
|
||||
}
|
||||
|
||||
function mechanicName(id) {
|
||||
var e = employees.find(function(x) { return x.id === id; });
|
||||
return e ? e.name : '—';
|
||||
}
|
||||
|
||||
function ensureEmployees() {
|
||||
if (employees && employees.length) return Promise.resolve(employees);
|
||||
return fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
employees = (d.data || d.employees || []);
|
||||
return employees;
|
||||
})
|
||||
.catch(function() { employees = []; return employees; });
|
||||
}
|
||||
|
||||
function populateMechanicSelect(selectId, selectedId) {
|
||||
var sel = document.getElementById(selectId);
|
||||
if (!sel) return;
|
||||
ensureEmployees().then(function() {
|
||||
sel.innerHTML = '<option value="">— Sin asignar —</option>' +
|
||||
employees
|
||||
.filter(function(e) { return e.is_active && (e.role === 'mechanic' || e.role === 'workshop'); })
|
||||
.map(function(e) {
|
||||
return '<option value="' + e.id + '"' + (e.id === selectedId ? ' selected' : '') + '>' + esc(e.name) + '</option>';
|
||||
}).join('');
|
||||
});
|
||||
}
|
||||
|
||||
function renderArticles(o) {
|
||||
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 actionCell = canEdit && it.status !== 'cancelled' ?
|
||||
'<td><button class="btn btn--sm btn--secondary" onclick="event.stopPropagation();Workshop.reserveItem(' + it.id + ')">Reservar</button></td>' : '<td></td>';
|
||||
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' ?
|
||||
'<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>' +
|
||||
'<td>' + fmt(it.quantity) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
||||
mechanicCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(it.status) + '">' + esc(ITEM_STATUS_LABELS[it.status] || it.status) + '</span></td>' +
|
||||
'<td>' + esc(it.observations || '') + '</td>' +
|
||||
actionCell +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var partsHeader = '<tr><th>Concepto</th><th>Cant.</th>' + (hidePrices ? '' : '<th>Precio</th>') + '<th>Estado</th><th></th></tr>';
|
||||
|
||||
var laborRows = (o.labor || []).map(function(l) {
|
||||
var priceCells = hidePrices ? '' : '<td>' + fmtMoney(l.hourly_rate) + '</td><td>' + fmtMoney(l.total_cost) + '</td>';
|
||||
return '<tr>' +
|
||||
'<td>' + esc(l.description) + '</td>' +
|
||||
'<td>' + fmt(l.hours) + '</td>' +
|
||||
priceCells +
|
||||
'<td><span class="badge ' + statusBadgeClass(l.status) + '">' + esc(STATUS_LABELS[l.status] || l.status) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
var laborHeader = '<tr><th>Concepto</th><th>Horas</th>' + (hidePrices ? '' : '<th>Precio/hr</th><th>Total</th>') + '<th>Estado</th></tr>';
|
||||
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 ?
|
||||
'<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:200px;">' +
|
||||
' <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()" />' +
|
||||
' <div id="itemSearchResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:220px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>' +
|
||||
' </div>' +
|
||||
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:80px;" />' +
|
||||
' <input class="form-input" id="newItemQty" type="number" value="1" min="1" style="width:70px;" />' +
|
||||
(hidePrices ? '' : '<input class="form-input" id="newItemPrice" type="number" step="0.01" placeholder="Precio" style="width:90px;" />') +
|
||||
' <select class="form-input" id="newItemMechanic" style="width:auto;"><option value="">— Mecánico —</option></select>' +
|
||||
' <select class="form-input" id="newItemStatus" style="width:auto;">' +
|
||||
Object.keys(ITEM_STATUS_LABELS).map(function(s) { return '<option value="' + s + '">' + esc(ITEM_STATUS_LABELS[s]) + '</option>'; }).join('') +
|
||||
' </select>' +
|
||||
' <input class="form-input" id="newItemObs" placeholder="Observaciones" style="min-width:140px;flex:1;" />' +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addSelectedItem()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var addLabor = canEdit ?
|
||||
'<div style="margin-top:var(--space-3);display:flex;gap:var(--space-2);flex-wrap:wrap;">' +
|
||||
' <select class="form-input" id="laborCatalogSelect"><option value="">Concepto manual</option></select>' +
|
||||
' <input class="form-input" id="laborDesc" placeholder="Descripción" style="flex:1;min-width:160px;" />' +
|
||||
' <input class="form-input" id="laborHours" type="number" step="0.1" placeholder="Hrs" style="width:80px;" />' +
|
||||
(hidePrices ? '' : '<input class="form-input" id="laborRate" type="number" step="0.01" placeholder="$/hr" style="width:100px;" />') +
|
||||
' <button class="btn btn--secondary" onclick="Workshop.addLabor()">Agregar</button>' +
|
||||
'</div>' : '';
|
||||
|
||||
var html =
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Refacciones</h3>' +
|
||||
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + (hidePrices ? 4 : 5) + '" style="text-align:center;">Sin refacciones</td></tr>') + '</tbody></table>' +
|
||||
' <h3>Artículos</h3>' +
|
||||
' <table class="data-table"><thead>' + partsHeader + '</thead><tbody>' + (partsRows || '<tr><td colspan="' + colCount + '" style="text-align:center;">Sin artículos</td></tr>') + '</tbody></table>' +
|
||||
addParts +
|
||||
'</div>' +
|
||||
'<div class="so-detail__section">' +
|
||||
' <h3>Mano de obra</h3>' +
|
||||
' <table class="data-table"><thead>' + laborHeader + '</thead><tbody>' + (laborRows || '<tr><td colspan="' + (hidePrices ? 3 : 5) + '" style="text-align:center;">Sin mano de obra</td></tr>') + '</tbody></table>' +
|
||||
addLabor +
|
||||
'</div>';
|
||||
|
||||
// schedule labor catalog select population after DOM insertion
|
||||
// populate mechanic select after DOM insertion
|
||||
setTimeout(function() {
|
||||
var sel = document.getElementById('laborCatalogSelect');
|
||||
if (!sel || sel.dataset.populated) return;
|
||||
catalog.forEach(function(c) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = JSON.stringify(c);
|
||||
opt.textContent = c.name + (hidePrices ? '' : ' ($' + fmtMoney(c.suggested_hours * c.suggested_rate).replace('$', '') + ')');
|
||||
sel.appendChild(opt);
|
||||
ensureEmployees().then(function() {
|
||||
var sel = document.getElementById('newItemMechanic');
|
||||
if (!sel || sel.dataset.populated) return;
|
||||
employees.forEach(function(e) {
|
||||
if (!e.is_active) return;
|
||||
var opt = document.createElement('option');
|
||||
opt.value = e.id;
|
||||
opt.textContent = e.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
sel.dataset.populated = '1';
|
||||
});
|
||||
sel.dataset.populated = '1';
|
||||
sel.onchange = function() {
|
||||
if (!sel.value) return;
|
||||
var c = JSON.parse(sel.value);
|
||||
document.getElementById('laborDesc').value = c.name;
|
||||
document.getElementById('laborHours').value = c.suggested_hours;
|
||||
if (!hidePrices) document.getElementById('laborRate').value = c.suggested_rate;
|
||||
};
|
||||
}, 0);
|
||||
|
||||
return html;
|
||||
@@ -559,12 +685,9 @@ var Workshop = (function() {
|
||||
async function populateEditOrderModal() {
|
||||
var o = currentOrder;
|
||||
selectedEditCustomer = o.customer_id ? {id: o.customer_id, name: o.customer_name || ''} : null;
|
||||
selectedEditVehicle = o.vehicle_id ? {id: o.vehicle_id, label: (o.vehicle_plate || '') + ' ' + (o.vehicle_make || '') + ' ' + (o.vehicle_model || '')} : null;
|
||||
|
||||
document.getElementById('eoCustomerSearch').value = selectedEditCustomer ? selectedEditCustomer.name : '';
|
||||
document.getElementById('eoCustomerId').value = selectedEditCustomer ? selectedEditCustomer.id : '';
|
||||
document.getElementById('eoVehicleSearch').value = selectedEditVehicle ? selectedEditVehicle.label.trim() : '';
|
||||
document.getElementById('eoVehicleId').value = selectedEditVehicle ? selectedEditVehicle.id : '';
|
||||
|
||||
// Branches
|
||||
var branchSel = document.getElementById('eoBranch');
|
||||
@@ -572,25 +695,26 @@ var Workshop = (function() {
|
||||
return '<option value="' + b.id + '"' + (b.id === o.branch_id ? ' selected' : '') + '>' + esc(b.name) + '</option>';
|
||||
}).join('');
|
||||
|
||||
// Mechanics/employees
|
||||
var mechSel = document.getElementById('eoMechanic');
|
||||
mechSel.innerHTML = '<option value="">— Ninguno —</option>';
|
||||
try {
|
||||
var res = await fetch('/pos/api/config/employees', {headers: headers()});
|
||||
var json = await res.json();
|
||||
(json.data || []).forEach(function(e) {
|
||||
if (!e.is_active) return;
|
||||
mechSel.innerHTML += '<option value="' + e.id + '"' + (e.id === o.employee_id ? ' selected' : '') + '>' + esc(e.name) + '</option>';
|
||||
});
|
||||
} catch (e) {}
|
||||
document.getElementById('eoWorkshopName').value = o.workshop_name || '';
|
||||
document.getElementById('eoCustomerAddress').value = o.customer_address || '';
|
||||
document.getElementById('eoCustomerPhone').value = o.customer_phone || '';
|
||||
document.getElementById('eoVehicleDescription').value = o.vehicle_description || '';
|
||||
|
||||
// Delivery / courier
|
||||
var deliverySel = document.getElementById('eoDelivery');
|
||||
deliverySel.value = o.delivery_method || '';
|
||||
var courierField = document.getElementById('eoCourierField');
|
||||
var courierSel = document.getElementById('eoCourier');
|
||||
courierSel.innerHTML = couriers.map(function(c) {
|
||||
return '<option value="' + c.id + '"' + (c.id === o.courier_id ? ' selected' : '') + '>' + esc(c.name) + '</option>';
|
||||
}).join('');
|
||||
courierField.style.display = (o.delivery_method === 'delivery' || o.delivery_method === 'courier') ? 'block' : 'none';
|
||||
|
||||
document.getElementById('eoPriority').value = o.priority || 'normal';
|
||||
document.getElementById('eoFuelLevel').value = o.fuel_level || '';
|
||||
document.getElementById('eoMileageIn').value = o.mileage_in != null ? o.mileage_in : '';
|
||||
document.getElementById('eoMileageOut').value = o.mileage_out != null ? o.mileage_out : '';
|
||||
document.getElementById('eoEstimatedCompletion').value = toDatetimeLocal(o.estimated_completion);
|
||||
document.getElementById('eoEstimatedCost').value = o.estimated_cost != null ? o.estimated_cost : '';
|
||||
populateMechanicSelect('eoMechanic', o.employee_id || null);
|
||||
document.getElementById('eoMechanicName').value = o.mechanic_name || '';
|
||||
document.getElementById('eoNotes').value = o.reception_notes || '';
|
||||
document.getElementById('eoRequiresInvoice').checked = !!o.requires_invoice;
|
||||
}
|
||||
|
||||
function hideEditSearchResults(type) {
|
||||
@@ -638,36 +762,9 @@ var Workshop = (function() {
|
||||
}
|
||||
|
||||
function searchVehiclesForSO() {
|
||||
var input = document.getElementById('eoVehicleSearch');
|
||||
// Fleet module removed; vehicle is free-text in the taller flow.
|
||||
var box = document.getElementById('eoVehicleResults');
|
||||
var q = input.value.trim();
|
||||
selectedEditVehicle = null;
|
||||
document.getElementById('eoVehicleId').value = '';
|
||||
if (!q || q.length < 2) {
|
||||
box.style.display = 'none'; box.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
clearTimeout(vehicleSearchTimer);
|
||||
vehicleSearchTimer = setTimeout(function() {
|
||||
fetch('/pos/api/fleet/vehicles?q=' + encodeURIComponent(q) + '&per_page=20&active_only=true', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var items = d.data || [];
|
||||
if (!items.length) {
|
||||
box.innerHTML = '<div style="padding:var(--space-2);color:var(--color-text-muted);">Sin resultados</div>';
|
||||
box.style.display = 'block'; return;
|
||||
}
|
||||
box.innerHTML = items.map(function(v) {
|
||||
var label = (v.plate || '') + ' · ' + (v.make || '') + ' ' + (v.model || '');
|
||||
return '<div class="so-search-result" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectEditVehicle(' + v.id + ', \'' + escJs(label) + '\')">' +
|
||||
'<strong>' + esc(v.plate || '') + '</strong>' +
|
||||
'<small>' + esc(v.make || '') + ' ' + esc(v.model || '') + ' · ' + esc(v.owner_name || '') + '</small>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
box.style.display = 'block';
|
||||
})
|
||||
.catch(function() { box.style.display = 'none'; });
|
||||
}, 250);
|
||||
if (box) { box.style.display = 'none'; box.innerHTML = ''; }
|
||||
}
|
||||
|
||||
function selectEditVehicle(id, label) {
|
||||
@@ -680,19 +777,22 @@ var Workshop = (function() {
|
||||
async function saveOrderChanges() {
|
||||
if (!currentOrderId) return;
|
||||
var customerId = selectedEditCustomer ? selectedEditCustomer.id : parseInt(document.getElementById('eoCustomerId').value, 10) || null;
|
||||
var vehicleId = selectedEditVehicle ? selectedEditVehicle.id : parseInt(document.getElementById('eoVehicleId').value, 10) || null;
|
||||
var delivery = document.getElementById('eoDelivery').value;
|
||||
var mechanicVal = document.getElementById('eoMechanic').value;
|
||||
var payload = {
|
||||
customer_id: customerId,
|
||||
vehicle_id: vehicleId,
|
||||
branch_id: parseInt(document.getElementById('eoBranch').value, 10) || null,
|
||||
employee_id: parseInt(document.getElementById('eoMechanic').value, 10) || null,
|
||||
priority: document.getElementById('eoPriority').value,
|
||||
fuel_level: document.getElementById('eoFuelLevel').value || null,
|
||||
mileage_in: document.getElementById('eoMileageIn').value ? parseInt(document.getElementById('eoMileageIn').value, 10) : null,
|
||||
mileage_out: document.getElementById('eoMileageOut').value ? parseInt(document.getElementById('eoMileageOut').value, 10) : null,
|
||||
estimated_completion: document.getElementById('eoEstimatedCompletion').value || null,
|
||||
workshop_name: document.getElementById('eoWorkshopName').value.trim() || null,
|
||||
customer_address: document.getElementById('eoCustomerAddress').value.trim() || null,
|
||||
customer_phone: document.getElementById('eoCustomerPhone').value.trim() || null,
|
||||
vehicle_description: document.getElementById('eoVehicleDescription').value.trim() || null,
|
||||
delivery_method: delivery || null,
|
||||
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('eoCourier').value, 10) || null) : null,
|
||||
estimated_cost: document.getElementById('eoEstimatedCost').value ? parseFloat(document.getElementById('eoEstimatedCost').value) : null,
|
||||
reception_notes: document.getElementById('eoNotes').value
|
||||
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
|
||||
mechanic_name: document.getElementById('eoMechanicName').value.trim() || null,
|
||||
reception_notes: document.getElementById('eoNotes').value,
|
||||
requires_invoice: document.getElementById('eoRequiresInvoice').checked
|
||||
};
|
||||
try {
|
||||
await api('PUT', '/' + currentOrderId, payload);
|
||||
@@ -832,8 +932,11 @@ var Workshop = (function() {
|
||||
opt.textContent = label.trim();
|
||||
sel.appendChild(opt);
|
||||
sel.value = res.id;
|
||||
document.getElementById('noWorkshopName').value = name;
|
||||
document.getElementById('noCustomerPhone').value = payload.phone || '';
|
||||
document.getElementById('noCustomerAddress').value = payload.address || '';
|
||||
if (typeof customers !== 'undefined') {
|
||||
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc});
|
||||
customers.push({id: res.id, name: name, phone: payload.phone, rfc: payload.rfc, address: payload.address});
|
||||
}
|
||||
} else {
|
||||
selectedEditCustomer = {id: res.id, name: name};
|
||||
@@ -867,25 +970,46 @@ var Workshop = (function() {
|
||||
|
||||
function saveNotes() {
|
||||
if (!currentOrderId) return;
|
||||
var payload = {
|
||||
reception_notes: document.getElementById('noteReception').value,
|
||||
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
||||
repair_notes: document.getElementById('noteRepair').value,
|
||||
delivery_notes: document.getElementById('noteDelivery').value
|
||||
};
|
||||
var payload;
|
||||
if (isMechanic) {
|
||||
payload = {
|
||||
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
||||
repair_notes: document.getElementById('noteRepair').value
|
||||
};
|
||||
} else {
|
||||
payload = {
|
||||
reception_notes: document.getElementById('noteReception').value,
|
||||
diagnosis_notes: document.getElementById('noteDiagnosis').value,
|
||||
repair_notes: document.getElementById('noteRepair').value,
|
||||
delivery_notes: document.getElementById('noteDelivery').value
|
||||
};
|
||||
}
|
||||
api('PUT', '/' + currentOrderId, payload)
|
||||
.then(function() {
|
||||
alert('Notas guardadas');
|
||||
if (currentOrder) {
|
||||
currentOrder.reception_notes = payload.reception_notes;
|
||||
currentOrder.diagnosis_notes = payload.diagnosis_notes;
|
||||
currentOrder.repair_notes = payload.repair_notes;
|
||||
currentOrder.delivery_notes = payload.delivery_notes;
|
||||
if ('reception_notes' in payload) currentOrder.reception_notes = payload.reception_notes;
|
||||
if ('diagnosis_notes' in payload) currentOrder.diagnosis_notes = payload.diagnosis_notes;
|
||||
if ('repair_notes' in payload) currentOrder.repair_notes = payload.repair_notes;
|
||||
if ('delivery_notes' in payload) currentOrder.delivery_notes = payload.delivery_notes;
|
||||
}
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function saveMechanicName() {
|
||||
if (!currentOrderId) return;
|
||||
var input = document.getElementById('mechanicNameInput');
|
||||
if (!input) return;
|
||||
var payload = { mechanic_name: input.value.trim() || null };
|
||||
api('PUT', '/' + currentOrderId, payload)
|
||||
.then(function() {
|
||||
alert('Mecánico asignado guardado');
|
||||
if (currentOrder) currentOrder.mechanic_name = payload.mechanic_name;
|
||||
})
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function reserveItem(itemId) {
|
||||
api('POST', '/' + currentOrderId + '/items/' + itemId + '/reserve', {})
|
||||
.then(function() {
|
||||
@@ -923,10 +1047,12 @@ var Workshop = (function() {
|
||||
box.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
var orderTier = currentOrder ? (currentOrder.customer_price_tier || 1) : 1;
|
||||
box.innerHTML = items.map(function(it) {
|
||||
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + (it.price_1 || 0) + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
|
||||
var price = priceForTier(it.price_1, orderTier);
|
||||
return '<div class="so-search-result" data-id="' + it.id + '" data-name="' + esc(it.name) + '" data-part="' + esc(it.part_number || '') + '" data-price="' + price + '" data-cost="' + (it.cost || 0) + '" style="padding:var(--space-2);cursor:pointer;border-bottom:1px solid var(--color-border);" onclick="Workshop.selectInventoryItem(' + it.id + ')">' +
|
||||
'<div><strong>' + esc(it.name) + '</strong></div>' +
|
||||
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(it.price_1)) + '</small>' +
|
||||
'<small>' + esc(it.part_number || '') + ' · ' + esc(it.brand || '') + ' · Stock: ' + fmt(it.stock) + (hidePrices ? '' : ' · ' + fmtMoney(price)) + '</small>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
box.style.display = 'block';
|
||||
@@ -954,20 +1080,28 @@ var Workshop = (function() {
|
||||
|
||||
function addSelectedItem() {
|
||||
if (!currentOrderId) return;
|
||||
var qty = parseInt(document.getElementById('newItemQty').value, 10) || 1;
|
||||
var qty = parseFloat(document.getElementById('newItemQty').value) || 1;
|
||||
var status = document.getElementById('newItemStatus').value || 'por_revisar';
|
||||
var mechanicId = parseInt(document.getElementById('newItemMechanic').value, 10) || null;
|
||||
var observations = document.getElementById('newItemObs').value.trim();
|
||||
if (selectedInventoryItem) {
|
||||
var price = hidePrices ? selectedInventoryItem.unit_price : (parseFloat(document.getElementById('newItemPrice').value) || selectedInventoryItem.unit_price || 0);
|
||||
api('POST', '/' + currentOrderId + '/items', {
|
||||
inventory_id: selectedInventoryItem.id,
|
||||
part_number: selectedInventoryItem.part_number,
|
||||
name: selectedInventoryItem.name,
|
||||
quantity: qty,
|
||||
unit_cost: selectedInventoryItem.unit_cost,
|
||||
unit_price: selectedInventoryItem.unit_price,
|
||||
status: 'pending'
|
||||
unit_price: price,
|
||||
status: status,
|
||||
mechanic_id: mechanicId,
|
||||
observations: observations
|
||||
}).then(function() {
|
||||
selectedInventoryItem = null;
|
||||
document.getElementById('newItemSearch').value = '';
|
||||
document.getElementById('newItemQty').value = '1';
|
||||
if (!hidePrices) document.getElementById('newItemPrice').value = '';
|
||||
document.getElementById('newItemObs').value = '';
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
return;
|
||||
@@ -975,19 +1109,36 @@ var Workshop = (function() {
|
||||
// Fallback: manual placeholder (no inventory link)
|
||||
var name = document.getElementById('newItemSearch').value.trim();
|
||||
if (!name) return;
|
||||
if (!confirm('No seleccionaste una refacción del inventario. ¿Agregar como concepto manual? No podrá reservarse.')) return;
|
||||
var manualPrice = hidePrices ? 0 : (parseFloat(document.getElementById('newItemPrice').value) || 0);
|
||||
api('POST', '/' + currentOrderId + '/items', {
|
||||
name: name,
|
||||
quantity: qty,
|
||||
unit_price: 0,
|
||||
status: 'pending'
|
||||
unit_price: manualPrice,
|
||||
status: status,
|
||||
mechanic_id: mechanicId,
|
||||
observations: observations
|
||||
}).then(function() {
|
||||
document.getElementById('newItemSearch').value = '';
|
||||
document.getElementById('newItemQty').value = '1';
|
||||
if (!hidePrices) document.getElementById('newItemPrice').value = '';
|
||||
document.getElementById('newItemObs').value = '';
|
||||
openDetail(currentOrderId);
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function editItemInline(itemId) {
|
||||
if (!currentOrder || !currentOrder.items) return;
|
||||
var it = currentOrder.items.find(function(x) { return x.id === itemId; });
|
||||
if (!it) return;
|
||||
var newStatus = prompt('Nuevo estado (' + Object.keys(ITEM_STATUS_LABELS).join(', ') + '):', it.status);
|
||||
if (!newStatus || !ITEM_STATUS_LABELS[newStatus]) return;
|
||||
var newObs = prompt('Observaciones:', it.observations || '');
|
||||
var payload = {status: newStatus, observations: newObs != null ? newObs : it.observations};
|
||||
api('PUT', '/items/' + itemId, payload)
|
||||
.then(function() { openDetail(currentOrderId); })
|
||||
.catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function addLabor() {
|
||||
var desc = document.getElementById('laborDesc').value.trim();
|
||||
var hours = parseFloat(document.getElementById('laborHours').value) || 0;
|
||||
@@ -1020,6 +1171,18 @@ var Workshop = (function() {
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function convertToRemission() {
|
||||
if (!currentOrderId) return;
|
||||
if (!confirm('¿Generar nota de remisión desde esta orden? Se reservarán las refacciones y quedará pendiente de cobro.')) return;
|
||||
api('POST', '/' + currentOrderId + '/convert-to-remission', {})
|
||||
.then(function(r) {
|
||||
alert('Nota de remisión creada: #' + r.sale_id + ' Total: ' + fmtMoney(r.total));
|
||||
closeDetailModal();
|
||||
loadSummary();
|
||||
loadOrders();
|
||||
}).catch(function(e) { alert('Error: ' + e.message); });
|
||||
}
|
||||
|
||||
function deleteOrder() {
|
||||
if (!currentOrderId) return;
|
||||
if (!confirm('¿Eliminar esta orden de servicio? Se ocultará del taller pero las reservas de inventario y la venta asociada (si existe) no se verán afectadas.')) return;
|
||||
@@ -1058,10 +1221,22 @@ var Workshop = (function() {
|
||||
// ─── New order ───
|
||||
|
||||
function openNewOrderModal() {
|
||||
populateSelect('noBranch', branches, function(b) { return {value: b.id, text: b.name}; });
|
||||
populateSelect('noCustomer', customers, function(c) { return {value: c.id, text: c.name + ' (' + (c.phone || '') + ')'}; });
|
||||
populateSelect('noVehicle', vehicles, function(v) { return {value: v.id, text: v.plate + ' ' + v.make + ' ' + v.model}; });
|
||||
populateSelect('noMechanic', employees, function(e) { return {value: e.id, text: e.name}; });
|
||||
populateSelect('noCourier', couriers, function(c) { return {value: c.id, text: c.name}; });
|
||||
populateMechanicSelect('noMechanic', null);
|
||||
document.getElementById('noMechanicName').value = '';
|
||||
document.getElementById('noEstimatedCost').value = '';
|
||||
var noCustomer = document.getElementById('noCustomer');
|
||||
noCustomer.onchange = function() {
|
||||
var cid = parseInt(noCustomer.value, 10);
|
||||
var c = customers.find(function(x) { return x.id === cid; });
|
||||
if (c) {
|
||||
document.getElementById('noWorkshopName').value = c.name || '';
|
||||
document.getElementById('noCustomerPhone').value = c.phone || '';
|
||||
document.getElementById('noCustomerAddress').value = c.address || '';
|
||||
}
|
||||
};
|
||||
document.getElementById('newOrderModal').classList.add('is-open');
|
||||
}
|
||||
|
||||
@@ -1074,19 +1249,27 @@ var Workshop = (function() {
|
||||
|
||||
function submitNewOrder() {
|
||||
var customerId = document.getElementById('noCustomer').value;
|
||||
var branchId = document.getElementById('noBranch').value;
|
||||
if (!customerId) return alert('Selecciona un cliente');
|
||||
if (!branchId) return alert('Selecciona una sucursal');
|
||||
var delivery = document.getElementById('noDelivery').value;
|
||||
var mechanicVal = document.getElementById('noMechanic').value;
|
||||
var estimatedCostVal = document.getElementById('noEstimatedCost').value;
|
||||
var payload = {
|
||||
branch_id: parseInt(branchId, 10),
|
||||
customer_id: parseInt(customerId, 10),
|
||||
vehicle_id: parseInt(document.getElementById('noVehicle').value, 10) || null,
|
||||
employee_id: parseInt(document.getElementById('noMechanic').value, 10) || null,
|
||||
priority: document.getElementById('noPriority').value,
|
||||
estimated_completion: document.getElementById('noEstimatedCompletion').value || null,
|
||||
mileage_in: parseInt(document.getElementById('noMileage').value, 10) || null,
|
||||
workshop_name: document.getElementById('noWorkshopName').value.trim() || null,
|
||||
customer_address: document.getElementById('noCustomerAddress').value.trim() || null,
|
||||
customer_phone: document.getElementById('noCustomerPhone').value.trim() || null,
|
||||
vehicle_description: document.getElementById('noVehicleDescription').value.trim() || null,
|
||||
reception_notes: document.getElementById('noNotes').value,
|
||||
delivery_method: delivery || null,
|
||||
courier_id: delivery === 'courier' ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
||||
is_direct: document.getElementById('noDirect').checked
|
||||
courier_id: (delivery === 'delivery' || delivery === 'courier') ? (parseInt(document.getElementById('noCourier').value, 10) || null) : null,
|
||||
employee_id: mechanicVal ? parseInt(mechanicVal, 10) : null,
|
||||
mechanic_name: document.getElementById('noMechanicName').value.trim() || null,
|
||||
estimated_cost: estimatedCostVal ? parseFloat(estimatedCostVal) : null,
|
||||
is_direct: document.getElementById('noDirect').checked,
|
||||
requires_invoice: document.getElementById('noRequiresInvoice').checked
|
||||
};
|
||||
api('POST', '', payload).then(function() {
|
||||
closeNewOrderModal();
|
||||
@@ -1168,18 +1351,13 @@ var Workshop = (function() {
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { customers = (d.data || d.customers || []); })
|
||||
.catch(function() {});
|
||||
// Vehicles
|
||||
fetch('/pos/api/fleet/vehicles?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { vehicles = (d.data || []); })
|
||||
.catch(function() { vehicles = []; });
|
||||
// Employees
|
||||
fetch('/pos/api/config/employees?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) { employees = (d.data || d.employees || []); })
|
||||
.catch(function() { employees = []; });
|
||||
// Couriers
|
||||
fetch('/pos/api/couriers?per_page=500', {headers: headers()})
|
||||
fetch('/pos/api/logistics/couriers?per_page=500', {headers: headers()})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
couriers = (d.data || d.couriers || []);
|
||||
@@ -1192,6 +1370,11 @@ var Workshop = (function() {
|
||||
.then(function(d) {
|
||||
branches = (d.data || []);
|
||||
populateBranchFilter();
|
||||
var noBranch = document.getElementById('noBranch');
|
||||
var userBranch = (window.POS_USER || {}).branch_id;
|
||||
if (noBranch && userBranch && branches.some(function(b) { return b.id == userBranch; })) {
|
||||
noBranch.value = userBranch;
|
||||
}
|
||||
})
|
||||
.catch(function() { branches = []; });
|
||||
}
|
||||
@@ -1235,11 +1418,14 @@ var Workshop = (function() {
|
||||
changeStatus: changeStatus,
|
||||
reserveItem: reserveItem,
|
||||
saveNotes: saveNotes,
|
||||
saveMechanicName: saveMechanicName,
|
||||
searchItemsForSO: searchItemsForSO,
|
||||
selectInventoryItem: selectInventoryItem,
|
||||
addSelectedItem: addSelectedItem,
|
||||
editItemInline: editItemInline,
|
||||
addLabor: addLabor,
|
||||
convertToSale: convertToSale,
|
||||
convertToRemission: convertToRemission,
|
||||
deleteOrder: deleteOrder,
|
||||
printOrder: printOrder,
|
||||
openNewOrderModal: openNewOrderModal,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// The fetch handler normalizes static asset URLs (strips ?v= query strings)
|
||||
// so templates can use cache-busting query params freely.
|
||||
|
||||
const VERSION = 33;
|
||||
const VERSION = 40;
|
||||
const CACHE_NAME = 'nexus-pos-v' + VERSION;
|
||||
|
||||
const APP_SHELL = [
|
||||
|
||||
@@ -492,11 +492,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/accounting.v9.js?v=33" 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>
|
||||
|
||||
@@ -314,12 +314,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/catalog.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
@@ -15,7 +15,39 @@
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/config.css?v=33"></head>
|
||||
<link rel="stylesheet" href="/pos/static/css/config.css?v=34">
|
||||
<style>
|
||||
.cfg-tabs {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.cfg-tab-btn {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-body-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.cfg-tab-btn:hover { background: var(--color-surface-3); }
|
||||
.cfg-tab-btn.active {
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.settings-section[data-tab] { display: none !important; }
|
||||
.settings-section[data-tab].active { display: block !important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@@ -138,11 +170,20 @@
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="content-scroll">
|
||||
<!-- Config tabs -->
|
||||
<div class="cfg-tabs" id="configTabs">
|
||||
<button class="cfg-tab-btn active" data-tab="general" onclick="Config.switchTab('general')">General</button>
|
||||
<button class="cfg-tab-btn" data-tab="fiscal" onclick="Config.switchTab('fiscal')">Fiscal</button>
|
||||
<button class="cfg-tab-btn" data-tab="catalog" onclick="Config.switchTab('catalog')">Catálogo</button>
|
||||
<button class="cfg-tab-btn" data-tab="employees" onclick="Config.switchTab('employees')">Empleados</button>
|
||||
<button class="cfg-tab-btn cfg-tab-btn--permissions" data-tab="permissions" onclick="Config.switchTab('permissions')" style="display:none;">Permisos</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ===============================================================
|
||||
SECTION 1: APARIENCIA / TEMA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
@@ -205,7 +246,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 2: DATOS DE LA EMPRESA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>
|
||||
@@ -256,7 +297,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 3: PERSONALIZACIÓN DE TICKET
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
||||
@@ -328,7 +369,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 4: MÓDULOS E INTEGRACIONES
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
|
||||
@@ -380,6 +421,16 @@
|
||||
<span class="toggle__slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<div class="toggle-row__info">
|
||||
<span class="toggle-row__label">Notas de remisión en mostrador</span>
|
||||
<span class="toggle-row__desc">Permite generar notas de remisión desde el POS para cobrar posteriormente en caja</span>
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input type="checkbox" id="cfg-module-counter-remission" />
|
||||
<span class="toggle__slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div style="margin-top:var(--space-4);text-align:right;">
|
||||
<button class="btn btn--primary" onclick="Config.saveModules()">Guardar módulos</button>
|
||||
</div>
|
||||
@@ -389,13 +440,13 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 4: USUARIOS Y PERMISOS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="employees">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-section__title">Usuarios y Permisos</div>
|
||||
<div class="settings-section__title">Empleados</div>
|
||||
<div class="settings-section__desc">Gestiona quién accede al sistema y qué puede hacer</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -432,7 +483,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 4: IMPRESORAS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<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>
|
||||
@@ -458,7 +509,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 5: SUCURSALES
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="fiscal">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>
|
||||
@@ -477,7 +528,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 6: PARÁMETROS FISCALES
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="fiscal">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
@@ -613,7 +664,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 7: PREFERENCIAS DEL SISTEMA
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="general">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
@@ -691,7 +742,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 8: VEHICLE COMPATIBILITY SOURCE
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="catalog">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
@@ -724,7 +775,7 @@
|
||||
<!-- ===============================================================
|
||||
SECTION 9: MARCAS DE PARTES PERMITIDAS
|
||||
=============================================================== -->
|
||||
<div class="settings-section">
|
||||
<div class="settings-section" data-tab="catalog">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><polyline points="2 17 12 22 22 17"/></svg>
|
||||
@@ -785,6 +836,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- ===============================================================
|
||||
SECTION: PERMISOS POR ROL
|
||||
=============================================================== -->
|
||||
<div class="settings-section" data-tab="permissions">
|
||||
<div class="settings-section__header">
|
||||
<div class="settings-section__icon">
|
||||
<svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<div class="settings-section__title">Permisos por Rol</div>
|
||||
<div class="settings-section__desc">Define los permisos predeterminados para cada rol del sistema</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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()">
|
||||
<option value="">Selecciona un rol</option>
|
||||
<option value="admin">Administrador</option>
|
||||
<option value="cashier">Cajero</option>
|
||||
<option value="counter">Mostrador</option>
|
||||
<option value="warehouse">Almacén</option>
|
||||
<option value="accountant">Contador</option>
|
||||
<option value="workshop">Taller</option>
|
||||
<option value="mechanic">Mecánico</option>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
</div><!-- /content-scroll -->
|
||||
</main>
|
||||
</div><!-- /app-shell -->
|
||||
@@ -893,6 +985,7 @@
|
||||
<option value="">-- Seleccionar --</option>
|
||||
<option value="admin">Administrador</option>
|
||||
<option value="cashier">Cajero</option>
|
||||
<option value="counter">Mostrador</option>
|
||||
<option value="warehouse">Almacenista</option>
|
||||
<option value="accountant">Contador</option>
|
||||
<option value="workshop">Taller</option>
|
||||
@@ -922,13 +1015,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=35" defer></script>
|
||||
<script src="/pos/static/js/config.js?v=39" 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>
|
||||
|
||||
@@ -650,11 +650,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/virtual-scroll.js" defer></script>
|
||||
<script src="/pos/static/js/customers.js?v=34" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
|
||||
@@ -158,6 +158,16 @@
|
||||
Ventas Históricas
|
||||
</a>
|
||||
|
||||
<a href="/pos/remission-notes" class="nav-link">
|
||||
<span class="nav-link__icon">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 3h12v10H2z"/>
|
||||
<path d="M5 7h6M5 10h4" stroke-width="1.2"/>
|
||||
</svg>
|
||||
</span>
|
||||
Notas de Remisión
|
||||
</a>
|
||||
|
||||
<div class="sidebar__section-label">Gestión</div>
|
||||
|
||||
<a href="/pos/marketplace" class="nav-link">
|
||||
@@ -595,15 +605,15 @@
|
||||
|
||||
|
||||
<script src="/pos/static/js/chart.umd.min.js" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/dashboard-stats.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/dashboard.js?v=33" 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>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js?v=34',{scope:'/pos/'});}</script>
|
||||
<script src="/pos/static/js/pwa-install.js" defer></script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
|
||||
@@ -148,12 +148,12 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/diagrams.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>
|
||||
|
||||
@@ -302,11 +302,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/fleet.js" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
<h1 class="page-header__title">Inventario</h1>
|
||||
</div>
|
||||
<div class="page-header__actions">
|
||||
<button class="btn btn--ghost" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||
<button class="btn btn--ghost" id="btnHeaderImport" onclick="document.getElementById('bulkImportModal').classList.add('is-open')">
|
||||
<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Importar CSV
|
||||
</button>
|
||||
@@ -204,7 +204,7 @@
|
||||
<svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-.38-4.93"/></svg>
|
||||
Sincronizar
|
||||
</button>
|
||||
<button class="btn btn--primary" onclick="showCreateModal()">
|
||||
<button class="btn btn--primary" id="btnHeaderNewProduct" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
@@ -336,7 +336,7 @@
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><line x1="12" y1="6" x2="12" y2="12"/><line x1="16.24" y1="16.24" x2="12" y2="12"/></svg>
|
||||
<span id="tierDiscountBadge">Taller -15% · Mayoreo -25%</span>
|
||||
</button>
|
||||
<button class="btn btn--primary btn--sm" onclick="showCreateModal()">
|
||||
<button class="btn btn--primary btn--sm" id="btnStockNewProduct" onclick="showCreateModal()">
|
||||
<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Nuevo Producto
|
||||
</button>
|
||||
@@ -1018,6 +1018,9 @@
|
||||
<button class="inv-modal__close" onclick="document.getElementById('bulkImportModal').classList.remove('is-open')">×</button>
|
||||
</div>
|
||||
<div class="inv-modal__body">
|
||||
<div style="margin-bottom:12px;">
|
||||
<button class="btn btn--ghost btn--sm" onclick="downloadBulkImportTemplate()" type="button">📥 Descargar plantilla CSV</button>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block;margin-bottom:4px;font-size:var(--text-caption);color:var(--color-text-muted);">Archivo CSV o Excel</label>
|
||||
<input type="file" id="bulkImportFile" accept=".csv,.xlsx,.xls" style="width:100%;padding:8px;border:1px dashed var(--color-border);border-radius:6px;background:var(--color-surface);color:var(--color-text);" />
|
||||
@@ -1039,8 +1042,8 @@
|
||||
</div>
|
||||
<div style="font-size:var(--text-caption);color:var(--color-text-muted);background:var(--color-surface);padding:10px;border-radius:6px;">
|
||||
<strong>Columnas esperadas:</strong>
|
||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, location, description, category, make, model, year, engine, engine_code</code>
|
||||
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, ubicacion, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||
<code style="display:block;margin-top:4px;word-break:break-all;">sku, name, brand, price, stock, cost, sku_secondary, description, category, make, model, year, engine, engine_code</code>
|
||||
<span style="display:block;margin-top:4px;">También se aceptan sinónimos en español: <em>numero_de_parte, nombre, marca, precio, cantidad, costo, sku_secundario, categoria, fabricante, modelo, anio, motor, codigo_motor</em></span>
|
||||
</div>
|
||||
<div id="bulkImportResult" style="margin-top:12px;display:none;"></div>
|
||||
</div>
|
||||
@@ -1058,13 +1061,13 @@
|
||||
<button class="banner__dismiss" onclick="document.getElementById('offlineBanner').style.display='none'" aria-label="Cerrar">×</button>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/virtual-scroll.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=34" defer></script>
|
||||
<script src="/pos/static/js/inventory.js?v=37" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" 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>
|
||||
|
||||
@@ -1063,11 +1063,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/invoicing.js?v=33" 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>
|
||||
|
||||
@@ -387,7 +387,7 @@
|
||||
|
||||
setTimeout(function() {
|
||||
var role = (result.data.employee.role || '').toLowerCase();
|
||||
if (role === 'workshop' || role === 'mechanic') {
|
||||
if (role === 'workshop' || role === 'mechanic' || role === 'counter') {
|
||||
window.location.href = '/pos/workshop';
|
||||
} else {
|
||||
window.location.href = '/pos/catalog';
|
||||
|
||||
@@ -342,11 +342,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/marketplace_external.js?v=33" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
</body>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
<script src="/pos/static/js/native-bridge.js" defer></script>
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=33"></head>
|
||||
<link rel="stylesheet" href="/pos/static/css/pos.css?v=34"></head>
|
||||
|
||||
<body class="pos-shell" id="appBody">
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn-change-customer" onclick="POS.clearCustomer()" aria-label="Cambiar cliente">Cambiar</button>
|
||||
<button class="btn-change-customer" onclick="POS.creditSale()" aria-label="Venta a credito" style="margin-left:4px;background:var(--color-primary);color:#fff;border-color:var(--color-primary);">Crédito</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -199,15 +198,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Courier selector for counter remission notes -->
|
||||
<div class="form-field" id="courierSelectField" style="display:none; margin-bottom: 12px;">
|
||||
<label class="form-label" for="remissionCourier">Repartidor</label>
|
||||
<select class="form-input" id="remissionCourier">
|
||||
<option value="">-- Sin repartidor --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- COBRAR Button -->
|
||||
<button class="btn-cobrar" id="btnCobrar" onclick="POS.checkout()" aria-label="Procesar cobro">
|
||||
<span>COBRAR</span>
|
||||
</button>
|
||||
|
||||
<!-- Counter remission button (shown for counter role when feature enabled) -->
|
||||
<button class="btn-cobrar" id="btnRemission" onclick="POS.createRemissionNote()" aria-label="Generar nota de remision" style="display:none;background:var(--color-secondary);">
|
||||
<span>NOTA DE REMISIÓN</span>
|
||||
</button>
|
||||
|
||||
<!-- Secondary Actions -->
|
||||
<div class="secondary-actions" role="toolbar" aria-label="Acciones secundarias">
|
||||
<button class="btn-secondary-action" onclick="POS.modifyPrice()" title="Modificar precio">Mod.Precio</button>
|
||||
<button class="btn-secondary-action" onclick="POS.saveQuotation()" title="Cotizacion (F4)">Cotizar</button>
|
||||
<button class="btn-secondary-action" id="btnPayRemission" onclick="POS.openPayRemissionModal()" title="Cobrar nota de remision" style="display:none;">Cobrar Nota</button>
|
||||
<button class="btn-secondary-action" onclick="POS.createLayaway()" title="Apartado (requiere cliente)">Apartado</button>
|
||||
<button class="btn-secondary-action" onclick="POS.createServiceOrder()" title="Orden de servicio (F7)">Orden Taller</button>
|
||||
<button class="btn-secondary-action" onclick="POS.showLastSale()" title="Ultima venta (F5)">Ult.Venta</button>
|
||||
@@ -295,6 +308,15 @@
|
||||
<button class="pago-tab" data-method="mixto" onclick="POS.selectPaymentMethod('mixto', this)">
|
||||
Mixto
|
||||
</button>
|
||||
<button class="pago-tab" data-method="credito" onclick="POS.selectPaymentMethod('credito', this)">
|
||||
Crédito
|
||||
</button>
|
||||
<button class="pago-tab" data-method="cheque" onclick="POS.selectPaymentMethod('cheque', this)">
|
||||
Cheque
|
||||
</button>
|
||||
<button class="pago-tab" data-method="pendiente" onclick="POS.selectPaymentMethod('pendiente', this)">
|
||||
Pendiente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Efectivo -->
|
||||
@@ -358,6 +380,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Crédito -->
|
||||
<div class="tab-content" id="creditPayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Venta a crédito</label>
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">Se registrará como venta a crédito para el cliente seleccionado y se agregará a su cuenta.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Cheque -->
|
||||
<div class="tab-content" id="chequePayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Monto</label>
|
||||
<input type="text" class="form-input form-input-lg" id="chequeAmount" readonly />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">No. de cheque / referencia</label>
|
||||
<input type="text" class="form-input" id="chequeRef" placeholder="No. de cheque" />
|
||||
</div>
|
||||
<div class="form-hint">Verificar que el cheque sea válido antes de confirmar</div>
|
||||
</div>
|
||||
|
||||
<!-- TAB: Pendiente -->
|
||||
<div class="tab-content" id="pendingPayment">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Pago pendiente</label>
|
||||
<p style="color:var(--color-text-muted);font-size:var(--text-body-sm);">La venta quedará pendiente de pago. Podrá cobrarse más tarde desde el listado de ventas o notas de remisión.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CFDI Checkbox -->
|
||||
<div class="cfdi-check">
|
||||
<input type="checkbox" id="cfdiCheck" />
|
||||
@@ -373,6 +424,68 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
PAY REMISSION NOTE MODAL
|
||||
================================================================ -->
|
||||
<div class="modal-overlay" id="payRemissionModal">
|
||||
<div class="modal-pago">
|
||||
<div class="modal-header">
|
||||
<h3>Cobrar Nota de Remisión</h3>
|
||||
<button class="modal-close" onclick="POS.closePayRemissionModal()">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="padding: 16px; gap: 8px;">
|
||||
<input class="form-input" type="number" id="payRemissionFolio" placeholder="Folio de la nota (NR-XXXX)" style="flex:1;" />
|
||||
<button class="btn btn-primary" onclick="POS.searchRemissionToPay()">Buscar</button>
|
||||
</div>
|
||||
|
||||
<div id="payRemissionDetail" style="padding: 0 16px 16px; max-height: 220px; overflow-y: auto;"></div>
|
||||
|
||||
<div id="payRemissionActions" style="display:none; padding: 0 16px 16px;">
|
||||
<div class="form-row" style="gap: 8px; margin-bottom: 8px;">
|
||||
<select class="form-input" id="payRemissionMethod" onchange="POS.updatePayRemissionMethod()">
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
<option value="mixto">Mixto</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="payRemissionCash" class="form-row" style="gap: 8px; margin-bottom: 8px;">
|
||||
<input class="form-input" type="number" id="payRemissionReceived" placeholder="Recibido" step="0.01" />
|
||||
</div>
|
||||
<div id="payRemissionRef" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none;">
|
||||
<input class="form-input" type="text" id="payRemissionReference" placeholder="Referencia" />
|
||||
</div>
|
||||
<div id="payRemissionMixed" class="form-row" style="gap: 8px; margin-bottom: 8px; display:none; flex-direction: column;">
|
||||
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
|
||||
<select class="form-input" style="flex:1;">
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
</select>
|
||||
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
|
||||
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
|
||||
</div>
|
||||
<div class="mixed-row" style="display:flex; gap:8px; width:100%;">
|
||||
<select class="form-input" style="flex:1;">
|
||||
<option value="efectivo">Efectivo</option>
|
||||
<option value="transferencia">Transferencia</option>
|
||||
<option value="tarjeta">Tarjeta</option>
|
||||
</select>
|
||||
<input class="form-input mixed-amount" type="number" placeholder="Monto" step="0.01" style="flex:1;" />
|
||||
<input class="form-input" type="text" placeholder="Referencia" style="flex:1;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer" style="padding:0;">
|
||||
<button class="btn btn-ghost" onclick="POS.closePayRemissionModal()">Cancelar</button>
|
||||
<button class="btn btn-primary" id="btnConfirmPayRemission" onclick="POS.confirmPayRemission()">Confirmar Pago</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="payRemissionResult" style="padding: 0 16px 16px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
CANCEL SALE CONFIRMATION MODAL
|
||||
================================================================ -->
|
||||
@@ -613,14 +726,14 @@
|
||||
<!-- ================================================================
|
||||
JAVASCRIPT
|
||||
================================================================ -->
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/kiosk.js" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/push.js" defer></script>
|
||||
<script src="/pos/static/js/printer.js" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos.js?v=41" defer></script>
|
||||
|
||||
<script>
|
||||
// Cancel sale button wiring
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<link rel="stylesheet" href="/pos/static/css/quotations.css"></head>
|
||||
<body>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
|
||||
<div class="page">
|
||||
<h1 class="page-title">Cotizaciones</h1>
|
||||
|
||||
436
pos/templates/remission_notes.html
Normal file
436
pos/templates/remission_notes.html
Normal file
@@ -0,0 +1,436 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<script>/*pos_theme_early*/(function(){var t=localStorage.getItem("pos_theme")||"industrial";document.documentElement.setAttribute("data-theme",t);})()</script>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Notas de Remisión — Nexus Autoparts POS</title>
|
||||
<link rel="stylesheet" href="/pos/static/css/chat.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/tokens.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/common.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/pos-ui.css?v=33" />
|
||||
<link rel="stylesheet" href="/pos/static/css/sidebar.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/pos-glass.css" />
|
||||
<link rel="stylesheet" href="/pos/static/css/inventory.css?v=34" />
|
||||
<link rel="stylesheet" href="/pos/static/css/remission_notes.css?v=1" />
|
||||
<link rel="manifest" href="/pos/static/pwa/manifest.json" />
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- THEME BAR -->
|
||||
<header class="theme-bar" role="banner">
|
||||
<div class="theme-bar__left">
|
||||
<div class="theme-bar__store">
|
||||
<span class="theme-bar__dot"></span>
|
||||
Nexus Autoparts
|
||||
</div>
|
||||
<div class="theme-bar__sep"></div>
|
||||
<span class="theme-bar__label">Sucursal Centro — Usuario: H. García</span>
|
||||
</div>
|
||||
<div class="theme-bar__right">
|
||||
<span class="theme-bar__label">Tema:</span>
|
||||
<button class="theme-btn theme-btn--industrial is-active" data-theme-target="industrial" onclick="setTheme('industrial')">
|
||||
<span class="theme-btn__swatch"></span>
|
||||
Industrial
|
||||
</button>
|
||||
<button class="theme-btn theme-btn--modern" data-theme-target="modern" onclick="setTheme('modern')">
|
||||
<span class="theme-btn__swatch"></span>
|
||||
Moderno
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- APP SHELL -->
|
||||
<div class="app-shell">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside class="sidebar" role="navigation" aria-label="Navegación principal">
|
||||
<div class="sidebar__brand">
|
||||
<div class="brand-logo">NA</div>
|
||||
<div class="brand-name">
|
||||
<span class="brand-name__primary">Nexus</span>
|
||||
<span class="brand-name__sub">Autoparts POS</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__nav">
|
||||
<div class="nav-section-label">Principal</div>
|
||||
<a class="nav-item" href="/pos/dashboard">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/>
|
||||
<rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/sale">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/>
|
||||
</svg>
|
||||
<span>POS</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/catalog">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 6h16M4 10h16M4 14h16M4 18h16"/>
|
||||
</svg>
|
||||
<span>Catálogo</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/inventory">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
<span>Inventario</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">Gestión</div>
|
||||
<a class="nav-item" href="/pos/customers">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
<span>Clientes</span>
|
||||
</a>
|
||||
<a class="nav-item is-active" href="/pos/remission-notes" aria-current="page">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
<polyline points="10 9 9 9 8 9"/>
|
||||
</svg>
|
||||
<span>Notas de Remisión</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/invoicing">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||
<polyline points="14 2 14 8 20 8"/>
|
||||
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||
</svg>
|
||||
<span>Facturación</span>
|
||||
</a>
|
||||
<a class="nav-item" href="/pos/reports">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/>
|
||||
<line x1="6" y1="20" x2="6" y2="14"/>
|
||||
</svg>
|
||||
<span>Reportes</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-section-label">Sistema</div>
|
||||
<a class="nav-item" href="/pos/config">
|
||||
<svg class="nav-item__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/>
|
||||
</svg>
|
||||
<span>Configuración</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<div class="sidebar__user-avatar">HG</div>
|
||||
<div class="sidebar__user-info">
|
||||
<div class="sidebar__user-name">Hugo García</div>
|
||||
<div class="sidebar__user-role">Administrador</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main class="main" role="main">
|
||||
<div class="page-header">
|
||||
<div class="page-header__title-group">
|
||||
<span class="page-header__eyebrow">Ventas</span>
|
||||
<h1 class="page-header__title">Notas de Remisión</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page-content">
|
||||
<!-- Filters -->
|
||||
<div class="filters-card">
|
||||
<div class="toolbar">
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="filterFolio" placeholder="Buscar folio NR-XXXX..." />
|
||||
</div>
|
||||
<div class="search-box">
|
||||
<svg viewBox="0 0 24 24" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<input type="text" id="filterCustomer" placeholder="Cliente..." />
|
||||
</div>
|
||||
<select class="select-filter" id="filterStatus">
|
||||
<option value="">Todos los estados</option>
|
||||
<option value="pending_payment" selected>Pendientes</option>
|
||||
<option value="completed">Pagadas</option>
|
||||
<option value="cancelled">Canceladas</option>
|
||||
</select>
|
||||
<select class="select-filter" id="filterCourier">
|
||||
<option value="">Todos los repartidores</option>
|
||||
</select>
|
||||
<input type="date" class="select-filter" id="dateFrom" />
|
||||
<input type="date" class="select-filter" id="dateTo" />
|
||||
<div class="toolbar__spacer"></div>
|
||||
<button class="btn btn--primary" onclick="loadData(1)">
|
||||
<svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
Buscar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrapper">
|
||||
<table class="data-table" id="remissionTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Folio</th>
|
||||
<th>Fecha</th>
|
||||
<th>Cliente</th>
|
||||
<th>Vendedor</th>
|
||||
<th>Repartidor</th>
|
||||
<th style="text-align:right">Total</th>
|
||||
<th>Estado</th>
|
||||
<th style="text-align:right">Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="remissionTableBody">
|
||||
<tr><td colspan="8" style="text-align:center;padding:var(--space-8);">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<div class="pagination" id="pagination"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Ticket Modal -->
|
||||
<div class="modal-overlay" id="ticketModal" onclick="if(event.target===this) closeTicketModal()">
|
||||
<div class="modal">
|
||||
<div class="modal__header">
|
||||
<h3 class="modal__title">Vista previa</h3>
|
||||
<button class="modal__close" onclick="closeTicketModal()">×</button>
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<div id="ticketContent" class="ticket-preview"></div>
|
||||
</div>
|
||||
<div class="modal__footer">
|
||||
<button class="btn btn--ghost" onclick="closeTicketModal()">Cerrar</button>
|
||||
<button class="btn btn--primary" onclick="printTicket()">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
|
||||
<script>
|
||||
const token = localStorage.getItem('pos_token') || '';
|
||||
let currentPage = 1;
|
||||
let couriers = [];
|
||||
|
||||
function headers() {
|
||||
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
options.headers = headers();
|
||||
const res = await fetch(url, options);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
|
||||
function statusLabel(status) {
|
||||
return { pending_payment: 'Pendiente', completed: 'Pagada', cancelled: 'Cancelada' }[status] || status;
|
||||
}
|
||||
function statusClass(status) {
|
||||
return { pending_payment: 'badge--pending_payment', completed: 'badge--completed', cancelled: 'badge--cancelled' }[status] || '';
|
||||
}
|
||||
|
||||
async function loadCouriers() {
|
||||
try {
|
||||
const data = await api('/pos/api/logistics/couriers');
|
||||
couriers = data.couriers || [];
|
||||
const sel = document.getElementById('filterCourier');
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '<option value="">Todos los repartidores</option>' + couriers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
|
||||
sel.value = current;
|
||||
} catch (e) {
|
||||
console.warn('No se pudieron cargar repartidores', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData(page) {
|
||||
currentPage = page;
|
||||
const tbody = document.getElementById('remissionTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-text-muted);">Cargando...</td></tr>';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('sale_type', 'counter_remission');
|
||||
params.set('per_page', '50');
|
||||
params.set('page', String(page));
|
||||
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
if (status) params.set('status', status);
|
||||
const folio = document.getElementById('filterFolio').value.trim();
|
||||
if (folio) params.set('q', folio.replace(/^NR-/i, ''));
|
||||
const customer = document.getElementById('filterCustomer').value.trim();
|
||||
if (customer) params.set('customer', customer);
|
||||
const courier = document.getElementById('filterCourier').value;
|
||||
if (courier) params.set('courier_id', courier);
|
||||
const from = document.getElementById('dateFrom').value;
|
||||
const to = document.getElementById('dateTo').value;
|
||||
if (from) params.set('date_from', from);
|
||||
if (to) params.set('date_to', to);
|
||||
|
||||
try {
|
||||
const res = await api('/pos/api/sales?' + params.toString());
|
||||
render(res.data || [], res.pagination || {});
|
||||
} catch (e) {
|
||||
tbody.innerHTML = `<tr><td colspan="8" style="text-align:center;padding:var(--space-8);color:var(--color-error);">Error: ${e.message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
function render(rows, pagination) {
|
||||
const tbody = document.getElementById('remissionTableBody');
|
||||
const pag = document.getElementById('pagination');
|
||||
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `<tr><td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">No hay notas de remisión</div>
|
||||
<div class="empty-state__subtitle">Ajusta los filtros o genera una nueva nota desde el POS.</div>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
pag.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map(r => {
|
||||
const canPay = r.status === 'pending_payment';
|
||||
return `
|
||||
<tr>
|
||||
<td class="td--mono">NR-${r.id}</td>
|
||||
<td>${new Date(r.created_at).toLocaleString('es-MX')}</td>
|
||||
<td class="td--primary">${r.customer_name || 'Público General'}</td>
|
||||
<td>${r.employee_name || '-'}</td>
|
||||
<td>${r.courier_name || '-'}</td>
|
||||
<td class="td--amount" style="text-align:right">${fmt(r.total)}</td>
|
||||
<td><span class="badge ${statusClass(r.status)}">${statusLabel(r.status)}</span></td>
|
||||
<td style="text-align:right">
|
||||
<div style="display:flex;justify-content:flex-end;gap:var(--space-2);">
|
||||
<button class="action-btn action-btn--ghost" onclick="viewTicket(${r.id})" title="Imprimir">
|
||||
<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>
|
||||
</button>
|
||||
${canPay ? `<button class="action-btn action-btn--primary" onclick="payNote(${r.id}, ${r.total})" title="Cobrar">
|
||||
<svg viewBox="0 0 24 24"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M6 14h.01"/></svg>
|
||||
</button>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (pagination.total_pages > 1) {
|
||||
pag.innerHTML = `
|
||||
<button class="page-btn" onclick="loadData(${pagination.page - 1})" ${pagination.page <= 1 ? 'disabled' : ''}>←</button>
|
||||
<span style="color:var(--color-text-muted);font-size:var(--text-caption);">Página ${pagination.page} de ${pagination.total_pages}</span>
|
||||
<button class="page-btn" onclick="loadData(${pagination.page + 1})" ${pagination.page >= pagination.total_pages ? 'disabled' : ''}>→</button>
|
||||
`;
|
||||
} else {
|
||||
pag.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function viewTicket(saleId) {
|
||||
try {
|
||||
const data = await api('/pos/api/sales/' + saleId + '/print-remission', { method: 'POST', body: '{}' });
|
||||
const dateStr = new Date(data.date).toLocaleString('es-MX');
|
||||
const itemsHtml = (data.items || []).map(it => `
|
||||
<div class="ticket-preview__item"><span>${it.quantity} x ${it.name}</span><span>${fmt(it.subtotal)}</span></div>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('ticketContent').innerHTML = `
|
||||
<div class="ticket-preview__center ticket-preview__bold">${data.business_name || 'NEXUS AUTOPARTS'}</div>
|
||||
<div class="ticket-preview__center">${data.business_rfc || ''}</div>
|
||||
<div class="ticket-preview__center">${data.business_address || ''}</div>
|
||||
<div class="ticket-preview__divider"></div>
|
||||
<div class="ticket-preview__center ticket-preview__bold">NOTA DE REMISIÓN</div>
|
||||
<div class="ticket-preview__center">${data.folio}</div>
|
||||
<div class="ticket-preview__center">${dateStr}</div>
|
||||
<div class="ticket-preview__divider"></div>
|
||||
<div class="ticket-preview__line"><span>Cliente:</span><span>${data.customer || 'Público General'}</span></div>
|
||||
<div class="ticket-preview__line"><span>Vendedor:</span><span>${data.employee || '-'}</span></div>
|
||||
${data.courier ? `<div class="ticket-preview__line"><span>Repartidor:</span><span>${data.courier}</span></div>` : ''}
|
||||
<div class="ticket-preview__items">${itemsHtml}</div>
|
||||
<div class="ticket-preview__divider"></div>
|
||||
<div class="ticket-preview__line"><span>Subtotal:</span><span>${fmt(data.subtotal)}</span></div>
|
||||
${data.discount_total ? `<div class="ticket-preview__line"><span>Descuento:</span><span>-${fmt(data.discount_total)}</span></div>` : ''}
|
||||
<div class="ticket-preview__line"><span>IVA:</span><span>${fmt(data.tax_total)}</span></div>
|
||||
<div class="ticket-preview__line ticket-preview__bold"><span>TOTAL:</span><span>${fmt(data.total)}</span></div>
|
||||
<div class="ticket-preview__divider"></div>
|
||||
<div class="ticket-preview__footer ticket-preview__bold">PENDIENTE DE PAGO</div>
|
||||
<div class="ticket-preview__footer" style="font-size:0.75rem;opacity:0.8;">Presente esta nota en caja para pagar</div>
|
||||
`;
|
||||
document.getElementById('ticketModal').classList.add('is-open');
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeTicketModal() {
|
||||
document.getElementById('ticketModal').classList.remove('is-open');
|
||||
}
|
||||
|
||||
function printTicket() {
|
||||
const w = window.open('', '_blank');
|
||||
w.document.write('<html><head><title>Nota de Remisión</title></head><body>' + document.getElementById('ticketContent').innerHTML + '</body></html>');
|
||||
w.document.close();
|
||||
w.print();
|
||||
}
|
||||
|
||||
async function payNote(saleId, total) {
|
||||
const method = prompt(`Cobrar nota NR-${saleId}\nTotal: ${fmt(total)}\n\nForma de pago: efectivo / transferencia / tarjeta`, 'efectivo');
|
||||
if (!method) return;
|
||||
const reference = ['transferencia', 'tarjeta'].includes(method) ? prompt('Referencia:') : '';
|
||||
try {
|
||||
await api('/pos/api/sales/' + saleId + '/pay', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
payment_method: method,
|
||||
amount_paid: total,
|
||||
reference: reference || ''
|
||||
})
|
||||
});
|
||||
alert('Nota cobrada correctamente');
|
||||
loadData(currentPage);
|
||||
} catch (e) {
|
||||
alert('Error al cobrar: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
if (!token) {
|
||||
document.getElementById('remissionTableBody').innerHTML = `<tr><td colspan="8">
|
||||
<div class="empty-state">
|
||||
<div class="empty-state__title">Inicia sesión</div>
|
||||
<div class="empty-state__subtitle">Se requiere autenticación para ver las notas de remisión.</div>
|
||||
</div>
|
||||
</td></tr>`;
|
||||
return;
|
||||
}
|
||||
await loadCouriers();
|
||||
loadData(1);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -229,6 +229,14 @@
|
||||
</svg>
|
||||
Histórico
|
||||
</button>
|
||||
<button class="tab-btn" onclick="switchTab('cortes', this)">
|
||||
<svg viewBox="0 0 15 15" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<rect x="1" y="3" width="13" height="10" rx="1"/>
|
||||
<path d="M4 7h7M4 10h5"/>
|
||||
<circle cx="11" cy="10" r="1.5" fill="currentColor"/>
|
||||
</svg>
|
||||
Cortes de caja
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ==================================================================
|
||||
@@ -355,6 +363,38 @@
|
||||
<!-- Sales detail table -->
|
||||
<div class="table-card mb-5" id="historico-detalle"></div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ==================================================================
|
||||
TAB 6: CORTES DE CAJA
|
||||
================================================================== -->
|
||||
<div class="tab-panel" id="panel-cortes">
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="filter-bar">
|
||||
<span class="filter-bar__label">Desde</span>
|
||||
<input type="date" class="filter-input" id="cortes-date-from" />
|
||||
<span class="filter-bar__label">Hasta</span>
|
||||
<input type="date" class="filter-input" id="cortes-date-to" />
|
||||
<span id="cortes-employee-filter">
|
||||
<span class="filter-bar__label">Cajero</span>
|
||||
<select class="filter-select" id="cortes-employee">
|
||||
<option value="">Todos</option>
|
||||
</select>
|
||||
</span>
|
||||
<div class="filter-bar__spacer"></div>
|
||||
<button class="btn btn-primary btn-sm" onclick="Reports.loadCortes()">Generar</button>
|
||||
</div>
|
||||
|
||||
<!-- KPI Cards (dynamic) -->
|
||||
<div class="kpi-grid" id="cortes-kpis"></div>
|
||||
|
||||
<!-- Cortes detail table -->
|
||||
<div class="table-card mb-5" id="cortes-detalle"></div>
|
||||
|
||||
<!-- Detail of sales for the selected cash cut -->
|
||||
<div class="table-card mb-5" id="corte-ventas-detalle" style="display:none;"></div>
|
||||
|
||||
</div>
|
||||
<!-- End panels -->
|
||||
|
||||
@@ -365,12 +405,12 @@
|
||||
</div>
|
||||
<!-- End app-shell -->
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/reports.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/reports.js?v=35" 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>
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/supplier_catalog.js?v=33" defer></script>
|
||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||
</body>
|
||||
|
||||
@@ -131,12 +131,12 @@ function posLogout(){localStorage.removeItem('pos_token');window.location.href='
|
||||
</script>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/whatsapp2.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
|
||||
<script src="/pos/static/js/chat.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<meta name="theme-color" content="#F5A623" />
|
||||
<link rel="shortcut icon" type="image/png" href="/pos/static/pwa/icon-192.png" />
|
||||
|
||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=42">
|
||||
<link rel="stylesheet" href="/pos/static/css/workshop.css?v=44">
|
||||
<style>
|
||||
.so-notes-grid { display: grid; grid-template-columns: 120px 1fr; gap: var(--space-2); align-items: start; }
|
||||
.so-notes-grid .form-label { margin: 0; padding-top: var(--space-2); }
|
||||
@@ -55,7 +55,7 @@
|
||||
<svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v10z"/><polyline points="3 8 12 13 21 8"/></svg>
|
||||
</div>
|
||||
<div class="summary-card__body">
|
||||
<div class="summary-card__label">Recibidos</div>
|
||||
<div class="summary-card__label">Por revisar</div>
|
||||
<div class="summary-card__value" id="statReceived">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@
|
||||
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="9 12 12 15 16 10"/></svg>
|
||||
</div>
|
||||
<div class="summary-card__body">
|
||||
<div class="summary-card__label">Listos</div>
|
||||
<div class="summary-card__label">Por entregar</div>
|
||||
<div class="summary-card__value" id="statReady">--</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,20 +96,28 @@
|
||||
</select>
|
||||
<select class="form-input" id="filterStatus">
|
||||
<option value="">Todos los estatus</option>
|
||||
<option value="received">Recibido</option>
|
||||
<option value="diagnosis">Diagnóstico</option>
|
||||
<option value="waiting_parts">Espera refacciones</option>
|
||||
<option value="repair">En reparación</option>
|
||||
<option value="quality_check">Control calidad</option>
|
||||
<option value="ready">Listo</option>
|
||||
<option value="delivered">Entregado</option>
|
||||
<option value="cancelled">Cancelado</option>
|
||||
<option value="por_revisar">Por revisar</option>
|
||||
<option value="en_revision">En revisión</option>
|
||||
<option value="revisada">Revisada</option>
|
||||
<option value="cotizada">Cotizada</option>
|
||||
<option value="por_autorizar">Por autorizar</option>
|
||||
<option value="autorizada">Autorizada</option>
|
||||
<option value="autorizacion_parcial">Autorización parcial</option>
|
||||
<option value="en_reparacion">En reparación</option>
|
||||
<option value="reparada">Reparada</option>
|
||||
<option value="por_entregar">Por entregar</option>
|
||||
<option value="entregado">Entregado</option>
|
||||
<option value="por_enviar">Por enviar</option>
|
||||
<option value="enviado">Enviado</option>
|
||||
<option value="por_facturar">Por facturar</option>
|
||||
<option value="facturada">Facturada</option>
|
||||
<option value="por_recolectar">Por recolectar</option>
|
||||
<option value="cancelada">Cancelada</option>
|
||||
</select>
|
||||
<select class="form-input" id="filterDelivery">
|
||||
<option value="">Todas las vías de entrega</option>
|
||||
<option value="pickup">Pasa cliente</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Envío a domicilio</option>
|
||||
<option value="courier">Motociclista</option>
|
||||
</select>
|
||||
<label class="toolbar-toggle">
|
||||
<input type="checkbox" id="filterDirect" />
|
||||
@@ -137,15 +145,16 @@
|
||||
<tr>
|
||||
<th>Sucursal</th>
|
||||
<th>Orden</th>
|
||||
<th>Cliente</th>
|
||||
<th>Vehículo</th>
|
||||
<th class="restricted-hide">Cliente</th>
|
||||
<th class="restricted-hide">Taller</th>
|
||||
<th class="restricted-hide">Vehículo</th>
|
||||
<th>Estatus</th>
|
||||
<th class="price-col">Total</th>
|
||||
<th class="price-col restricted-hide">Total</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listBody">
|
||||
<tr><td colspan="7" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
|
||||
<tr><td colspan="8" style="text-align:center;padding:var(--space-4);">Cargando...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -184,6 +193,10 @@
|
||||
</div>
|
||||
<div class="modal__body">
|
||||
<form id="newOrderForm" class="form-grid">
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noBranch">Sucursal</label>
|
||||
<select class="form-input" id="noBranch" required></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noCustomer">Cliente</label>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
@@ -192,29 +205,26 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noVehicle">Vehículo</label>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<select class="form-input" id="noVehicle" style="flex:1;"></select>
|
||||
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModalFromNewOrder()">+ Nuevo</button>
|
||||
</div>
|
||||
<label class="form-label" for="noWorkshopName">Taller</label>
|
||||
<input class="form-input" id="noWorkshopName" placeholder="Nombre del taller" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noMechanic">Mecánico asignado</label>
|
||||
<select class="form-input" id="noMechanic"></select>
|
||||
<label class="form-label" for="noCustomerPhone">Teléfono</label>
|
||||
<input class="form-input" id="noCustomerPhone" placeholder="3312345678" />
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<label class="form-label" for="noCustomerAddress">Dirección</label>
|
||||
<input class="form-input" id="noCustomerAddress" placeholder="Calle, número, colonia" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noPriority">Prioridad</label>
|
||||
<select class="form-input" id="noPriority">
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
<label class="form-label" for="noVehicleDescription">Vehículo</label>
|
||||
<input class="form-input" id="noVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noDelivery">Vía de entrega</label>
|
||||
<select class="form-input" id="noDelivery">
|
||||
<option value="">—</option>
|
||||
<option value="pickup">Pasa cliente</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Envío a domicilio</option>
|
||||
<option value="courier">Motociclista</option>
|
||||
</select>
|
||||
@@ -224,21 +234,31 @@
|
||||
<select class="form-input" id="noCourier"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noEstimatedCompletion">Entrega estimada</label>
|
||||
<input class="form-input" type="datetime-local" id="noEstimatedCompletion" />
|
||||
<label class="form-label" for="noMechanic">Mecánico asignado (usuario)</label>
|
||||
<select class="form-input" id="noMechanic"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noMileage">Kilometraje</label>
|
||||
<input class="form-input" type="number" id="noMileage" placeholder="Ej. 45200" />
|
||||
<label class="form-label" for="noMechanicName">Mecánico asignado (nombre libre)</label>
|
||||
<input class="form-input" type="text" id="noMechanicName" placeholder="Ej. Juan Pérez" />
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="noEstimatedCost">Presupuesto</label>
|
||||
<input class="form-input" type="number" id="noEstimatedCost" step="0.01" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="toolbar-toggle">
|
||||
<input type="checkbox" id="noRequiresInvoice" />
|
||||
<span>Requiere factura</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="toolbar-toggle">
|
||||
<input type="checkbox" id="noDirect" />
|
||||
<span>Orden directa (sin inventario)</span>
|
||||
<span>Orden directa</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<label class="form-label" for="noNotes">Notas de recepción</label>
|
||||
<label class="form-label" for="noNotes">Observaciones</label>
|
||||
<textarea class="form-input" id="noNotes" rows="3" placeholder="Falla reportada, observaciones..."></textarea>
|
||||
</div>
|
||||
</form>
|
||||
@@ -270,63 +290,59 @@
|
||||
</div>
|
||||
<input type="hidden" id="eoCustomerId" />
|
||||
</div>
|
||||
<div class="form-field form-field--span2" style="position:relative;">
|
||||
<label class="form-label" for="eoVehicleSearch">Vehículo</label>
|
||||
<div style="display:flex;gap:var(--space-2);">
|
||||
<div style="position:relative;flex:1;">
|
||||
<input class="form-input" id="eoVehicleSearch" autocomplete="off" placeholder="Buscar por placa..." oninput="Workshop.searchVehiclesForSO()" />
|
||||
<div id="eoVehicleResults" style="display:none;position:absolute;z-index:10;top:100%;left:0;right:0;max-height:180px;overflow-y:auto;background:#fff;border:1px solid var(--color-border);border-radius:var(--radius-md);box-shadow:0 4px 12px rgba(0,0,0,.15);"></div>
|
||||
</div>
|
||||
<button class="btn btn--secondary" type="button" onclick="Workshop.openNewVehicleModal('edit')">+ Nuevo</button>
|
||||
</div>
|
||||
<input type="hidden" id="eoVehicleId" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoBranch">Sucursal</label>
|
||||
<select class="form-input" id="eoBranch"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMechanic">Mecánico asignado</label>
|
||||
<select class="form-input" id="eoMechanic"><option value="">— Ninguno —</option></select>
|
||||
<label class="form-label" for="eoWorkshopName">Taller</label>
|
||||
<input class="form-input" id="eoWorkshopName" placeholder="Nombre del taller" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoPriority">Prioridad</label>
|
||||
<select class="form-input" id="eoPriority">
|
||||
<option value="low">Baja</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Alta</option>
|
||||
<option value="urgent">Urgente</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoFuelLevel">Nivel de combustible</label>
|
||||
<select class="form-input" id="eoFuelLevel">
|
||||
<option value="">—</option>
|
||||
<option value="empty">Vacío</option>
|
||||
<option value="quarter">1/4</option>
|
||||
<option value="half">1/2</option>
|
||||
<option value="three_quarters">3/4</option>
|
||||
<option value="full">Lleno</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMileageIn">Kilometraje entrada</label>
|
||||
<input class="form-input" type="number" id="eoMileageIn" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMileageOut">Kilometraje salida</label>
|
||||
<input class="form-input" type="number" id="eoMileageOut" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoEstimatedCompletion">Entrega estimada</label>
|
||||
<input class="form-input" type="datetime-local" id="eoEstimatedCompletion" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoEstimatedCost">Costo estimado</label>
|
||||
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
|
||||
<label class="form-label" for="eoCustomerPhone">Teléfono</label>
|
||||
<input class="form-input" id="eoCustomerPhone" placeholder="3312345678" />
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<label class="form-label" for="eoNotes">Notas de recepción</label>
|
||||
<label class="form-label" for="eoCustomerAddress">Dirección</label>
|
||||
<input class="form-input" id="eoCustomerAddress" placeholder="Calle, número, colonia" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoVehicleDescription">Vehículo</label>
|
||||
<input class="form-input" id="eoVehicleDescription" placeholder="Ej. Versa 2020" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoDelivery">Vía de entrega</label>
|
||||
<select class="form-input" id="eoDelivery">
|
||||
<option value="">—</option>
|
||||
<option value="pickup">Mostrador</option>
|
||||
<option value="delivery">Envío a domicilio</option>
|
||||
<option value="courier">Motociclista</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field" id="eoCourierField" style="display:none;">
|
||||
<label class="form-label" for="eoCourier">Motociclista</label>
|
||||
<select class="form-input" id="eoCourier"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoEstimatedCost">Presupuesto</label>
|
||||
<input class="form-input" type="number" id="eoEstimatedCost" step="0.01" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMechanic">Mecánico asignado (usuario)</label>
|
||||
<select class="form-input" id="eoMechanic"></select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label" for="eoMechanicName">Mecánico asignado (nombre libre)</label>
|
||||
<input class="form-input" type="text" id="eoMechanicName" placeholder="Ej. Juan Pérez" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="toolbar-toggle">
|
||||
<input type="checkbox" id="eoRequiresInvoice" />
|
||||
<span>Requiere factura</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-field form-field--span2">
|
||||
<label class="form-label" for="eoNotes">Observaciones</label>
|
||||
<textarea class="form-input" id="eoNotes" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
@@ -477,13 +493,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/pos/static/js/i18n.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=2" defer></script>
|
||||
<script src="/pos/static/js/i18n.js?v=39" defer></script>
|
||||
<script src="/pos/static/js/app-init.js?v=5" defer></script>
|
||||
<script src="/pos/static/js/splash-loader.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/pos-utils.js?v=33" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=36" defer></script>
|
||||
<script src="/pos/static/js/sidebar.js?v=44" defer></script>
|
||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=42" defer></script>
|
||||
<script src="/pos/static/js/workshop.js?v=55" 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>
|
||||
|
||||
Reference in New Issue
Block a user