Update app.py
Browse files
app.py
CHANGED
|
@@ -33,6 +33,8 @@ KASSAS_FILE = os.path.join(DATA_DIR, 'kassas.json')
|
|
| 33 |
EXPENSES_FILE = os.path.join(DATA_DIR, 'expenses.json')
|
| 34 |
PERSONAL_EXPENSES_FILE = os.path.join(DATA_DIR, 'personal_expenses.json')
|
| 35 |
SHIFTS_FILE = os.path.join(DATA_DIR, 'shifts.json')
|
|
|
|
|
|
|
| 36 |
|
| 37 |
DATA_FILES = {
|
| 38 |
'inventory': (INVENTORY_FILE, threading.Lock()),
|
|
@@ -42,6 +44,8 @@ DATA_FILES = {
|
|
| 42 |
'expenses': (EXPENSES_FILE, threading.Lock()),
|
| 43 |
'personal_expenses': (PERSONAL_EXPENSES_FILE, threading.Lock()),
|
| 44 |
'shifts': (SHIFTS_FILE, threading.Lock()),
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE")
|
|
@@ -1612,6 +1616,126 @@ def admin_logout():
|
|
| 1612 |
flash("Вы вышли из системы.", "info")
|
| 1613 |
return redirect(url_for('sales_screen'))
|
| 1614 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1615 |
BASE_TEMPLATE = """
|
| 1616 |
<!DOCTYPE html>
|
| 1617 |
<html lang="ru" data-bs-theme="light">
|
|
@@ -1665,6 +1789,7 @@ BASE_TEMPLATE = """
|
|
| 1665 |
<li><a class="dropdown-item" href="{{ url_for('product_roi_report') }}">Окупаемость товаров</a></li>
|
| 1666 |
</ul>
|
| 1667 |
</li>
|
|
|
|
| 1668 |
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_login', 'cashier_dashboard'] %}active{% endif %}" href="{{ url_for('cashier_login') }}"><i class="fas fa-fw fa-user-circle me-2"></i>Кабинет кассира</a></li>
|
| 1669 |
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['admin_panel', 'admin_shifts'] %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cogs me-2"></i>Админка</a></li>
|
| 1670 |
{% if session.admin_logged_in %}
|
|
@@ -1714,6 +1839,19 @@ BASE_TEMPLATE = """
|
|
| 1714 |
</div>
|
| 1715 |
<div class="form-text">Введите номер без +7.</div>
|
| 1716 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1717 |
<input type="hidden" id="receipt-url">
|
| 1718 |
</div>
|
| 1719 |
<div class="modal-footer">
|
|
@@ -1741,7 +1879,9 @@ BASE_TEMPLATE = """
|
|
| 1741 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 1742 |
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
| 1743 |
<script>
|
|
|
|
| 1744 |
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
| 1745 |
document.getElementById('sidebar-toggle')?.addEventListener('click', () => document.querySelector('.sidebar').classList.toggle('active'));
|
| 1746 |
const themeToggle = document.getElementById('theme-toggle');
|
| 1747 |
const getStoredTheme = () => localStorage.getItem('theme');
|
|
@@ -1757,6 +1897,92 @@ BASE_TEMPLATE = """
|
|
| 1757 |
setStoredTheme(newTheme);
|
| 1758 |
setTheme(newTheme);
|
| 1759 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1760 |
});
|
| 1761 |
</script>
|
| 1762 |
__SCRIPTS__
|
|
@@ -1835,6 +2061,10 @@ SALES_SCREEN_CONTENT = """
|
|
| 1835 |
</div>
|
| 1836 |
<div class="d-grid gap-2">
|
| 1837 |
<div class="btn-group"><button class="btn btn-success flex-grow-1" id="pay-cash-btn"><i class="fas fa-money-bill-wave me-2"></i>Наличные</button><button class="btn btn-info flex-grow-1" id="pay-card-btn"><i class="far fa-credit-card me-2"></i>Карта</button></div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1838 |
<button class="btn btn-danger" id="clear-cart-btn">Очистить</button>
|
| 1839 |
</div>
|
| 1840 |
</div>
|
|
@@ -1891,6 +2121,33 @@ SALES_SCREEN_CONTENT = """
|
|
| 1891 |
</div>
|
| 1892 |
</div>
|
| 1893 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1894 |
"""
|
| 1895 |
|
| 1896 |
SALES_SCREEN_SCRIPTS = """
|
|
@@ -1902,11 +2159,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 1902 |
let audioCtx;
|
| 1903 |
let isScannerPaused = false;
|
| 1904 |
|
| 1905 |
-
const receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
| 1906 |
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal'));
|
| 1907 |
const cashierLoginModal = new bootstrap.Modal(document.getElementById('cashierLoginModal'));
|
| 1908 |
const startShiftModal = new bootstrap.Modal(document.getElementById('startShiftModal'));
|
| 1909 |
const customItemModal = new bootstrap.Modal(document.getElementById('customItemModal'));
|
|
|
|
|
|
|
| 1910 |
const allProducts = {{ inventory|tojson|safe }};
|
| 1911 |
|
| 1912 |
const session = {
|
|
@@ -2199,18 +2457,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2199 |
|
| 2200 |
document.getElementById('pay-cash-btn').addEventListener('click', () => completeSale('cash'));
|
| 2201 |
document.getElementById('pay-card-btn').addEventListener('click', () => completeSale('card'));
|
| 2202 |
-
|
| 2203 |
-
document.getElementById('send-whatsapp-btn').addEventListener('click', () => {
|
| 2204 |
-
const phone = document.getElementById('whatsapp-phone').value.replace(/\\D/g, '');
|
| 2205 |
-
const receiptUrl = document.getElementById('receipt-url').value;
|
| 2206 |
-
if (phone && receiptUrl) {
|
| 2207 |
-
const fullPhone = '7' + phone;
|
| 2208 |
-
const message = encodeURIComponent(`Ваша накладная: ${receiptUrl}`);
|
| 2209 |
-
window.open(`https://wa.me/${fullPhone}?text=${message}`, '_blank');
|
| 2210 |
-
} else {
|
| 2211 |
-
alert('Введите номер телефона.');
|
| 2212 |
-
}
|
| 2213 |
-
});
|
| 2214 |
|
| 2215 |
const html5QrCode = new Html5Qrcode("reader");
|
| 2216 |
const scannerStatusEl = document.getElementById('scanner-status');
|
|
@@ -2431,8 +2677,121 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 2431 |
}
|
| 2432 |
});
|
| 2433 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2434 |
updateCartView();
|
| 2435 |
initializeSession();
|
|
|
|
| 2436 |
});
|
| 2437 |
</script>
|
| 2438 |
"""
|
|
@@ -3466,6 +3825,55 @@ RETURN_PAGE_CONTENT = """
|
|
| 3466 |
</div>
|
| 3467 |
"""
|
| 3468 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3469 |
if __name__ == '__main__':
|
| 3470 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 3471 |
backup_thread.start()
|
|
|
|
| 33 |
EXPENSES_FILE = os.path.join(DATA_DIR, 'expenses.json')
|
| 34 |
PERSONAL_EXPENSES_FILE = os.path.join(DATA_DIR, 'personal_expenses.json')
|
| 35 |
SHIFTS_FILE = os.path.join(DATA_DIR, 'shifts.json')
|
| 36 |
+
HELD_BILLS_FILE = os.path.join(DATA_DIR, 'held_bills.json')
|
| 37 |
+
CUSTOMERS_FILE = os.path.join(DATA_DIR, 'customers.json')
|
| 38 |
|
| 39 |
DATA_FILES = {
|
| 40 |
'inventory': (INVENTORY_FILE, threading.Lock()),
|
|
|
|
| 44 |
'expenses': (EXPENSES_FILE, threading.Lock()),
|
| 45 |
'personal_expenses': (PERSONAL_EXPENSES_FILE, threading.Lock()),
|
| 46 |
'shifts': (SHIFTS_FILE, threading.Lock()),
|
| 47 |
+
'held_bills': (HELD_BILLS_FILE, threading.Lock()),
|
| 48 |
+
'customers': (CUSTOMERS_FILE, threading.Lock()),
|
| 49 |
}
|
| 50 |
|
| 51 |
HF_TOKEN_WRITE = os.getenv("HF_TOKEN_WRITE", "YOUR_WRITE_TOKEN_HERE")
|
|
|
|
| 1616 |
flash("Вы вышли из системы.", "info")
|
| 1617 |
return redirect(url_for('sales_screen'))
|
| 1618 |
|
| 1619 |
+
@app.route('/api/held_bills', methods=['GET'])
|
| 1620 |
+
def get_held_bills():
|
| 1621 |
+
bills = load_json_data('held_bills')
|
| 1622 |
+
bills.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
|
| 1623 |
+
return jsonify(bills)
|
| 1624 |
+
|
| 1625 |
+
@app.route('/api/hold_bill', methods=['POST'])
|
| 1626 |
+
def hold_bill():
|
| 1627 |
+
data = request.json
|
| 1628 |
+
if not data or 'name' not in data or 'cart' not in data:
|
| 1629 |
+
return jsonify({'success': False, 'message': 'Invalid data'}), 400
|
| 1630 |
+
|
| 1631 |
+
bills = load_json_data('held_bills')
|
| 1632 |
+
new_bill = {
|
| 1633 |
+
'id': uuid.uuid4().hex,
|
| 1634 |
+
'name': data['name'],
|
| 1635 |
+
'cart': data['cart'],
|
| 1636 |
+
'timestamp': get_current_time().isoformat()
|
| 1637 |
+
}
|
| 1638 |
+
bills.append(new_bill)
|
| 1639 |
+
save_json_data('held_bills', bills)
|
| 1640 |
+
upload_db_to_hf('held_bills')
|
| 1641 |
+
return jsonify({'success': True, 'bill': new_bill})
|
| 1642 |
+
|
| 1643 |
+
@app.route('/api/restore_bill/<bill_id>', methods=['POST'])
|
| 1644 |
+
def restore_bill(bill_id):
|
| 1645 |
+
bills = load_json_data('held_bills')
|
| 1646 |
+
bill_to_restore = find_item_by_field(bills, 'id', bill_id)
|
| 1647 |
+
if not bill_to_restore:
|
| 1648 |
+
return jsonify({'success': False, 'message': 'Bill not found'}), 404
|
| 1649 |
+
|
| 1650 |
+
remaining_bills = [b for b in bills if b.get('id') != bill_id]
|
| 1651 |
+
save_json_data('held_bills', remaining_bills)
|
| 1652 |
+
upload_db_to_hf('held_bills')
|
| 1653 |
+
return jsonify({'success': True, 'bill': bill_to_restore})
|
| 1654 |
+
|
| 1655 |
+
@app.route('/api/hold_bill/<bill_id>', methods=['DELETE'])
|
| 1656 |
+
def delete_held_bill(bill_id):
|
| 1657 |
+
bills = load_json_data('held_bills')
|
| 1658 |
+
initial_len = len(bills)
|
| 1659 |
+
bills = [b for b in bills if b.get('id') != bill_id]
|
| 1660 |
+
if len(bills) < initial_len:
|
| 1661 |
+
save_json_data('held_bills', bills)
|
| 1662 |
+
upload_db_to_hf('held_bills')
|
| 1663 |
+
return jsonify({'success': True})
|
| 1664 |
+
else:
|
| 1665 |
+
return jsonify({'success': False, 'message': 'Bill not found'}), 404
|
| 1666 |
+
|
| 1667 |
+
@app.route('/customers')
|
| 1668 |
+
@admin_required
|
| 1669 |
+
def customer_management():
|
| 1670 |
+
customers = load_json_data('customers')
|
| 1671 |
+
customers.sort(key=lambda x: x.get('name', '').lower())
|
| 1672 |
+
html = BASE_TEMPLATE.replace('__TITLE__', "Клиенты").replace('__CONTENT__', CUSTOMERS_CONTENT).replace('__SCRIPTS__', '')
|
| 1673 |
+
return render_template_string(html, customers=customers)
|
| 1674 |
+
|
| 1675 |
+
@app.route('/admin/customer', methods=['POST'])
|
| 1676 |
+
@admin_required
|
| 1677 |
+
def manage_customer():
|
| 1678 |
+
action = request.form.get('action')
|
| 1679 |
+
customers = load_json_data('customers')
|
| 1680 |
+
|
| 1681 |
+
if action == 'add':
|
| 1682 |
+
name = request.form.get('name', '').strip()
|
| 1683 |
+
phone = request.form.get('phone', '').strip().replace('+', '').replace(' ', '')
|
| 1684 |
+
if name and phone:
|
| 1685 |
+
if not find_item_by_field(customers, 'phone', phone):
|
| 1686 |
+
new_customer = {'id': uuid.uuid4().hex, 'name': name, 'phone': phone}
|
| 1687 |
+
customers.append(new_customer)
|
| 1688 |
+
flash(f"Клиент '{name}' добавлен.", "success")
|
| 1689 |
+
else:
|
| 1690 |
+
flash(f"Клиент с номером {phone} уже существует.", "warning")
|
| 1691 |
+
else:
|
| 1692 |
+
flash("Имя и телефон обязательны.", "danger")
|
| 1693 |
+
|
| 1694 |
+
elif action == 'delete':
|
| 1695 |
+
customer_id = request.form.get('id')
|
| 1696 |
+
initial_len = len(customers)
|
| 1697 |
+
customers = [c for c in customers if c.get('id') != customer_id]
|
| 1698 |
+
if len(customers) < initial_len:
|
| 1699 |
+
flash("Клиент удален.", "success")
|
| 1700 |
+
else:
|
| 1701 |
+
flash("Клиент не найден.", "warning")
|
| 1702 |
+
|
| 1703 |
+
save_json_data('customers', customers)
|
| 1704 |
+
upload_db_to_hf('customers')
|
| 1705 |
+
return redirect(url_for('customer_management'))
|
| 1706 |
+
|
| 1707 |
+
@app.route('/api/customers/search')
|
| 1708 |
+
def search_customers():
|
| 1709 |
+
query = request.args.get('q', '').lower()
|
| 1710 |
+
if not query:
|
| 1711 |
+
return jsonify([])
|
| 1712 |
+
customers = load_json_data('customers')
|
| 1713 |
+
matches = [
|
| 1714 |
+
c for c in customers
|
| 1715 |
+
if query in c.get('name', '').lower() or query in c.get('phone', '')
|
| 1716 |
+
]
|
| 1717 |
+
return jsonify(matches[:10])
|
| 1718 |
+
|
| 1719 |
+
@app.route('/api/customers/save', methods=['POST'])
|
| 1720 |
+
def save_customer():
|
| 1721 |
+
data = request.json
|
| 1722 |
+
name = data.get('name', '').strip()
|
| 1723 |
+
phone = data.get('phone', '').strip()
|
| 1724 |
+
if not name or not phone:
|
| 1725 |
+
return jsonify({'success': False, 'message': 'Name and phone required'}), 400
|
| 1726 |
+
|
| 1727 |
+
customers = load_json_data('customers')
|
| 1728 |
+
existing_customer = find_item_by_field(customers, 'phone', phone)
|
| 1729 |
+
if existing_customer:
|
| 1730 |
+
existing_customer['name'] = name
|
| 1731 |
+
else:
|
| 1732 |
+
new_customer = {'id': uuid.uuid4().hex, 'name': name, 'phone': phone}
|
| 1733 |
+
customers.append(new_customer)
|
| 1734 |
+
|
| 1735 |
+
save_json_data('customers', customers)
|
| 1736 |
+
upload_db_to_hf('customers')
|
| 1737 |
+
return jsonify({'success': True})
|
| 1738 |
+
|
| 1739 |
BASE_TEMPLATE = """
|
| 1740 |
<!DOCTYPE html>
|
| 1741 |
<html lang="ru" data-bs-theme="light">
|
|
|
|
| 1789 |
<li><a class="dropdown-item" href="{{ url_for('product_roi_report') }}">Окупаемость товаров</a></li>
|
| 1790 |
</ul>
|
| 1791 |
</li>
|
| 1792 |
+
<li class="nav-item"><a class="nav-link {% if request.endpoint == 'customer_management' %}active{% endif %}" href="{{ url_for('customer_management') }}"><i class="fas fa-fw fa-users me-2"></i>Клиенты</a></li>
|
| 1793 |
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['cashier_login', 'cashier_dashboard'] %}active{% endif %}" href="{{ url_for('cashier_login') }}"><i class="fas fa-fw fa-user-circle me-2"></i>Кабинет кассира</a></li>
|
| 1794 |
<li class="nav-item"><a class="nav-link {% if request.endpoint in ['admin_panel', 'admin_shifts'] %}active{% endif %}" href="{{ url_for('admin_panel') }}"><i class="fas fa-fw fa-cogs me-2"></i>Админка</a></li>
|
| 1795 |
{% if session.admin_logged_in %}
|
|
|
|
| 1839 |
</div>
|
| 1840 |
<div class="form-text">Введите номер без +7.</div>
|
| 1841 |
</div>
|
| 1842 |
+
<div class="mb-3">
|
| 1843 |
+
<label for="customer-search" class="form-label">Поиск клиента</label>
|
| 1844 |
+
<input type="text" id="customer-search" class="form-control" placeholder="Начните вводить имя...">
|
| 1845 |
+
<div id="customer-search-results" class="list-group mt-1" style="max-height: 150px; overflow-y: auto;"></div>
|
| 1846 |
+
</div>
|
| 1847 |
+
<div class="form-check">
|
| 1848 |
+
<input class="form-check-input" type="checkbox" id="save-customer-checkbox">
|
| 1849 |
+
<label class="form-check-label" for="save-customer-checkbox">Сохранить/обновить клиента</label>
|
| 1850 |
+
</div>
|
| 1851 |
+
<div class="mb-3 mt-2" id="customer-name-container" style="display:none;">
|
| 1852 |
+
<label for="customer-name-input" class="form-label">Имя клиента</label>
|
| 1853 |
+
<input type="text" class="form-control" id="customer-name-input">
|
| 1854 |
+
</div>
|
| 1855 |
<input type="hidden" id="receipt-url">
|
| 1856 |
</div>
|
| 1857 |
<div class="modal-footer">
|
|
|
|
| 1879 |
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
| 1880 |
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
| 1881 |
<script>
|
| 1882 |
+
let receiptModal;
|
| 1883 |
document.addEventListener('DOMContentLoaded', () => {
|
| 1884 |
+
receiptModal = new bootstrap.Modal(document.getElementById('receiptModal'));
|
| 1885 |
document.getElementById('sidebar-toggle')?.addEventListener('click', () => document.querySelector('.sidebar').classList.toggle('active'));
|
| 1886 |
const themeToggle = document.getElementById('theme-toggle');
|
| 1887 |
const getStoredTheme = () => localStorage.getItem('theme');
|
|
|
|
| 1897 |
setStoredTheme(newTheme);
|
| 1898 |
setTheme(newTheme);
|
| 1899 |
});
|
| 1900 |
+
|
| 1901 |
+
const receiptModalEl = document.getElementById('receiptModal');
|
| 1902 |
+
if (receiptModalEl) {
|
| 1903 |
+
const saveCustomerCheckbox = document.getElementById('save-customer-checkbox');
|
| 1904 |
+
const customerNameContainer = document.getElementById('customer-name-container');
|
| 1905 |
+
const customerSearchInput = document.getElementById('customer-search');
|
| 1906 |
+
const customerSearchResults = document.getElementById('customer-search-results');
|
| 1907 |
+
const whatsappPhoneInput = document.getElementById('whatsapp-phone');
|
| 1908 |
+
const customerNameInput = document.getElementById('customer-name-input');
|
| 1909 |
+
const sendWhatsappBtn = document.getElementById('send-whatsapp-btn');
|
| 1910 |
+
|
| 1911 |
+
saveCustomerCheckbox.addEventListener('change', () => {
|
| 1912 |
+
customerNameContainer.style.display = saveCustomerCheckbox.checked ? 'block' : 'none';
|
| 1913 |
+
});
|
| 1914 |
+
|
| 1915 |
+
let searchTimeout;
|
| 1916 |
+
customerSearchInput.addEventListener('input', () => {
|
| 1917 |
+
clearTimeout(searchTimeout);
|
| 1918 |
+
const query = customerSearchInput.value.trim();
|
| 1919 |
+
if (query.length < 2) {
|
| 1920 |
+
customerSearchResults.innerHTML = '';
|
| 1921 |
+
return;
|
| 1922 |
+
}
|
| 1923 |
+
searchTimeout = setTimeout(() => {
|
| 1924 |
+
fetch(`/api/customers/search?q=${encodeURIComponent(query)}`)
|
| 1925 |
+
.then(res => res.json())
|
| 1926 |
+
.then(customers => {
|
| 1927 |
+
customerSearchResults.innerHTML = '';
|
| 1928 |
+
if (customers.length > 0) {
|
| 1929 |
+
customers.forEach(customer => {
|
| 1930 |
+
const item = document.createElement('a');
|
| 1931 |
+
item.href = '#';
|
| 1932 |
+
item.className = 'list-group-item list-group-item-action';
|
| 1933 |
+
item.textContent = `${customer.name} - ${customer.phone}`;
|
| 1934 |
+
item.addEventListener('click', (e) => {
|
| 1935 |
+
e.preventDefault();
|
| 1936 |
+
whatsappPhoneInput.value = customer.phone;
|
| 1937 |
+
customerNameInput.value = customer.name;
|
| 1938 |
+
customerSearchInput.value = customer.name;
|
| 1939 |
+
customerSearchResults.innerHTML = '';
|
| 1940 |
+
});
|
| 1941 |
+
customerSearchResults.appendChild(item);
|
| 1942 |
+
});
|
| 1943 |
+
} else {
|
| 1944 |
+
customerSearchResults.innerHTML = '<div class="list-group-item">Клиенты не найдены.</div>';
|
| 1945 |
+
}
|
| 1946 |
+
});
|
| 1947 |
+
}, 300);
|
| 1948 |
+
});
|
| 1949 |
+
|
| 1950 |
+
sendWhatsappBtn.addEventListener('click', () => {
|
| 1951 |
+
const phone = whatsappPhoneInput.value.replace(/\\D/g, '');
|
| 1952 |
+
const receiptUrl = document.getElementById('receipt-url').value;
|
| 1953 |
+
if (!phone || !receiptUrl) {
|
| 1954 |
+
alert('Введите номер телефона.');
|
| 1955 |
+
return;
|
| 1956 |
+
}
|
| 1957 |
+
const fullPhone = '7' + phone;
|
| 1958 |
+
const message = encodeURIComponent(`Ваша накладная: ${receiptUrl}`);
|
| 1959 |
+
window.open(`https://wa.me/${fullPhone}?text=${message}`, '_blank');
|
| 1960 |
+
|
| 1961 |
+
if (saveCustomerCheckbox.checked) {
|
| 1962 |
+
const name = customerNameInput.value.trim();
|
| 1963 |
+
if (name && phone) {
|
| 1964 |
+
fetch('/api/customers/save', {
|
| 1965 |
+
method: 'POST',
|
| 1966 |
+
headers: {'Content-Type': 'application/json'},
|
| 1967 |
+
body: JSON.stringify({name: name, phone: phone})
|
| 1968 |
+
})
|
| 1969 |
+
.then(res => res.json())
|
| 1970 |
+
.then(data => {
|
| 1971 |
+
if (!data.success) console.error('Failed to save customer:', data.message);
|
| 1972 |
+
});
|
| 1973 |
+
}
|
| 1974 |
+
}
|
| 1975 |
+
});
|
| 1976 |
+
|
| 1977 |
+
receiptModalEl.addEventListener('show.bs.modal', () => {
|
| 1978 |
+
whatsappPhoneInput.value = '';
|
| 1979 |
+
customerSearchInput.value = '';
|
| 1980 |
+
customerNameInput.value = '';
|
| 1981 |
+
customerSearchResults.innerHTML = '';
|
| 1982 |
+
saveCustomerCheckbox.checked = false;
|
| 1983 |
+
customerNameContainer.style.display = 'none';
|
| 1984 |
+
});
|
| 1985 |
+
}
|
| 1986 |
});
|
| 1987 |
</script>
|
| 1988 |
__SCRIPTS__
|
|
|
|
| 2061 |
</div>
|
| 2062 |
<div class="d-grid gap-2">
|
| 2063 |
<div class="btn-group"><button class="btn btn-success flex-grow-1" id="pay-cash-btn"><i class="fas fa-money-bill-wave me-2"></i>Наличные</button><button class="btn btn-info flex-grow-1" id="pay-card-btn"><i class="far fa-credit-card me-2"></i>Карта</button></div>
|
| 2064 |
+
<div class="btn-group">
|
| 2065 |
+
<button class="btn btn-secondary" id="hold-bill-btn"><i class="fas fa-pause me-2"></i>Отложить накладную</button>
|
| 2066 |
+
<button class="btn btn-secondary" id="list-held-bills-btn"><i class="fas fa-list me-2"></i>Отложенные <span id="held-bills-count" class="badge bg-primary ms-1" style="display:none;"></span></button>
|
| 2067 |
+
</div>
|
| 2068 |
<button class="btn btn-danger" id="clear-cart-btn">Очистить</button>
|
| 2069 |
</div>
|
| 2070 |
</div>
|
|
|
|
| 2121 |
</div>
|
| 2122 |
</div>
|
| 2123 |
</div>
|
| 2124 |
+
|
| 2125 |
+
<div class="modal fade" id="holdBillModal" tabindex="-1">
|
| 2126 |
+
<div class="modal-dialog">
|
| 2127 |
+
<div class="modal-content">
|
| 2128 |
+
<div class="modal-header"><h5 class="modal-title">Отложить накладную</h5></div>
|
| 2129 |
+
<form id="hold-bill-form">
|
| 2130 |
+
<div class="modal-body">
|
| 2131 |
+
<label for="hold-bill-name" class="form-label">Название для этой накладной</label>
|
| 2132 |
+
<input type="text" id="hold-bill-name" class="form-control" placeholder="Напр. 'Стол 5', 'Мария'" required>
|
| 2133 |
+
</div>
|
| 2134 |
+
<div class="modal-footer"><button type="submit" class="btn btn-primary">Отложить</button></div>
|
| 2135 |
+
</form>
|
| 2136 |
+
</div>
|
| 2137 |
+
</div>
|
| 2138 |
+
</div>
|
| 2139 |
+
|
| 2140 |
+
<div class="modal fade" id="heldBillsListModal" tabindex="-1">
|
| 2141 |
+
<div class="modal-dialog modal-lg">
|
| 2142 |
+
<div class="modal-content">
|
| 2143 |
+
<div class="modal-header"><h5 class="modal-title">Отложенные накладные</h5></div>
|
| 2144 |
+
<div class="modal-body">
|
| 2145 |
+
<div id="held-bills-list" class="list-group">
|
| 2146 |
+
</div>
|
| 2147 |
+
</div>
|
| 2148 |
+
</div>
|
| 2149 |
+
</div>
|
| 2150 |
+
</div>
|
| 2151 |
"""
|
| 2152 |
|
| 2153 |
SALES_SCREEN_SCRIPTS = """
|
|
|
|
| 2159 |
let audioCtx;
|
| 2160 |
let isScannerPaused = false;
|
| 2161 |
|
|
|
|
| 2162 |
const variantSelectModal = new bootstrap.Modal(document.getElementById('variantSelectModal'));
|
| 2163 |
const cashierLoginModal = new bootstrap.Modal(document.getElementById('cashierLoginModal'));
|
| 2164 |
const startShiftModal = new bootstrap.Modal(document.getElementById('startShiftModal'));
|
| 2165 |
const customItemModal = new bootstrap.Modal(document.getElementById('customItemModal'));
|
| 2166 |
+
const holdBillModal = new bootstrap.Modal(document.getElementById('holdBillModal'));
|
| 2167 |
+
const heldBillsListModal = new bootstrap.Modal(document.getElementById('heldBillsListModal'));
|
| 2168 |
const allProducts = {{ inventory|tojson|safe }};
|
| 2169 |
|
| 2170 |
const session = {
|
|
|
|
| 2457 |
|
| 2458 |
document.getElementById('pay-cash-btn').addEventListener('click', () => completeSale('cash'));
|
| 2459 |
document.getElementById('pay-card-btn').addEventListener('click', () => completeSale('card'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2460 |
|
| 2461 |
const html5QrCode = new Html5Qrcode("reader");
|
| 2462 |
const scannerStatusEl = document.getElementById('scanner-status');
|
|
|
|
| 2677 |
}
|
| 2678 |
});
|
| 2679 |
|
| 2680 |
+
const updateHeldBillsCount = () => {
|
| 2681 |
+
fetch('/api/held_bills')
|
| 2682 |
+
.then(res => res.json())
|
| 2683 |
+
.then(data => {
|
| 2684 |
+
const count = data.length;
|
| 2685 |
+
const badge = document.getElementById('held-bills-count');
|
| 2686 |
+
if (count > 0) {
|
| 2687 |
+
badge.textContent = count;
|
| 2688 |
+
badge.style.display = '';
|
| 2689 |
+
} else {
|
| 2690 |
+
badge.style.display = 'none';
|
| 2691 |
+
}
|
| 2692 |
+
});
|
| 2693 |
+
};
|
| 2694 |
+
|
| 2695 |
+
document.getElementById('hold-bill-btn').addEventListener('click', () => {
|
| 2696 |
+
if (Object.keys(cart).length === 0) {
|
| 2697 |
+
alert('Корзина пуста. Нечего откладывать.');
|
| 2698 |
+
return;
|
| 2699 |
+
}
|
| 2700 |
+
document.getElementById('hold-bill-form').reset();
|
| 2701 |
+
holdBillModal.show();
|
| 2702 |
+
});
|
| 2703 |
+
|
| 2704 |
+
document.getElementById('hold-bill-form').addEventListener('submit', (e) => {
|
| 2705 |
+
e.preventDefault();
|
| 2706 |
+
const name = document.getElementById('hold-bill-name').value.trim();
|
| 2707 |
+
if (!name) return;
|
| 2708 |
+
|
| 2709 |
+
fetch('/api/hold_bill', {
|
| 2710 |
+
method: 'POST',
|
| 2711 |
+
headers: {'Content-Type': 'application/json'},
|
| 2712 |
+
body: JSON.stringify({ name: name, cart: cart })
|
| 2713 |
+
})
|
| 2714 |
+
.then(res => res.json())
|
| 2715 |
+
.then(data => {
|
| 2716 |
+
if (data.success) {
|
| 2717 |
+
for(const id in cart) delete cart[id];
|
| 2718 |
+
updateCartView();
|
| 2719 |
+
updateHeldBillsCount();
|
| 2720 |
+
holdBillModal.hide();
|
| 2721 |
+
} else {
|
| 2722 |
+
alert('Ошибка: ' + data.message);
|
| 2723 |
+
}
|
| 2724 |
+
});
|
| 2725 |
+
});
|
| 2726 |
+
|
| 2727 |
+
document.getElementById('list-held-bills-btn').addEventListener('click', () => {
|
| 2728 |
+
fetch('/api/held_bills')
|
| 2729 |
+
.then(res => res.json())
|
| 2730 |
+
.then(bills => {
|
| 2731 |
+
const listEl = document.getElementById('held-bills-list');
|
| 2732 |
+
listEl.innerHTML = '';
|
| 2733 |
+
if (bills.length === 0) {
|
| 2734 |
+
listEl.innerHTML = '<p class="text-center text-muted">Нет отложенных накладных.</p>';
|
| 2735 |
+
} else {
|
| 2736 |
+
bills.forEach(bill => {
|
| 2737 |
+
listEl.innerHTML += `
|
| 2738 |
+
<div class="list-group-item d-flex justify-content-between align-items-center">
|
| 2739 |
+
<span><strong>${bill.name}</strong> - ${new Date(bill.timestamp).toLocaleTimeString('ru-RU')}</span>
|
| 2740 |
+
<div>
|
| 2741 |
+
<button class="btn btn-sm btn-success restore-bill-btn" data-id="${bill.id}">Восстановить</button>
|
| 2742 |
+
<button class="btn btn-sm btn-danger delete-held-bill-btn ms-2" data-id="${bill.id}">Удалить</button>
|
| 2743 |
+
</div>
|
| 2744 |
+
</div>`;
|
| 2745 |
+
});
|
| 2746 |
+
}
|
| 2747 |
+
heldBillsListModal.show();
|
| 2748 |
+
});
|
| 2749 |
+
});
|
| 2750 |
+
|
| 2751 |
+
document.getElementById('held-bills-list').addEventListener('click', (e) => {
|
| 2752 |
+
const billId = e.target.dataset.id;
|
| 2753 |
+
if (!billId) return;
|
| 2754 |
+
|
| 2755 |
+
if (e.target.classList.contains('restore-bill-btn')) {
|
| 2756 |
+
if (Object.keys(cart).length > 0 && !confirm('Текущая корзина будет заменена. Продолжить?')) {
|
| 2757 |
+
return;
|
| 2758 |
+
}
|
| 2759 |
+
fetch(`/api/restore_bill/${billId}`, { method: 'POST' })
|
| 2760 |
+
.then(res => res.json())
|
| 2761 |
+
.then(data => {
|
| 2762 |
+
if (data.success) {
|
| 2763 |
+
for(const id in cart) delete cart[id];
|
| 2764 |
+
Object.assign(cart, data.bill.cart);
|
| 2765 |
+
updateCartView();
|
| 2766 |
+
updateHeldBillsCount();
|
| 2767 |
+
heldBillsListModal.hide();
|
| 2768 |
+
} else {
|
| 2769 |
+
alert('Ошибка: ' + data.message);
|
| 2770 |
+
}
|
| 2771 |
+
});
|
| 2772 |
+
}
|
| 2773 |
+
|
| 2774 |
+
if (e.target.classList.contains('delete-held-bill-btn')) {
|
| 2775 |
+
if (!confirm('Удалить эту отложенную накладную?')) return;
|
| 2776 |
+
fetch(`/api/hold_bill/${billId}`, { method: 'DELETE' })
|
| 2777 |
+
.then(res => res.json())
|
| 2778 |
+
.then(data => {
|
| 2779 |
+
if (data.success) {
|
| 2780 |
+
e.target.closest('.list-group-item').remove();
|
| 2781 |
+
updateHeldBillsCount();
|
| 2782 |
+
if (document.getElementById('held-bills-list').children.length === 0) {
|
| 2783 |
+
document.getElementById('held-bills-list').innerHTML = '<p class="text-center text-muted">Нет отложенных накладных.</p>';
|
| 2784 |
+
}
|
| 2785 |
+
} else {
|
| 2786 |
+
alert('Ошибка: ' + data.message);
|
| 2787 |
+
}
|
| 2788 |
+
});
|
| 2789 |
+
}
|
| 2790 |
+
});
|
| 2791 |
+
|
| 2792 |
updateCartView();
|
| 2793 |
initializeSession();
|
| 2794 |
+
updateHeldBillsCount();
|
| 2795 |
});
|
| 2796 |
</script>
|
| 2797 |
"""
|
|
|
|
| 3825 |
</div>
|
| 3826 |
"""
|
| 3827 |
|
| 3828 |
+
CUSTOMERS_CONTENT = """
|
| 3829 |
+
<div class="card">
|
| 3830 |
+
<div class="card-header d-flex justify-content-between align-items-center">
|
| 3831 |
+
<h5 class="mb-0">Клиенты</h5>
|
| 3832 |
+
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#addCustomerModal"><i class="fas fa-plus me-2"></i>Добавить клиента</button>
|
| 3833 |
+
</div>
|
| 3834 |
+
<div class="card-body">
|
| 3835 |
+
<div class="table-responsive">
|
| 3836 |
+
<table class="table table-hover">
|
| 3837 |
+
<thead><tr><th>Имя</th><th>Телефон</th><th></th></tr></thead>
|
| 3838 |
+
<tbody>
|
| 3839 |
+
{% for c in customers %}
|
| 3840 |
+
<tr>
|
| 3841 |
+
<td>{{ c.name }}</td>
|
| 3842 |
+
<td>{{ c.phone }}</td>
|
| 3843 |
+
<td class="text-end">
|
| 3844 |
+
<form action="{{ url_for('manage_customer') }}" method="POST" onsubmit="return confirm('Удалить этого клиента?');">
|
| 3845 |
+
<input type="hidden" name="action" value="delete">
|
| 3846 |
+
<input type="hidden" name="id" value="{{ c.id }}">
|
| 3847 |
+
<button type="submit" class="btn btn-sm btn-outline-danger"><i class="fas fa-trash"></i></button>
|
| 3848 |
+
</form>
|
| 3849 |
+
</td>
|
| 3850 |
+
</tr>
|
| 3851 |
+
{% else %}
|
| 3852 |
+
<tr><td colspan="3" class="text-center">Список клиентов пуст.</td></tr>
|
| 3853 |
+
{% endfor %}
|
| 3854 |
+
</tbody>
|
| 3855 |
+
</table>
|
| 3856 |
+
</div>
|
| 3857 |
+
</div>
|
| 3858 |
+
</div>
|
| 3859 |
+
|
| 3860 |
+
<div class="modal fade" id="addCustomerModal" tabindex="-1">
|
| 3861 |
+
<div class="modal-dialog">
|
| 3862 |
+
<div class="modal-content">
|
| 3863 |
+
<div class="modal-header"><h5 class="modal-title">Новый клиент</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
| 3864 |
+
<form action="{{ url_for('manage_customer') }}" method="POST">
|
| 3865 |
+
<input type="hidden" name="action" value="add">
|
| 3866 |
+
<div class="modal-body">
|
| 3867 |
+
<div class="mb-3"><label class="form-label">Имя</label><input type="text" name="name" class="form-control" required></div>
|
| 3868 |
+
<div class="mb-3"><label class="form-label">Телефон</label><input type="tel" name="phone" class="form-control" placeholder="7071234567" required></div>
|
| 3869 |
+
</div>
|
| 3870 |
+
<div class="modal-footer"><button type="submit" class="btn btn-primary">Сохранить</button></div>
|
| 3871 |
+
</form>
|
| 3872 |
+
</div>
|
| 3873 |
+
</div>
|
| 3874 |
+
</div>
|
| 3875 |
+
"""
|
| 3876 |
+
|
| 3877 |
if __name__ == '__main__':
|
| 3878 |
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 3879 |
backup_thread.start()
|