From 4814c813c1428c84451ceeb53abca756034134a1 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 01:31:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20add=20config=20blueprint=20?= =?UTF-8?q?=E2=80=94=20branches,=20employees,=20theming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/app.py | 3 + pos/blueprints/config_bp.py | 149 ++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 pos/blueprints/config_bp.py diff --git a/pos/app.py b/pos/app.py index 0fa19e5..453be2e 100644 --- a/pos/app.py +++ b/pos/app.py @@ -7,6 +7,9 @@ def create_app(): from blueprints.auth_bp import auth_bp app.register_blueprint(auth_bp) + from blueprints.config_bp import config_bp + app.register_blueprint(config_bp) + # Health check @app.route('/pos/health') def health(): diff --git a/pos/blueprints/config_bp.py b/pos/blueprints/config_bp.py new file mode 100644 index 0000000..2d17b3d --- /dev/null +++ b/pos/blueprints/config_bp.py @@ -0,0 +1,149 @@ +# /home/Autopartes/pos/blueprints/config_bp.py +"""Config blueprint: tenant configuration, branches, theming.""" + +from flask import Blueprint, request, jsonify, g +from middleware import require_auth, has_permission +from tenant_db import get_tenant_conn + +config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config') + + +@config_bp.route('/branches', methods=['GET']) +@require_auth() +def list_branches(): + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id") + branches = [] + for r in cur.fetchall(): + branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]}) + cur.close() + conn.close() + return jsonify({'data': branches}) + + +@config_bp.route('/branches', methods=['POST']) +@require_auth('config.edit') +def create_branch(): + data = request.get_json() or {} + if not data.get('name'): + return jsonify({'error': 'name required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO branches (name, address, phone) + VALUES (%s, %s, %s) RETURNING id + """, (data['name'], data.get('address'), data.get('phone'))) + branch_id = cur.fetchone()[0] + conn.commit() + cur.close() + conn.close() + return jsonify({'id': branch_id, 'message': 'Branch created'}), 201 + + +@config_bp.route('/employees', methods=['GET']) +@require_auth('config.view') +def list_employees(): + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + SELECT e.id, e.name, e.email, e.phone, e.role, e.branch_id, + b.name as branch_name, e.max_discount_pct, e.is_active + FROM employees e + LEFT JOIN branches b ON e.branch_id = b.id + ORDER BY e.id + """) + employees = [] + for r in cur.fetchall(): + employees.append({ + 'id': r[0], 'name': r[1], 'email': r[2], 'phone': r[3], + 'role': r[4], 'branch_id': r[5], 'branch_name': r[6], + 'max_discount_pct': float(r[7]) if r[7] else 0, 'is_active': r[8] + }) + cur.close() + conn.close() + return jsonify({'data': employees}) + + +@config_bp.route('/employees', methods=['POST']) +@require_auth('config.edit') +def create_employee(): + import bcrypt + data = request.get_json() or {} + required = ['name', 'role', 'pin'] + for f in required: + if not data.get(f): + return jsonify({'error': f'{f} required'}), 400 + + valid_roles = ['admin', 'cashier', 'warehouse', 'accountant'] + if data['role'] not in valid_roles: + return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400 + + pin_hash = bcrypt.hashpw(data['pin'].encode(), bcrypt.gensalt()).decode() + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + cur.execute(""" + INSERT INTO employees (name, email, phone, pin, role, branch_id, max_discount_pct, is_active) + VALUES (%s, %s, %s, %s, %s, %s, %s, true) RETURNING id + """, (data['name'], data.get('email'), data.get('phone'), pin_hash, + 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'], + 'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', + 'catalog.view', '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'], + } + + for perm in role_permissions.get(data['role'], []): + cur.execute( + "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", + (emp_id, perm) + ) + + from services.audit import log_action + log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id, + new_value={'name': data['name'], 'role': data['role']}) + + conn.commit() + cur.close() + conn.close() + return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 + + +@config_bp.route('/theme', methods=['GET']) +@require_auth() +def get_theme(): + """Get current theme for this tenant. Returns CSS variables.""" + # For v1, return a default theme. The design team will add more. + return jsonify({ + 'theme': 'default', + 'variables': { + '--color-primary': '#1a73e8', + '--color-secondary': '#5f6368', + '--color-accent': '#ff6b35', + '--color-bg': '#ffffff', + '--color-surface': '#f8f9fa', + '--color-text': '#202124', + '--color-border': '#dadce0', + '--font-display': "'Sora', sans-serif", + '--font-body': "'Plus Jakarta Sans', sans-serif", + '--font-mono': "'JetBrains Mono', monospace", + '--radius': '8px', + } + })