feat(whatsapp): QWEN primary AI backend, Hermes fallback, conversation history, vehicle persistence, demo prompts
- Add QWEN (qwen3.6) as primary AI backend with short system prompt - Hermes remains as fallback with 45s timeout - Increase QWEN timeout to 35s, max_tokens to 4000 - Add conversation history loading from whatsapp_messages (last 4 msgs) - Persist detected vehicle in whatsapp_sessions table - Add 'limpiar chat' / 'nuevo chat' / 'reset' commands to clear history - Fix CSS conflict: rename whatsapp chat-panel classes to wa-chat-panel - Fix JS ID conflicts with chat.js widget (waChatPanel, waChatMessages, etc.) - Improve no-stock response: conversational with alternatives - Split search_query by | for multi-part lookups - Add DEMO_PROMPTS.md and DEMO_PROMPTS_V2.md
This commit is contained in:
@@ -6,8 +6,9 @@ that validates input, calls the engine, and returns JSON responses.
|
||||
"""
|
||||
|
||||
import json
|
||||
import jwt
|
||||
from datetime import datetime, date, timedelta
|
||||
from flask import Blueprint, request, jsonify, g
|
||||
from flask import Blueprint, request, jsonify, g, render_template_string
|
||||
from middleware import require_auth, has_permission
|
||||
from tenant_db import get_tenant_conn
|
||||
from services.pos_engine import (
|
||||
@@ -15,6 +16,7 @@ from services.pos_engine import (
|
||||
get_price_for_customer, get_margin_info
|
||||
)
|
||||
from services.audit import log_action
|
||||
from config import JWT_SECRET
|
||||
|
||||
pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
|
||||
|
||||
@@ -485,6 +487,16 @@ def create_quotation():
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for quotation
|
||||
from services.quote_reservation import reserve_for_quotation, get_quotation_items_for_reservation
|
||||
try:
|
||||
reservation_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
reserve_for_quotation(conn, quot_id, reservation_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
# Log but don't fail the quote creation
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
|
||||
@@ -766,6 +778,270 @@ def get_quotation(quot_id):
|
||||
return jsonify(quot)
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PUT'])
|
||||
@require_auth('pos.sell')
|
||||
def update_quotation(quot_id):
|
||||
"""Replace all items in an existing active quotation.
|
||||
|
||||
Body: { items: [...], customer_id, notes, valid_days, currency, exchange_rate }
|
||||
"""
|
||||
data = request.get_json() or {}
|
||||
items = data.get('items', [])
|
||||
if not items:
|
||||
return jsonify({'error': 'No items provided'}), 400
|
||||
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[1] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Quotation is {row[1]}, cannot edit'}), 400
|
||||
|
||||
try:
|
||||
enriched = _enrich_items(cur, items, data.get('customer_id'))
|
||||
except ValueError as e:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 400
|
||||
|
||||
totals = calculate_totals(enriched)
|
||||
valid_days = int(data.get('valid_days', 7))
|
||||
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
|
||||
|
||||
from services.currency import get_exchange_rate
|
||||
currency = data.get('currency', 'MXN')
|
||||
if currency not in ('MXN', 'USD'):
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
|
||||
exchange_rate = data.get('exchange_rate')
|
||||
if currency != 'MXN' and exchange_rate is None:
|
||||
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
|
||||
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
|
||||
|
||||
try:
|
||||
# Release old reservations before deleting items
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
reserve_for_quotation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
old_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if old_items:
|
||||
release_quotation_reservation(conn, quot_id, old_items, employee_id=g.employee_id)
|
||||
|
||||
# Delete old items
|
||||
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
|
||||
|
||||
# Update header
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET customer_id = %s, subtotal = %s, tax_total = %s, total = %s,
|
||||
valid_until = %s, notes = %s, currency = %s, exchange_rate = %s,
|
||||
employee_id = %s
|
||||
WHERE id = %s
|
||||
""", (
|
||||
data.get('customer_id'), totals['subtotal'], totals['tax_total'],
|
||||
totals['total'], valid_until, data.get('notes'),
|
||||
currency, exchange_rate, g.employee_id, quot_id
|
||||
))
|
||||
|
||||
# Insert new items
|
||||
for item in totals['items']:
|
||||
line_subtotal = round(
|
||||
item['unit_price'] * item['quantity'] * (1 - item['discount_pct'] / 100), 2
|
||||
)
|
||||
cur.execute("""
|
||||
INSERT INTO quotation_items
|
||||
(quotation_id, inventory_id, part_number, name, quantity,
|
||||
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
|
||||
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
|
||||
""", (
|
||||
quot_id, item['inventory_id'], item.get('part_number', ''),
|
||||
item.get('name', ''), item['quantity'], item['unit_price'],
|
||||
item['discount_pct'], item['tax_rate'], line_subtotal,
|
||||
currency, exchange_rate
|
||||
))
|
||||
|
||||
# Reserve stock for new items
|
||||
new_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if new_items:
|
||||
reserve_for_quotation(conn, quot_id, new_items, employee_id=g.employee_id)
|
||||
|
||||
log_action(conn, 'QUOTATION_UPDATE', 'quotation', quot_id,
|
||||
new_value={'total': totals['total'], 'items_count': len(items)})
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated', 'id': quot_id, 'total': totals['total']})
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>', methods=['PATCH'])
|
||||
@require_auth('pos.sell')
|
||||
def patch_quotation(quot_id):
|
||||
"""Update quotation header fields without touching items."""
|
||||
data = request.get_json() or {}
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("SELECT id, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
fields = []
|
||||
params = []
|
||||
if 'customer_id' in data:
|
||||
fields.append('customer_id = %s')
|
||||
params.append(data['customer_id'])
|
||||
if 'notes' in data:
|
||||
fields.append('notes = %s')
|
||||
params.append(data['notes'])
|
||||
if 'valid_until' in data:
|
||||
fields.append('valid_until = %s')
|
||||
params.append(data['valid_until'])
|
||||
if 'status' in data and data['status'] in ('active', 'cancelled', 'expired'):
|
||||
fields.append('status = %s')
|
||||
params.append(data['status'])
|
||||
|
||||
if not fields:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'No changes'}), 200
|
||||
|
||||
params.append(quot_id)
|
||||
cur.execute(f"UPDATE quotations SET {', '.join(fields)} WHERE id = %s", params)
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation updated'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/share', methods=['POST'])
|
||||
@require_auth('pos.sell')
|
||||
def share_quotation(quot_id):
|
||||
"""Generate a public JWT token for viewing this quotation."""
|
||||
conn = get_tenant_conn(g.tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT id, valid_until, status FROM quotations WHERE id = %s", (quot_id,))
|
||||
row = cur.fetchone()
|
||||
cur.close(); conn.close()
|
||||
if not row:
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[2] != 'active':
|
||||
return jsonify({'error': 'Only active quotations can be shared'}), 400
|
||||
|
||||
valid_until = row[1] or (date.today() + timedelta(days=7))
|
||||
if isinstance(valid_until, str):
|
||||
valid_until = datetime.strptime(valid_until, '%Y-%m-%d').date()
|
||||
|
||||
payload = {
|
||||
'type': 'public_quote',
|
||||
'quot_id': quot_id,
|
||||
'tenant_id': g.tenant_id,
|
||||
'exp': datetime.combine(valid_until, datetime.max.time()),
|
||||
}
|
||||
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
||||
public_url = request.host_url.rstrip('/') + f'/public/quote/{token}'
|
||||
return jsonify({'token': token, 'url': public_url})
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>', methods=['GET'])
|
||||
def public_quote(token):
|
||||
"""Unauthenticated public view of a quotation."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
# Resolve tenant db
|
||||
from tenant_db import get_tenant_conn
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
|
||||
q.created_at, q.notes, q.customer_id, q.currency, q.exchange_rate,
|
||||
c.name as customer_name, c.phone as customer_phone, c.email as customer_email,
|
||||
e.name as employee_name
|
||||
FROM quotations q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
LEFT JOIN employees e ON q.employee_id = e.id
|
||||
WHERE q.id = %s
|
||||
""", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
|
||||
cols = ['id', 'subtotal', 'tax_total', 'total', 'valid_until', 'created_at',
|
||||
'notes', 'customer_id', 'currency', 'exchange_rate', 'customer_name',
|
||||
'customer_phone', 'customer_email', 'employee_name']
|
||||
quot = dict(zip(cols, row))
|
||||
for k in ('subtotal', 'tax_total', 'total', 'exchange_rate'):
|
||||
if quot.get(k) is not None:
|
||||
quot[k] = float(quot[k])
|
||||
|
||||
cur.execute("""
|
||||
SELECT part_number, name, quantity, unit_price, discount_pct, tax_rate, subtotal
|
||||
FROM quotation_items WHERE quotation_id = %s ORDER BY id
|
||||
""", (payload['quot_id'],))
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
'part_number': r[0], 'name': r[1], 'quantity': r[2],
|
||||
'unit_price': float(r[3]) if r[3] else 0,
|
||||
'discount_pct': float(r[4]) if r[4] else 0,
|
||||
'tax_rate': float(r[5]) if r[5] else 0,
|
||||
'subtotal': float(r[6]) if r[6] else 0,
|
||||
})
|
||||
cur.close(); conn.close()
|
||||
|
||||
html = render_template_string(PUBLIC_QUOTE_TEMPLATE,
|
||||
quot=quot, items=items, host=request.host_url.rstrip('/'),
|
||||
token=token)
|
||||
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
|
||||
|
||||
|
||||
@pos_bp.route('/public/quote/<token>/accept', methods=['POST'])
|
||||
def public_quote_accept(token):
|
||||
"""Customer accepts a public quote."""
|
||||
try:
|
||||
payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256'])
|
||||
if payload.get('type') != 'public_quote':
|
||||
return jsonify({'error': 'Invalid token type'}), 400
|
||||
except jwt.ExpiredSignatureError:
|
||||
return jsonify({'error': 'Quote expired'}), 410
|
||||
except jwt.InvalidTokenError:
|
||||
return jsonify({'error': 'Invalid token'}), 400
|
||||
|
||||
conn = get_tenant_conn(payload['tenant_id'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT status FROM quotations WHERE id = %s", (payload['quot_id'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation not found'}), 404
|
||||
if row[0] != 'active':
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Quotation is no longer active'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s",
|
||||
(payload['quot_id'],))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Cotizacion aceptada. Un asesor se pondra en contacto contigo.'})
|
||||
|
||||
|
||||
@pos_bp.route('/quotations/<int:quot_id>/pdf', methods=['GET'])
|
||||
@require_auth('pos.view')
|
||||
def get_quotation_pdf(quot_id):
|
||||
@@ -1004,6 +1280,19 @@ def convert_quotation(quot_id):
|
||||
WHERE id = %s
|
||||
""", (sale['id'], quot_id))
|
||||
|
||||
# Convert reservation to actual sale
|
||||
from services.quote_reservation import (
|
||||
convert_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
convert_quotation_reservation(conn, quot_id, res_items, sale_id=sale['id'], employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote conversion reservation failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify(sale), 201
|
||||
@@ -1034,11 +1323,76 @@ def cancel_quotation(quot_id):
|
||||
return jsonify({'error': f'Quotation is already {quot[1]}'}), 400
|
||||
|
||||
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (quot_id,))
|
||||
|
||||
# Release reserved stock
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, quot_id)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, quot_id, res_items, employee_id=g.employee_id)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on cancel failed for #{quot_id}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'message': 'Quotation cancelled'})
|
||||
|
||||
|
||||
@pos_bp.route('/internal/check-expired-quotations', methods=['POST'])
|
||||
def check_expired_quotations():
|
||||
"""Cron endpoint: mark active quotations as expired when valid_until < today.
|
||||
|
||||
Can be called internally by systemd timer or Celery beat.
|
||||
Requires a secret header INTERNAL_API_KEY for safety.
|
||||
Body (optional): { tenant_id: int } — if omitted, uses g.tenant_id.
|
||||
"""
|
||||
from config import INTERNAL_API_KEY
|
||||
if INTERNAL_API_KEY and request.headers.get('X-Internal-Key') != INTERNAL_API_KEY:
|
||||
return jsonify({'error': 'Unauthorized'}), 401
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
tenant_id = data.get('tenant_id') or getattr(g, 'tenant_id', None)
|
||||
if not tenant_id:
|
||||
return jsonify({'error': 'tenant_id required'}), 400
|
||||
|
||||
conn = get_tenant_conn(tenant_id)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
UPDATE quotations
|
||||
SET status = 'expired'
|
||||
WHERE status = 'active'
|
||||
AND valid_until < CURRENT_DATE
|
||||
RETURNING id
|
||||
""")
|
||||
expired_ids = [r[0] for r in cur.fetchall()]
|
||||
|
||||
# Release reservations for expired quotes
|
||||
from services.quote_reservation import (
|
||||
release_quotation_reservation,
|
||||
get_quotation_items_for_reservation
|
||||
)
|
||||
for qid in expired_ids:
|
||||
try:
|
||||
res_items = get_quotation_items_for_reservation(conn, qid)
|
||||
if res_items:
|
||||
release_quotation_reservation(conn, qid, res_items)
|
||||
except Exception as res_err:
|
||||
import logging
|
||||
logging.getLogger('pos').warning(f'Quote release on expiry failed for #{qid}: {res_err}')
|
||||
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'expired': len(expired_ids),
|
||||
'ids': expired_ids,
|
||||
'tenant_id': tenant_id,
|
||||
})
|
||||
|
||||
|
||||
# ─── Layaways (Apartados) ────────────────────────
|
||||
|
||||
@pos_bp.route('/layaways', methods=['POST'])
|
||||
@@ -1967,3 +2321,109 @@ def print_ticket(sale_id):
|
||||
raw = generate_ticket(sale_data, business_info, width=width)
|
||||
return Response(raw, mimetype='application/octet-stream',
|
||||
headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'})
|
||||
|
||||
|
||||
# ─── Public Quote HTML Template ─────────────────────────────────────────────
|
||||
|
||||
PUBLIC_QUOTE_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Cotizacion #{{ quot.id }}</title>
|
||||
<style>
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#111;padding:16px;line-height:1.5}
|
||||
.card{max-width:640px;margin:0 auto;background:#fff;border-radius:12px;box-shadow:0 4px 20px rgba(0,0,0,0.08);overflow:hidden}
|
||||
.header{background:linear-gradient(135deg,#1f2937,#374151);color:#fff;padding:28px 24px;text-align:center}
|
||||
.header h1{font-size:22px;font-weight:700;margin-bottom:6px}
|
||||
.header p{font-size:13px;opacity:.85}
|
||||
.body{padding:24px}
|
||||
.meta{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:20px;font-size:13px;color:#4b5563}
|
||||
.meta div{background:#f9fafb;padding:10px 12px;border-radius:8px}
|
||||
.meta strong{color:#111;display:block;font-size:12px;text-transform:uppercase;letter-spacing:.4px;margin-bottom:2px}
|
||||
table{width:100%;border-collapse:collapse;font-size:14px;margin-bottom:16px}
|
||||
th{text-align:left;padding:10px 8px;background:#f3f4f6;color:#374151;font-size:11px;text-transform:uppercase;letter-spacing:.4px}
|
||||
td{padding:12px 8px;border-bottom:1px solid #e5e7eb;vertical-align:top}
|
||||
tr:last-child td{border-bottom:none}
|
||||
.part{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;color:#6b7280}
|
||||
.qty{text-align:center}
|
||||
.price{text-align:right;font-weight:600}
|
||||
.totals{border-top:2px solid #e5e7eb;padding-top:16px;text-align:right;font-size:14px}
|
||||
.totals div{margin-bottom:4px;color:#4b5563}
|
||||
.totals .big{font-size:22px;font-weight:800;color:#111;margin-top:8px}
|
||||
.actions{padding:0 24px 24px;text-align:center}
|
||||
.btn{display:inline-block;width:100%;padding:14px 20px;border-radius:10px;border:none;font-size:16px;font-weight:700;cursor:pointer;transition:transform .1s}
|
||||
.btn-primary{background:linear-gradient(135deg,#f59e0b,#d97706);color:#fff}
|
||||
.btn-primary:hover{transform:translateY(-1px)}
|
||||
.btn-primary:active{transform:translateY(0)}
|
||||
.btn-disabled{background:#e5e7eb;color:#9ca3af;cursor:not-allowed}
|
||||
.footer{text-align:center;padding:16px;font-size:12px;color:#9ca3af}
|
||||
.badge{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;font-weight:700;text-transform:uppercase}
|
||||
.badge-active{background:#d1fae5;color:#065f46}
|
||||
.badge-expired{background:#fee2e2;color:#991b1b}
|
||||
@media(min-width:480px){.meta{grid-template-columns:repeat(3,1fr)}.btn{width:auto;min-width:280px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<h1>Cotizacion #{{ quot.id }}</h1>
|
||||
<p>{{ host }}</p>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div><strong>Cliente</strong>{{ quot.customer_name or 'Publico general' }}</div>
|
||||
<div><strong>Fecha</strong>{{ quot.created_at[:10] if quot.created_at else '—' }}</div>
|
||||
<div><strong>Vigencia</strong>{{ quot.valid_until or '—' }} <span class="badge badge-{{ 'active' if quot.status == 'active' else 'expired' }}">{{ quot.status }}</span></div>
|
||||
</div>
|
||||
<table>
|
||||
<thead><tr><th>Descripcion</th><th class="qty">Cant</th><th class="price">P. Unit</th><th class="price">Subtotal</th></tr></thead>
|
||||
<tbody>
|
||||
{% for it in items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight:600">{{ it.name }}</div>
|
||||
<div class="part">{{ it.part_number }}</div>
|
||||
</td>
|
||||
<td class="qty">{{ it.quantity }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.unit_price) }}</td>
|
||||
<td class="price">${{ "{:,.2f}".format(it.subtotal) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals">
|
||||
<div>Subtotal: ${{ "{:,.2f}".format(quot.subtotal) }}</div>
|
||||
<div>IVA: ${{ "{:,.2f}".format(quot.tax_total) }}</div>
|
||||
<div class="big">Total: ${{ "{:,.2f}".format(quot.total) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
{% if quot.status == 'active' %}
|
||||
<button class="btn btn-primary" id="acceptBtn" onclick="acceptQuote()">Aceptar cotizacion</button>
|
||||
{% else %}
|
||||
<button class="btn btn-disabled" disabled>Cotizacion no disponible</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Precios sujetos a cambio sin previo aviso. Vigencia limitada.
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function acceptQuote(){
|
||||
var btn=document.getElementById('acceptBtn');
|
||||
btn.disabled=true;btn.textContent='Procesando...';
|
||||
fetch('/public/quote/{{ token }}/accept',{method:'POST'})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if(d.error){alert('Error: '+d.error);btn.disabled=false;btn.textContent='Aceptar cotizacion';}
|
||||
else{btn.textContent='Cotizacion aceptada';btn.className='btn btn-disabled';alert(d.message);}
|
||||
})
|
||||
.catch(function(){alert('Error de red');btn.disabled=false;btn.textContent='Aceptar cotizacion';});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user