/
Velodostup
/
Velodostup_admin
Обзор
Документация
Войти
/
Velodostup
/
Velodostup_admin
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
app.py
8 286 строк
392 KB
Velodostup
create: app.py
13 апр 2026, 18:27
Верифицирован
13 апр 2026, 18:27
38b49d2
Код
Авторство
О чём код?
from flask import Flask, render_template_string, request, redirect, url_for, session, flash, jsonify, send_from_directory, make_response, send_file import os import json import uuid import sqlite3 from datetime import datetime, timedelta import pytz from functools import wraps from werkzeug.utils import secure_filename import io import xlsxwriter import mimetypes app = Flask(__name__) ## ===================== НАСТРОЙКИ ПУТЕЙ ===================== import os BASE_DIR = os.path.dirname(os.path.abspath(__file__)) # Принудительно используем /data/ для Amvera DATA_DIR = '/data' if not os.path.exists(DATA_DIR): DATA_DIR = BASE_DIR app.config['UPLOAD_FOLDER'] = os.path.join(DATA_DIR, 'photos') app.config['DATABASE'] = os.path.join(DATA_DIR, 'velodostup.db') os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) os.makedirs(os.path.join(BASE_DIR, 'static'), exist_ok=True) print(f"Папка для фото: {app.config['UPLOAD_FOLDER']}", flush=True) print(f"Путь к базе данных: {app.config['DATABASE']}", flush=True) # Проверяем старую базу в /app/ и копируем если нужно old_db = '/app/velodostup.db' if os.path.exists(old_db) and not os.path.exists(app.config['DATABASE']): import shutil shutil.copy2(old_db, app.config['DATABASE']) print(f"✅ База скопирована из {old_db} в {app.config['DATABASE']}", flush=True) if os.path.exists(app.config['DATABASE']): print(f"✅ База данных найдена! Размер: {os.path.getsize(app.config['DATABASE'])} байт", flush=True) else: print(f"⚠️ База данных не найдена, будет создана новая", flush=True) app.secret_key = "velodostup_secret_key_2025" app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} def allowed_file(filename): return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) CHELYABINSK_TZ = pytz.timezone('Asia/Yekaterinburg') def get_now(): return datetime.now(CHELYABINSK_TZ) # ===================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ===================== def safe_int(value, default=0): try: return int(value) except (ValueError, TypeError): return default def safe_float(value, default=0.0): try: return float(value) except (ValueError, TypeError): return default def log_error(message, exc=None): print(f"ERROR: {message}", flush=True) if exc: print(f"Exception: {exc}", flush=True) # ===================== РАСЧЁТ ВРЕМЕНИ ===================== def calculate_hours_diff(start_str, end_str): if not start_str or not end_str: return 0, 0 try: start = datetime.fromisoformat(start_str) end = datetime.fromisoformat(end_str) if start.tzinfo is None: start = CHELYABINSK_TZ.localize(start) if end.tzinfo is None: end = CHELYABINSK_TZ.localize(end) if end <= start: return 0, 0 diff_seconds = (end - start).total_seconds() diff_hours = diff_seconds / 3600 hours = int(diff_hours) half_hours = int((diff_hours - hours) * 2) if half_hours > 0: return hours, half_hours return hours, 0 except: return 0, 0 # ===================== РАСЧЁТ СТОИМОСТИ ===================== def calculate_price(category, hours, half_hours, has_card=False, tariff_type='hourly', days=1): tariffs = { 'adult': { 'hourly_first': 600, 'hourly_first_card': 500, 'hourly_next': 200, 'daily': 2000, 'daily_card': 1500, 'night': 1000, 'night_card': 800 }, 'child': { 'hourly_first': 400, 'hourly_first_card': 300, 'hourly_next': 200, 'daily': 2000, 'daily_card': 1500, 'night': 1000, 'night_card': 800 }, 'sup': { 'daily_first': 1500, 'daily_next': 1000, 'weekly': 6000 } } t = tariffs.get(category, tariffs['adult']) if category == 'sup': if tariff_type == 'daily': if days == 1: return t['daily_first'] else: return t['daily_first'] + (days - 1) * t['daily_next'] elif tariff_type == 'weekly': return t['weekly'] else: if days == 1: return t['daily_first'] else: return t['daily_first'] + (days - 1) * t['daily_next'] if tariff_type == 'hourly': if hours == 0 and half_hours > 0: half_hours = 1 if hours == 0 and half_hours == 0: return 0 if hours == 0 and half_hours == 1: return t['hourly_first_card'] if has_card else t['hourly_first'] total = (t['hourly_first_card'] if has_card else t['hourly_first']) if hours > 1: total += (hours - 1) * t['hourly_next'] if half_hours > 0: total += (t['hourly_next'] / 2) * half_hours return int(total) elif tariff_type == 'daily': return (t['daily_card'] if has_card else t['daily']) * days elif tariff_type == 'night': return t['night_card'] if has_card else t['night'] return 0 # ===================== ПОЛУЧЕНИЕ ПУНКТА СОТРУДНИКА ===================== def get_employee_point(employee_id): if not employee_id: return None, None conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT point_id, point_name FROM employees WHERE id = ?", (employee_id,)) row = cursor.fetchone() if row: return row[0], row[1] return None, None except Exception as e: log_error("Ошибка в get_employee_point", e) return None, None finally: if conn: conn.close() # ===================== ДЕКОРАТОРЫ ===================== def login_required(f): @wraps(f) def decorated(*args, **kwargs): if 'user_id' not in session: return redirect(url_for('login')) return f(*args, **kwargs) return decorated def admin_required(f): @wraps(f) def decorated(*args, **kwargs): if 'user_id' not in session or session.get('role') != 'admin': flash('Доступ запрещён. Только для администратора.', 'error') return redirect(url_for('dashboard')) return f(*args, **kwargs) return decorated # ===================== РАБОТА С БАЗОЙ ДАННЫХ ===================== def get_db(): conn = sqlite3.connect(app.config['DATABASE']) conn.row_factory = sqlite3.Row return conn # ===================== CSS СТИЛИ ===================== CSS_STYLES = """ <style> :root { --bg-primary: #0f172a; --bg-secondary: #1e293b; --bg-card: #1e293b; --bg-header: #1a2538; --text-primary: #e2e8f0; --text-secondary: #cbd5e1; --text-muted: #94a3b8; --border: #334155; --accent: #3b82f6; --accent-hover: #2563eb; --accent-gradient: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); --success: #10b981; --danger: #7f1d1d; --warning: #f59e0b; --input-bg: #0f172a; --input-border: #334155; } [data-theme="light"] { --bg-primary: #f0f9ff; --bg-secondary: #ffffff; --bg-card: #ffffff; --bg-header: #e0f2fe; --text-primary: #0f172a; --text-secondary: #334155; --text-muted: #64748b; --border: #cbd5e1; --accent: #2563eb; --accent-hover: #1d4ed8; --input-bg: #ffffff; --input-border: #cbd5e1; --danger: #dc2626; } * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; background: var(--bg-primary); color: var(--text-primary); overflow-x: hidden; } .header { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-header) 100%); border-bottom: 1px solid var(--border); padding: 0 16px; height: 60px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 100; } .logo { display: flex; align-items: center; gap: 10px; } .logo span { font-size: 1.1rem; font-weight: 700; background: var(--accent-gradient); -webkit-background-clip: text; background-clip: text; color: transparent; } .theme-toggle { background: var(--bg-primary); border: 1px solid var(--border); color: var(--accent); font-size: 1.2rem; cursor: pointer; padding: 6px 12px; border-radius: 40px; margin-right: 10px; } .logout-btn { background: linear-gradient(135deg, var(--danger) 0%, #991b1b 100%); padding: 6px 14px; border-radius: 40px; color: #fff; text-decoration: none; font-size: 0.75rem; display: inline-flex; align-items: center; gap: 6px; } .menu-toggle { display: none; background: none; border: none; color: var(--accent); font-size: 1.5rem; cursor: pointer; padding: 8px; } .sidebar { width: 260px; background: var(--bg-secondary); border-right: 1px solid var(--border); padding: 20px 12px; position: sticky; top: 60px; height: calc(100vh - 60px); overflow-y: auto; } .sidebar-link { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--text-secondary); text-decoration: none; font-weight: 500; border-radius: 12px; margin: 4px 0; font-size: 0.9rem; } .sidebar-link i { width: 22px; font-size: 1.1rem; text-align: center; color: var(--accent); } .sidebar-link:hover { background: var(--bg-primary); color: var(--accent); } .sidebar-link.active { background: var(--accent-gradient); color: #fff; } .main-content { flex: 1; padding: 16px; overflow-x: hidden; background: var(--bg-primary); } .app-layout { display: flex; min-height: calc(100vh - 60px); } .card { background: var(--bg-card); border-radius: 20px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); margin-bottom: 20px; border: 1px solid var(--border); overflow: hidden; } .card-header { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px; background: var(--bg-header); } .card-header h2 { font-size: 1.1rem; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 8px; } .dashboard-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 20px; } .dashboard-card { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-header) 100%); padding: 16px; border-radius: 20px; text-align: center; border: 1px solid var(--border); } .dashboard-card h3 { font-size: 0.8rem; color: var(--text-muted); margin-bottom: 8px; display: flex; align-items: center; justify-content: center; gap: 6px; } .dashboard-card .number { font-size: 1.6rem; font-weight: 700; color: var(--accent); } .btn-primary, .btn-outline, .btn-danger, .btn-success, .btn-warning { padding: 8px 16px; border-radius: 40px; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; text-decoration: none; border: none; transition: all 0.2s ease; white-space: nowrap; } .btn-primary { background: var(--accent-gradient); color: #fff; } .btn-primary:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); } .btn-outline { background: transparent; border: 2px solid var(--accent); color: var(--accent); } .btn-outline:hover { background: var(--accent); color: #fff; } .btn-success { background: linear-gradient(135deg, var(--success) 0%, #059669 100%); color: #fff; } .btn-danger { background: linear-gradient(135deg, var(--danger) 0%, #991b1b 100%); color: #fff; } .btn-warning { background: linear-gradient(135deg, var(--warning) 0%, #d97706 100%); color: #fff; } .quick-actions { display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 0; } .filters-bar { display: flex; gap: 10px; flex-wrap: wrap; padding: 12px 16px; background: var(--bg-header); border-bottom: 1px solid var(--border); } .search-input, .filter-select { padding: 8px 14px; border: 1px solid var(--border); border-radius: 40px; font-size: 0.8rem; background: var(--input-bg); color: var(--text-primary); } /* ========== ТАБЛИЦЫ ========== */ .table-wrapper { overflow-x: auto; overflow-y: visible; width: 100%; padding: 0; } .data-table { width: 100%; border-collapse: collapse; font-size: 0.75rem; min-width: 100%; table-layout: auto; } .data-table th, .data-table td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); color: var(--text-secondary); } .data-table td { white-space: normal; word-wrap: break-word; max-width: 250px; } .data-table td:first-child, .data-table td:nth-child(7), .data-table td:nth-child(8), .data-table td:nth-child(9) { white-space: nowrap; } .data-table th { background: var(--bg-header); color: var(--accent); font-weight: 600; white-space: nowrap; } .data-table tr:hover { background: var(--bg-primary); } .data-table td:nth-child(5) { max-width: 200px; white-space: normal; word-wrap: break-word; line-height: 1.4; } .data-table td:nth-child(4) { max-width: 180px; white-space: normal; word-wrap: break-word; } .badge { display: inline-block; padding: 3px 10px; border-radius: 30px; font-size: 0.65rem; font-weight: 600; white-space: nowrap; } .badge-free { background: #065f46; color: #34d399; } .badge-rented { background: #1e3a5f; color: #60a5fa; } .badge-active { background: #065f46; color: #34d399; } .badge-closed { background: #450a0a; color: #f87171; } .badge-pending { background: #78350f; color: #fbbf24; } .badge-progress { background: #1e3a5f; color: #60a5fa; } .badge-done { background: #065f46; color: #34d399; } .badge-success { background: #065f46; color: #34d399; } .badge-warning { background: #78350f; color: #fbbf24; } .form-group { margin-bottom: 16px; } .form-group label { font-size: 0.8rem; margin-bottom: 6px; display: block; color: var(--text-muted); } .form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 14px; border: 1px solid var(--border); border-radius: 14px; font-size: 0.85rem; background: var(--input-bg); color: var(--text-primary); transition: all 0.2s ease; } .form-group input:focus, .form-group select:focus, .form-group textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } .form-row { display: flex; gap: 12px; flex-wrap: wrap; } .form-row .form-group { flex: 1; min-width: 120px; } .pagination { display: flex; justify-content: center; gap: 8px; padding: 16px; border-top: 1px solid var(--border); } .page-btn { padding: 6px 12px; border: 1px solid var(--border); background: var(--input-bg); color: var(--accent); border-radius: 10px; cursor: pointer; font-size: 0.8rem; } .phone-link { color: var(--accent); text-decoration: none; } .btn-icon { background: transparent; border: none; cursor: pointer; font-size: 1rem; padding: 4px 8px; border-radius: 8px; color: var(--accent); transition: all 0.2s ease; } .btn-icon:hover { background: var(--bg-primary); } .btn-icon-danger { color: var(--danger); } .btn-icon-danger:hover { background: rgba(220, 38, 38, 0.1); } /* ========== МЕНЮ (ТРИ ТОЧКИ И ПРАВЫЙ КЛИК) ========== */ .actions-menu { position: relative; display: inline-block; } .actions-trigger { background: none; border: none; cursor: pointer; font-size: 1.2rem; padding: 6px 10px; border-radius: 8px; color: var(--accent); transition: all 0.2s ease; } .actions-trigger:hover { background: var(--bg-primary); } .actions-dropdown { display: none; position: fixed; background: var(--bg-card); min-width: 160px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); border-radius: 12px; z-index: 9999; overflow: hidden; border: 1px solid var(--border); animation: fadeIn 0.15s ease; } .actions-dropdown.show { display: block; } .actions-dropdown a { display: flex; align-items: center; gap: 10px; padding: 10px 16px; color: var(--text-secondary); text-decoration: none; font-size: 0.8rem; transition: all 0.15s ease; } .actions-dropdown a i { width: 20px; font-size: 0.9rem; color: var(--accent); } .actions-dropdown a:hover { background: var(--accent); color: #fff; } .actions-dropdown a:hover i { color: #fff; } .menu-dropdown { display: none; position: fixed; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; min-width: 180px; max-height: 300px; overflow-y: auto; z-index: 99999; box-shadow: 0 8px 24px rgba(0,0,0,0.4); } .menu-dropdown.show { display: block; } .menu-dropdown a { display: flex; align-items: center; gap: 12px; padding: 12px 16px; color: var(--text-primary); text-decoration: none; font-size: 0.9rem; border-bottom: 1px solid var(--border); } .menu-dropdown a:last-child { border-bottom: none; } .menu-dropdown a:hover { background: var(--accent); color: white; } .menu-dropdown a.danger { color: var(--danger); } .menu-dropdown a.danger:hover { background: var(--danger); color: white; } @keyframes fadeIn { from { opacity: 0; transform: translateY(-5px); } to { opacity: 1; transform: translateY(0); } } /* ========== СТАТУС ДРОПДАУН ========== */ .status-dropdown { position: relative; display: inline-block; } .status-badge { cursor: pointer; display: inline-flex; align-items: center; gap: 4px; } .status-dropdown-content { display: none; position: absolute; background: var(--bg-card); min-width: 140px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); border-radius: 14px; z-index: 1000; overflow: hidden; border: 1px solid var(--border); } .status-dropdown-content.show { display: block; } .status-dropdown-content a { color: var(--text-secondary); padding: 10px 16px; text-decoration: none; display: flex; align-items: center; gap: 8px; font-size: 0.8rem; } .status-dropdown-content a:hover { background: var(--accent); color: #fff; } /* ========== КЛИКАБЕЛЬНЫЕ СТРОКИ ========== */ .clickable-row { cursor: pointer; } /* ========== ИСПРАВЛЕНИЯ ДЛЯ ПРОКАТА ========== */ .bike-select { background: var(--input-bg) !important; color: var(--text-primary) !important; border: 1px solid var(--border) !important; border-radius: 10px !important; padding: 8px 10px !important; font-size: 0.8rem !important; } .bike-select option { background: var(--bg-card) !important; color: var(--text-primary) !important; padding: 8px !important; } .bike-item { background: var(--bg-primary) !important; border-radius: 12px !important; padding: 10px !important; margin-bottom: 8px !important; border: 1px solid var(--border) !important; } .bikes-scroll-container { max-height: 280px; overflow-y: auto; padding-right: 4px; background: transparent !important; } .list-card { background: var(--bg-card) !important; border-radius: 20px !important; border: 1px solid var(--border) !important; overflow: hidden !important; } .list-card-header { border-radius: 20px 20px 0 0 !important; overflow: hidden !important; } .list-card .table-wrapper { border-radius: 0 0 20px 20px !important; overflow: hidden !important; } .list-card .pagination { border-radius: 0 0 20px 20px !important; } select.bike-select, select[name="tariff_type"], select[name="payment_type"] { background: var(--input-bg) !important; color: var(--text-primary) !important; border: 1px solid var(--border) !important; } select option { background: var(--bg-card) !important; color: var(--text-primary) !important; } select, select option { background-color: var(--input-bg) !important; color: var(--text-primary) !important; } [data-theme="light"] select option { background: var(--bg-card) !important; color: var(--text-primary) !important; } [data-theme="light"] .bike-select { background: var(--input-bg) !important; color: var(--text-primary) !important; } [data-theme="light"] .bike-select option { background: var(--bg-card) !important; color: var(--text-primary) !important; } /* ========== СТИЛИ ДЛЯ ЗАВЕРШЕНИЯ ПРОКАТА ========== */ .close-rental-options { display: flex; flex-direction: column; gap: 12px; margin-top: 15px; } .close-option-card { display: block; padding: 16px; background: var(--bg-primary); border: 2px solid var(--border); border-radius: 16px; cursor: pointer; transition: all 0.2s ease; } .close-option-card:hover { border-color: var(--accent); background: var(--bg-secondary); transform: translateX(4px); } .close-option-card.selected { border-color: var(--accent); background: linear-gradient(135deg, rgba(59, 130, 246, 0.1) 0%, var(--bg-primary) 100%); box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); } .close-option-card input[type="radio"] { display: none; } .close-option-header { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; } .close-option-header i { font-size: 1.5rem; color: var(--accent); width: 30px; text-align: center; } .close-option-header strong { font-size: 1rem; color: var(--text-primary); } .close-option-desc { margin-left: 42px; color: var(--text-muted); font-size: 0.8rem; } .close-option-desc small { display: block; margin-top: 4px; color: var(--accent); } .custom-time-block { margin-top: 16px; padding: 16px; background: var(--bg-primary); border-radius: 14px; border: 1px solid var(--border); } .custom-time-block label { display: block; margin-bottom: 8px; color: var(--text-secondary); font-weight: 500; } .custom-time-block input { width: 100%; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); font-size: 0.9rem; } .price-summary { background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); padding: 20px; border-radius: 16px; margin: 20px 0; text-align: center; border: 1px solid var(--border); } .price-summary-label { font-size: 0.85rem; color: var(--text-muted); margin-bottom: 8px; display: flex; align-items: center; justify-content: center; gap: 8px; } .price-summary-value { font-size: 2.5rem; font-weight: 700; color: var(--accent); } .price-summary-value small { font-size: 1rem; color: var(--text-muted); font-weight: normal; } .price-change-indicator { margin-top: 8px; padding: 6px 12px; border-radius: 20px; font-size: 0.75rem; display: inline-block; } .price-change-up { background: rgba(220, 38, 38, 0.15); color: #f87171; } .price-change-down { background: rgba(16, 185, 129, 0.15); color: #34d399; } .price-change-same { background: rgba(148, 163, 184, 0.15); color: var(--text-muted); } /* ========== АДАПТИВНОСТЬ ========== */ @media (max-width: 768px) { .menu-toggle { display: block; } .sidebar { position: fixed; top: 60px; left: 0; transform: translateX(-100%); z-index: 99; width: 260px; height: calc(100vh - 60px); transition: transform 0.3s ease; } .sidebar.open { transform: translateX(0); } .app-layout { display: block; } .main-content { padding: 12px; } .dashboard-stats { grid-template-columns: repeat(2, 1fr); } .user-name { display: none; } .logout-btn span { display: none; } .form-row { flex-direction: column; } .card-header { flex-direction: column; align-items: stretch; } .card-header a, .card-header button { width: 100%; justify-content: center; } .data-table td { max-width: 150px; } } .main-content::-webkit-scrollbar { width: 6px; } .main-content::-webkit-scrollbar-track { background: var(--bg-primary); } .main-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } .main-content::-webkit-scrollbar-thumb:hover { background: var(--accent); } .share-buttons { display: flex; gap: 8px; margin-left: 5px; } .share-btn { width: 38px; height: 38px; border-radius: 50%; border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 16px; transition: all 0.2s; color: white; } .share-btn.telegram { background: #0088cc; } .share-btn.telegram:hover { background: #006699; transform: scale(1.05); } .share-btn.copy { background: #64748b; } .share-btn.copy:hover { background: #475569; transform: scale(1.05); } .bike-tag { background: var(--bg-primary); padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); } .status-badge { padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; } .status-active { background: #065f46; color: #34d399; } .status-closed { background: #374151; color: #9ca3af; } </style> """ # ===================== LAYOUT ===================== def layout(content): is_admin = session.get('role') == 'admin' admin_menu = ''' <a href="/users" class="sidebar-link"><i class="fas fa-users"></i> <span>Пользователи</span></a> <a href="/expense" class="sidebar-link"><i class="fas fa-receipt"></i> <span>Расходы</span></a> <a href="/settings" class="sidebar-link"><i class="fas fa-sliders-h"></i> <span>Настройки</span></a> ''' if is_admin else '' html = f"""<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Велодоступ | Прокат велосипедов</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> {CSS_STYLES} <style> .sidebar-link--has-children {{ cursor: pointer; display: flex; align-items: center; justify-content: space-between; }} .sidebar-link--has-children .sidebar-link__arrow {{ transition: transform 0.3s ease; font-size: 0.7rem; }} .sidebar-link--has-children.open .sidebar-link__arrow {{ transform: rotate(180deg); }} .sidebar-submenu {{ padding-left: 20px; max-height: 0; overflow: hidden; transition: max-height 0.3s ease-out; }} .sidebar-submenu.open {{ max-height: 300px; }} .sidebar-link--child {{ padding-left: 44px !important; }} </style> </head> <body> <div class="header"> <div class="logo"> <span style="font-size: 1.3rem; font-weight: 700;">🚲 Велодоступ</span> </div> <div style="display:flex; align-items:center; gap:10px;"> <button class="theme-toggle" onclick="toggleTheme()" id="themeToggle"> <i class="fas fa-moon"></i> </button> <button class="menu-toggle" onclick="toggleMobileSidebar()"><i class="fas fa-bars"></i></button> <div> <span><i class="fas fa-user-circle"></i> <span class="user-name">{session.get('fullname', 'Гость')}</span></span> <a href="/logout" class="logout-btn"><i class="fas fa-sign-out-alt"></i> <span>Выйти</span></a> </div> </div> </div> <div class="app-layout"> <aside class="sidebar" id="sidebar"> <a href="/dashboard" class="sidebar-link"><i class="fas fa-tachometer-alt"></i> <span>Главная</span></a> <div class="sidebar-link sidebar-link--has-children" onclick="toggleSubmenu(this)"> <i class="fas fa-bicycle"></i> <span>Прокат</span> <i class="fas fa-chevron-down sidebar-link__arrow"></i> </div> <div class="sidebar-submenu"> <a href="/rentals" class="sidebar-link sidebar-link--child"><i class="fas fa-file-signature"></i> <span>Аренды</span></a> <a href="/inventory" class="sidebar-link sidebar-link--child"><i class="fas fa-boxes"></i> <span>Инвентарь</span></a> </div> <div class="sidebar-link sidebar-link--has-children" onclick="toggleSubmenu(this)"> <i class="fas fa-tools"></i> <span>Сервис</span> <i class="fas fa-chevron-down sidebar-link__arrow"></i> </div> <div class="sidebar-submenu"> <a href="/service/repairs" class="sidebar-link sidebar-link--child"><i class="fas fa-clipboard-list"></i> <span>Ремонт</span></a> <a href="/services" class="sidebar-link sidebar-link--child"><i class="fas fa-wrench"></i> <span>Услуги</span></a> <a href="/parts" class="sidebar-link sidebar-link--child"><i class="fas fa-microchip"></i> <span>Запчасти</span></a> </div> <a href="/clients" class="sidebar-link"><i class="fas fa-user-friends"></i> <span>Клиенты</span></a> <div class="sidebar-link sidebar-link--has-children" onclick="toggleSubmenu(this)"> <i class="fas fa-user-cog"></i> <span>Сотрудники</span> <i class="fas fa-chevron-down sidebar-link__arrow"></i> </div> <div class="sidebar-submenu"> <a href="/employees" class="sidebar-link sidebar-link--child"><i class="fas fa-users"></i> <span>Список</span></a> <a href="/salary" class="sidebar-link sidebar-link--child"><i class="fas fa-money-bill-wave"></i> <span>Зарплата</span></a> <a href="/work_schedule" class="sidebar-link sidebar-link--child"><i class="fas fa-calendar-alt"></i> <span>График работы</span></a> </div> <a href="/reports" class="sidebar-link"><i class="fas fa-chart-line"></i> <span>Отчёты</span></a> <a href="/analytics" class="sidebar-link"><i class="fas fa-chart-pie"></i> <span>Аналитика</span></a> {admin_menu} </aside> <main class="main-content">{content}</main> </div> <script> function toggleMobileSidebar() {{ document.getElementById('sidebar').classList.toggle('open'); }} function toggleSubmenu(element) {{ element.classList.toggle('open'); const submenu = element.nextElementSibling; submenu.classList.toggle('open'); }} document.addEventListener('click', function(event) {{ var sidebar = document.getElementById('sidebar'); var toggle = document.querySelector('.menu-toggle'); if (sidebar.classList.contains('open') && !sidebar.contains(event.target) && !toggle.contains(event.target)) {{ sidebar.classList.remove('open'); }} document.querySelectorAll('.actions-dropdown.show').forEach(function(dropdown) {{ if (!dropdown.parentElement.contains(event.target)) {{ dropdown.classList.remove('show'); }} }}); }}); function toggleTheme() {{ const html = document.documentElement; const currentTheme = html.getAttribute('data-theme'); const newTheme = currentTheme === 'light' ? 'dark' : 'light'; html.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); const toggleIcon = document.querySelector('#themeToggle i'); if (newTheme === 'light') {{ toggleIcon.classList.remove('fa-moon'); toggleIcon.classList.add('fa-sun'); }} else {{ toggleIcon.classList.remove('fa-sun'); toggleIcon.classList.add('fa-moon'); }} }} const savedTheme = localStorage.getItem('theme'); if (savedTheme) {{ document.documentElement.setAttribute('data-theme', savedTheme); const toggleIcon = document.querySelector('#themeToggle i'); if (savedTheme === 'light') {{ toggleIcon.classList.remove('fa-moon'); toggleIcon.classList.add('fa-sun'); }} }} function toggleActionsMenu(button) {{ event.stopPropagation(); const dropdown = button.nextElementSibling; document.querySelectorAll('.actions-dropdown.show').forEach(menu => {{ if (menu !== dropdown) menu.classList.remove('show'); }}); dropdown.classList.toggle('show'); if (dropdown.classList.contains('show')) {{ const rect = button.getBoundingClientRect(); let left = rect.right - 150; let top = rect.bottom + 5; if (left + 150 > window.innerWidth) left = window.innerWidth - 160; if (left < 10) left = 10; if (top + 200 > window.innerHeight) top = rect.top - 210; if (top < 10) top = 10; dropdown.style.position = 'fixed'; dropdown.style.left = left + 'px'; dropdown.style.top = top + 'px'; dropdown.style.zIndex = '9999'; }} }} function initClickableRows() {{ document.querySelectorAll('.clickable-row').forEach(row => {{ row.removeEventListener('click', row._clickHandler); row.removeEventListener('contextmenu', row._contextHandler); row._clickHandler = function(e) {{ if (e.target.tagName === 'A' || e.target.tagName === 'BUTTON' || e.target.closest('.actions-menu') || e.target.closest('.status-dropdown') || e.target.closest('select')) {{ return; }} const url = this.getAttribute('data-url'); if (url) {{ window.location.href = url; }} }}; row._contextHandler = function(e) {{ e.preventDefault(); e.stopPropagation(); document.querySelectorAll('.actions-dropdown.show').forEach(menu => {{ menu.classList.remove('show'); }}); const menu = this.querySelector('.actions-dropdown'); if (menu) {{ const rect = this.getBoundingClientRect(); let left = e.clientX; let top = e.clientY; if (left + 160 > window.innerWidth) left = window.innerWidth - 170; if (left < 10) left = 10; if (top + 200 > window.innerHeight) top = window.innerHeight - 210; if (top < 10) top = 10; menu.style.position = 'fixed'; menu.style.left = left + 'px'; menu.style.top = top + 'px'; menu.style.zIndex = '9999'; menu.classList.add('show'); setTimeout(() => {{ const closeMenu = function(event) {{ if (!menu.contains(event.target)) {{ menu.classList.remove('show'); document.removeEventListener('click', closeMenu); document.removeEventListener('contextmenu', closeMenu); }} }}; document.addEventListener('click', closeMenu); document.addEventListener('contextmenu', closeMenu); }}, 10); }} }}; row.addEventListener('click', row._clickHandler); row.addEventListener('contextmenu', row._contextHandler); }}); }} document.addEventListener('click', function(e) {{ if (!e.target.closest('.actions-menu')) {{ document.querySelectorAll('.actions-dropdown.show').forEach(menu => {{ menu.classList.remove('show'); }}); }} }}); document.addEventListener('contextmenu', function(e) {{ // НЕ ТРОГАЕМ СТРОКИ С ПРОКАТОМ if (e.target.closest('.clickable-row')) {{ return; // Пропускаем, пусть обрабатывает наш новый код }} document.querySelectorAll('.actions-dropdown.show, .menu-dropdown.show').forEach(menu => {{ menu.classList.remove('show'); }}); }}); document.addEventListener('DOMContentLoaded', function() {{ initClickableRows(); }}); const observer = new MutationObserver(function(mutations) {{ initClickableRows(); }}); const tableWrapper = document.querySelector('.table-wrapper'); if (tableWrapper) {{ observer.observe(tableWrapper, {{ childList: true, subtree: true }}); }} window.toggleActionsMenu = toggleActionsMenu; </script> <script> (function() {{ var scrollPos = sessionStorage.getItem('scrollPos'); if (scrollPos) {{ window.scrollTo(0, parseInt(scrollPos)); sessionStorage.removeItem('scrollPos'); }} window.addEventListener('beforeunload', function() {{ sessionStorage.setItem('scrollPos', window.scrollY); }}); var currentUrl = new URL(window.location.href); var filters = {{}}; for (var key of ['status', 'point', 'page', 'search', 'category', 'payment', 'employee']) {{ if (currentUrl.searchParams.has(key)) {{ filters[key] = currentUrl.searchParams.get(key); }} }} document.addEventListener('click', function(e) {{ var link = e.target.closest('a'); if (link && link.href && link.href.includes(window.location.pathname) && !link.href.startsWith('javascript:')) {{ var url = new URL(link.href); for (var key in filters) {{ if (!url.searchParams.has(key)) {{ url.searchParams.set(key, filters[key]); }} }} if (url.toString() !== link.href) {{ e.preventDefault(); sessionStorage.setItem('scrollPos', window.scrollY); window.location.href = url.toString(); }} }} }}); document.addEventListener('click', function(e) {{ var btn = e.target.closest('button:not([type="submit"]), .btn-icon, .actions-trigger'); if (btn && !e.target.closest('a')) {{ sessionStorage.setItem('scrollPos', window.scrollY); }} }}); document.addEventListener('submit', function() {{ sessionStorage.setItem('scrollPos', window.scrollY); }}); }})(); </script> </body> </html>""" response = make_response(html) response.headers['Content-Type'] = 'text/html; charset=utf-8' return response # ===================== ИНИЦИАЛИЗАЦИЯ БД ===================== def init_db(): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, fullname TEXT NOT NULL, role TEXT DEFAULT 'user', employee_id INTEGER, permissions TEXT DEFAULT '{}' ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS points ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS employees ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, point_id INTEGER, point_name TEXT, phone TEXT, passport TEXT, address TEXT, salary_per_shift REAL DEFAULT 2000, repair_percent REAL DEFAULT 50, parts_percent REAL DEFAULT 10, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS clients ( id INTEGER PRIMARY KEY AUTOINCREMENT, last_name TEXT NOT NULL, first_name TEXT NOT NULL, middle_name TEXT, phone TEXT NOT NULL, passport TEXT, address TEXT, has_card INTEGER DEFAULT 0, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS client_photos ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER, filename TEXT, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS inventory ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT, category_name TEXT, point_id INTEGER, point_name TEXT, status TEXT DEFAULT 'free', created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS rentals ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER, client_name TEXT, client_phone TEXT, inventory_id INTEGER, inventory_name TEXT, inventory_category TEXT, point_name TEXT, employee_id INTEGER, tariff_type TEXT, hours INTEGER, half_hours INTEGER, days INTEGER, has_card INTEGER DEFAULT 0, price REAL, payment_type TEXT, status TEXT DEFAULT 'active', start_time TEXT, end_time TEXT, created_at TEXT, closed_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS repairs ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_id INTEGER, client_name TEXT, client_phone TEXT, employee_id INTEGER, service_ids TEXT, service_names TEXT, bike_name TEXT, description TEXT, price REAL, payment_type TEXT, payment_status TEXT DEFAULT 'unpaid', status TEXT DEFAULT 'pending', created_at TEXT, closed_at TEXT, admin_name TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS services ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, description TEXT, category TEXT, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS parts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, sku TEXT, point_id INTEGER, point_name TEXT, quantity INTEGER DEFAULT 0, purchase_price REAL, selling_price REAL, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS income ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT, category TEXT, amount REAL, payment_type TEXT, point_name TEXT, employee_id INTEGER, comment TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS expense ( id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT, category TEXT, amount REAL, comment TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS expense_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS shifts ( id INTEGER PRIMARY KEY AUTOINCREMENT, employee_id INTEGER, date TEXT, status TEXT, started_at TEXT, completed_at TEXT ) ''') cursor.execute(''' CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ) ''') cursor.execute("SELECT COUNT(*) FROM points") if cursor.fetchone()[0] == 0: cursor.execute("INSERT INTO points (name) VALUES ('ЧМЗ (парк Тищенко)')") cursor.execute("INSERT INTO points (name) VALUES ('Лыжная база')") cursor.execute("INSERT INTO points (name) VALUES ('Универ')") default_settings = { 'enable_repairs': '1', 'enable_parts': '1', 'enable_services': '1', 'enable_employees': '1', 'enable_reports': '1', 'enable_salary': '1', 'enable_deposits': '0', 'enable_penalties': '1', 'enable_card_discount': '1', 'auto_close_rental_days': '30' } for key, value in default_settings.items(): cursor.execute("INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)", (key, value)) cursor.execute("SELECT COUNT(*) FROM expense_categories") if cursor.fetchone()[0] == 0: default_categories = ['ЗП', 'Аренда места', 'Хоз', 'Велосипед', 'Реклама', 'Коммунальные', 'Транспорт', 'Ремонт', 'Канцелярия'] for cat in default_categories: cursor.execute("INSERT INTO expense_categories (name, created_at) VALUES (?, ?)", (cat, get_now().isoformat())) conn.commit() except Exception as e: log_error("Ошибка при инициализации БД", e) if conn: conn.rollback() finally: if conn: conn.close() # ===================== АВТОРИЗАЦИЯ ===================== @app.route('/') def index(): return redirect(url_for('login')) @app.route('/setup', methods=['GET', 'POST']) def setup(): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM users") if cursor.fetchone()[0] == 0 and request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '').strip() fullname = request.form.get('fullname', '').strip() if not username or not password or not fullname: flash('Все поля обязательны', 'error') return redirect(url_for('setup')) cursor.execute("INSERT INTO users (username, password, fullname, role) VALUES (?, ?, ?, ?)", (username, password, fullname, 'admin')) conn.commit() flash('Администратор создан!', 'success') return redirect(url_for('login')) except Exception as e: log_error("Ошибка при создании администратора", e) flash('Произошла ошибка', 'error') finally: if conn: conn.close() return render_template_string(""" <!DOCTYPE html> <html> <head> <title>Установка Велодоступ</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body{background:#0f172a;font-family:Arial;padding:16px} .card{max-width:500px;margin:50px auto;background:#1e293b;border-radius:24px;padding:24px;border:1px solid #334155} input,button{width:100%;padding:12px;margin:8px 0;border-radius:12px;background:#0f172a;border:1px solid #334155;color:#fff} button{background:#3b82f6;color:#fff;border:none;cursor:pointer} h1{color:#60a5fa} </style> </head> <body> <div class='card'> <h1>🚲 Установка Велодоступ</h1> <form method=POST> <input name=username placeholder="Логин" required> <input name=password type=password placeholder="Пароль" required> <input name=fullname placeholder="ФИО" required> <button>Создать администратора</button> </form> </div> </body> </html> """) @app.route('/login', methods=['GET', 'POST']) def login(): if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '').strip() conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM users WHERE username = ? AND password = ?", (username, password)) user = cursor.fetchone() if user: session['user_id'] = user['username'] session['fullname'] = user['fullname'] session['role'] = user['role'] session['employee_id'] = user['employee_id'] return redirect(url_for('dashboard')) flash('Неверный логин или пароль', 'error') except Exception as e: log_error("Ошибка при входе", e) flash('Произошла ошибка', 'error') finally: if conn: conn.close() return render_template_string(""" <!DOCTYPE html> <html> <head> <title>Вход | Велодоступ</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <style> body{background:#0f172a;font-family:Arial;padding:16px} .card{max-width:400px;margin:100px auto;background:#1e293b;border-radius:24px;padding:24px;border:1px solid #334155} input,button{width:100%;padding:12px;margin:8px 0;border-radius:12px;background:#0f172a;border:1px solid #334155;color:#fff} button{background:#3b82f6;color:#fff;border:none;cursor:pointer} .link{text-align:center;margin-top:16px} .link a{color:#60a5fa;text-decoration:none} h1{color:#60a5fa} .error{color:#f87171;margin:10px 0;padding:10px;background:#450a0a;border-radius:8px} </style> </head> <body> <div class='card'> <h1>🚲 Велодоступ</h1> {% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} <div class="error">{{ message }}</div> {% endfor %} {% endif %} {% endwith %} <form method=POST> <input name=username placeholder="Логин" required> <input name=password type=password placeholder="Пароль" required> <button>Войти</button> </form> <div class='link'><a href='/setup'>Первый запуск? Создать администратора</a></div> </div> </body> </html> """) @app.route('/logout') def logout(): session.clear() return redirect(url_for('login')) @app.after_request def force_html(response): if 'text/html' in response.headers.get('Content-Type', ''): response.headers['Content-Type'] = 'text/html; charset=utf-8' response.headers['X-Content-Type-Options'] = 'nosniff' return response # ===================== ДАШБОРД ===================== @app.route('/dashboard') @login_required def dashboard(): conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, employee_point_name = get_employee_point(employee_id) cursor.execute("SELECT id, name FROM points ORDER BY name") all_points = cursor.fetchall() if not is_admin and employee_point: points_to_show = [p for p in all_points if p['id'] == employee_point] else: points_to_show = all_points total_inventory_all = 0 total_free_all = 0 total_rented_all = 0 total_repair_all = 0 total_active_rentals = 0 total_active_repairs = 0 points_stats = [] for point in points_to_show: point_id = point['id'] point_name = point['name'] cursor.execute("SELECT COUNT(*) FROM inventory WHERE point_id = ?", (point_id,)) total_inventory = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM inventory WHERE point_id = ? AND status = 'free'", (point_id,)) free_inventory = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM inventory WHERE point_id = ? AND status = 'rented'", (point_id,)) rented_inventory = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM inventory WHERE point_id = ? AND status = 'repair'", (point_id,)) repair_inventory = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM rentals WHERE point_name = ? AND status = 'active'", (point_name,)) active_rentals = cursor.fetchone()[0] cursor.execute(""" SELECT COUNT(*) FROM repairs r JOIN employees e ON r.employee_id = e.id WHERE e.point_id = ? AND r.status IN ('pending', 'progress') """, (point_id,)) active_repairs = cursor.fetchone()[0] points_stats.append({ 'id': point_id, 'name': point_name, 'total': total_inventory, 'free': free_inventory, 'rented': rented_inventory, 'repair': repair_inventory, 'active_rentals': active_rentals, 'active_repairs': active_repairs }) total_inventory_all += total_inventory total_free_all += free_inventory total_rented_all += rented_inventory total_repair_all += repair_inventory total_active_rentals += active_rentals total_active_repairs += active_repairs points_cards_html = '' for ps in points_stats: points_cards_html += f''' <div class="point-card"> <div class="point-card-header"> <i class="fas fa-map-marker-alt"></i> {ps['name']} </div> <div class="point-stats-grid"> <div class="point-stat-item"> <div class="stat-label"><i class="fas fa-bicycle"></i> Всего</div> <div class="stat-value">{ps['total']}</div> </div> <div class="point-stat-item free"> <div class="stat-label"><i class="fas fa-check-circle"></i> Свободно</div> <div class="stat-value">{ps['free']}</div> </div> <div class="point-stat-item rented"> <div class="stat-label"><i class="fas fa-clock"></i> В аренде</div> <div class="stat-value">{ps['rented']}</div> </div> <div class="point-stat-item repair"> <div class="stat-label"><i class="fas fa-tools"></i> На ремонте</div> <div class="stat-value">{ps['repair']}</div> </div> <div class="point-stat-item"> <div class="stat-label"><i class="fas fa-file-signature"></i> Активных прокатов</div> <div class="stat-value">{ps['active_rentals']}</div> </div> <div class="point-stat-item"> <div class="stat-label"><i class="fas fa-wrench"></i> Активных ремонтов</div> <div class="stat-value">{ps['active_repairs']}</div> </div> </div> </div> ''' if not points_cards_html: points_cards_html = '<div class="no-data"><i class="fas fa-info-circle"></i> Нет данных по пунктам</div>' today = get_now().strftime("%Y-%m-%d") cursor.execute("SELECT * FROM shifts WHERE employee_id = ? AND date = ?", (employee_id, today)) current_shift = cursor.fetchone() shift_status = '' shift_button_text = '' if current_shift: if current_shift['status'] == 'active': shift_status = '<span class="shift-badge active"><i class="fas fa-play-circle"></i> Смена активна</span>' shift_button_text = '<a href="/shift/toggle" class="btn-danger"><i class="fas fa-stop"></i> Завершить смену</a>' else: shift_status = '<span class="shift-badge completed"><i class="fas fa-check-circle"></i> Смена завершена</span>' shift_button_text = '' else: shift_status = '<span class="shift-badge not-started"><i class="fas fa-circle"></i> Смена не начата</span>' shift_button_text = '<a href="/shift/toggle" class="btn-success"><i class="fas fa-play"></i> Начать смену</a>' summary_html = '' if len(points_stats) > 1: summary_html = f''' <div class="summary-card"> <div class="summary-header"><i class="fas fa-chart-bar"></i> Общая статистика по всем пунктам</div> <div class="summary-grid"> <div class="summary-item"><span>Всего велосипедов:</span> <strong>{total_inventory_all}</strong></div> <div class="summary-item"><span>Свободно:</span> <strong style="color:#10b981;">{total_free_all}</strong></div> <div class="summary-item"><span>В аренде:</span> <strong style="color:#3b82f6;">{total_rented_all}</strong></div> <div class="summary-item"><span>На ремонте:</span> <strong style="color:#f59e0b;">{total_repair_all}</strong></div> <div class="summary-item"><span>Активных прокатов:</span> <strong>{total_active_rentals}</strong></div> <div class="summary-item"><span>Активных ремонтов:</span> <strong>{total_active_repairs}</strong></div> </div> </div> ''' content = f''' <style> .dashboard-container {{ display: flex; flex-direction: column; gap: 20px; }} .points-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 20px; }} .point-card {{ background: var(--bg-card); border-radius: 20px; border: 1px solid var(--border); overflow: hidden; box-shadow: 0 4px 15px rgba(0,0,0,0.1); transition: transform 0.2s; }} .point-card:hover {{ transform: translateY(-2px); }} .point-card-header {{ padding: 16px 20px; background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); border-bottom: 1px solid var(--border); font-size: 1.1rem; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 10px; }} .point-stats-grid {{ display: grid; grid-template-columns: repeat(2, 1fr); gap: 1px; background: var(--border); }} .point-stat-item {{ background: var(--bg-card); padding: 16px 12px; text-align: center; }} .point-stat-item .stat-label {{ font-size: 0.75rem; color: var(--text-muted); margin-bottom: 6px; display: flex; align-items: center; justify-content: center; gap: 5px; }} .point-stat-item .stat-value {{ font-size: 1.5rem; font-weight: 700; color: var(--accent); }} .point-stat-item.free .stat-value {{ color: #10b981; }} .point-stat-item.rented .stat-value {{ color: #3b82f6; }} .point-stat-item.repair .stat-value {{ color: #f59e0b; }} .summary-card {{ background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); border-radius: 20px; border: 1px solid var(--border); overflow: hidden; }} .summary-header {{ padding: 16px 20px; border-bottom: 1px solid var(--border); font-size: 1rem; font-weight: 600; color: var(--accent); display: flex; align-items: center; gap: 10px; }} .summary-grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1px; background: var(--border); }} .summary-item {{ background: var(--bg-card); padding: 16px; display: flex; justify-content: space-between; align-items: center; font-size: 0.9rem; }} .summary-item strong {{ font-size: 1.3rem; color: var(--accent); }} .shift-card {{ background: var(--bg-card); border-radius: 20px; border: 1px solid var(--border); padding: 20px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 15px; }} .shift-info {{ display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }} .shift-user {{ display: flex; align-items: center; gap: 10px; }} .shift-user i {{ font-size: 2rem; color: var(--accent); }} .shift-user span {{ font-weight: 600; }} .shift-badge {{ padding: 6px 16px; border-radius: 30px; font-size: 0.8rem; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; }} .shift-badge.active {{ background: rgba(16, 185, 129, 0.15); color: #10b981; }} .shift-badge.completed {{ background: rgba(245, 158, 11, 0.15); color: #f59e0b; }} .shift-badge.not-started {{ background: rgba(100, 116, 139, 0.15); color: #64748b; }} .btn-success, .btn-danger {{ padding: 10px 20px; border-radius: 40px; font-size: 0.85rem; display: inline-flex; align-items: center; gap: 8px; cursor: pointer; text-decoration: none; border: none; transition: all 0.2s; font-weight: 500; }} .btn-success {{ background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; }} .btn-danger {{ background: linear-gradient(135deg, #dc2626 0%, #b91c1c 100%); color: white; }} .btn-success:hover, .btn-danger:hover {{ transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.2); }} .no-data {{ text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 1rem; }} .no-data i {{ font-size: 3rem; opacity: 0.3; margin-bottom: 15px; display: block; }} @media (max-width: 768px) {{ .points-grid {{ grid-template-columns: 1fr; }} .shift-card {{ flex-direction: column; align-items: flex-start; }} }} </style> <div class="dashboard-container"> {summary_html} <div class="points-grid"> {points_cards_html} </div> <div class="shift-card"> <div class="shift-info"> <div class="shift-user"> <i class="fas fa-user-circle"></i> <span>{session.get('fullname', 'Сотрудник')}</span> </div> {shift_status} </div> <div> {shift_button_text} </div> </div> </div> ''' return layout(content) except Exception as e: log_error("Ошибка в dashboard", e) flash('Произошла ошибка при загрузке дашборда', 'error') return redirect(url_for('login')) finally: if conn: conn.close() # ===================== УПРАВЛЕНИЕ ПОЛЬЗОВАТЕЛЯМИ ===================== # (оставляю без изменений для экономии места - этот раздел работает корректно) @app.route('/users') @admin_required def users_list(): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM users ORDER BY id") users = cursor.fetchall() rows = '' for u in users: role_badge = 'badge-active' if u['role'] == 'admin' else 'badge-pending' role_text = 'Администратор' if u['role'] == 'admin' else 'Пользователь' rows += f''' <tr> <td>{u['id']}</td> <td>{u['username']}</td> <td>{u['fullname']}</td> <td><span class="badge {role_badge}">{role_text}</span></td> <td> <a href="/users/edit/{u['id']}" class="btn-icon" title="Редактировать"><i class="fas fa-edit"></i></a> <a href="/users/permissions/{u['id']}" class="btn-icon" title="Права доступа"><i class="fas fa-lock"></i></a> <a href="/users/delete/{u['id']}" class="btn-icon btn-icon-danger" onclick="return confirm('Удалить пользователя?')"><i class="fas fa-trash-alt"></i></a> </td> </tr> ''' if not rows: rows = '<tr><td colspan="5" style="text-align:center;padding:40px;">Нет пользователей</td></tr>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-users"></i> Пользователи</h2><a href="/users/add"><button class="btn-primary"><i class="fas fa-user-plus"></i> Добавить</button></a></div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>ID</th><th>Логин</th><th>ФИО</th><th>Роль</th><th></th></thead> <tbody>{rows}</tbody> </table> </div></div> ''' return layout(content) except Exception as e: log_error("Ошибка при загрузке пользователей", e) flash('Произошла ошибка при загрузке списка пользователей', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/users/permissions/<int:user_id>', methods=['GET', 'POST']) @admin_required def user_permissions(user_id): conn = None try: conn = get_db() cursor = conn.cursor() try: cursor.execute("ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '{}'") conn.commit() except: pass cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if not user: flash('Пользователь не найден', 'error') return redirect(url_for('users_list')) all_permissions = { 'view_inventory': 'Просмотр инвентаря', 'edit_inventory': 'Редактирование инвентаря', 'delete_inventory': 'Удаление инвентаря', 'view_rentals': 'Просмотр прокатов', 'create_rentals': 'Создание прокатов', 'edit_rentals': 'Редактирование прокатов', 'close_rentals': 'Закрытие прокатов', 'delete_rentals': 'Удаление прокатов', 'view_clients': 'Просмотр клиентов', 'edit_clients': 'Редактирование клиентов', 'delete_clients': 'Удаление клиентов', 'view_employees': 'Просмотр сотрудников', 'edit_employees': 'Редактирование сотрудников', 'delete_employees': 'Удаление сотрудников', 'view_repairs': 'Просмотр ремонтов', 'create_repairs': 'Создание ремонтов', 'edit_repairs': 'Редактирование ремонтов', 'delete_repairs': 'Удаление ремонтов', 'view_services': 'Просмотр услуг', 'edit_services': 'Редактирование услуг', 'delete_services': 'Удаление услуг', 'view_parts': 'Просмотр запчастей', 'edit_parts': 'Редактирование запчастей', 'delete_parts': 'Удаление запчастей', 'use_parts': 'Списание запчастей', 'view_reports': 'Просмотр отчётов', 'view_analytics': 'Просмотр аналитики', 'view_salary': 'Просмотр зарплаты', 'edit_salary': 'Редактирование зарплаты', 'view_settings': 'Просмотр настроек', 'edit_settings': 'Редактирование настроек', } current_permissions = {} if user['permissions']: try: current_permissions = json.loads(user['permissions']) except: current_permissions = {} if request.method == 'POST': new_permissions = {} for perm in all_permissions.keys(): if perm in request.form: new_permissions[perm] = True if user['role'] == 'admin': new_permissions = {perm: True for perm in all_permissions.keys()} cursor.execute("UPDATE users SET permissions = ? WHERE id = ?", (json.dumps(new_permissions), user_id)) conn.commit() flash('Права доступа сохранены!', 'success') return redirect(url_for('users_list')) permissions_html = '' categories = { '📦 Инвентарь': ['view_inventory', 'edit_inventory', 'delete_inventory'], '🚲 Прокат': ['view_rentals', 'create_rentals', 'edit_rentals', 'close_rentals', 'delete_rentals'], '👥 Клиенты': ['view_clients', 'edit_clients', 'delete_clients'], '👔 Сотрудники': ['view_employees', 'edit_employees', 'delete_employees'], '🔧 Ремонт': ['view_repairs', 'create_repairs', 'edit_repairs', 'delete_repairs'], '📋 Услуги': ['view_services', 'edit_services', 'delete_services'], '🔩 Запчасти': ['view_parts', 'edit_parts', 'delete_parts', 'use_parts'], '📊 Отчёты': ['view_reports', 'view_analytics'], '💰 Зарплата': ['view_salary', 'edit_salary'], '⚙️ Настройки': ['view_settings', 'edit_settings'], } for category, perms in categories.items(): permissions_html += f'<div class="permission-category"><h3>{category}</h3><div class="permission-group">' for perm in perms: checked = 'checked' if current_permissions.get(perm, False) or user['role'] == 'admin' else '' disabled = 'disabled' if user['role'] == 'admin' else '' permissions_html += f''' <label class="permission-item"> <input type="checkbox" name="{perm}" {checked} {disabled}> <span>{all_permissions[perm]}</span> </label> ''' permissions_html += '</div></div>' content = f''' <div class="card"><div class="card-header"> <h2><i class="fas fa-lock"></i> Права доступа: {user['fullname']} ({user['username']})</h2> <a href="/users"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a> </div> <form method="POST" style="padding:20px;"> <div class="permissions-container"> {permissions_html} </div> <div style="margin-top: 30px; text-align: center;"> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить права доступа</button> </div> </form></div> <style> .permissions-container {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }} .permission-category {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; padding: 15px; }} .permission-category h3 {{ color: var(--accent); font-size: 1rem; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }} .permission-group {{ display: flex; flex-direction: column; gap: 8px; }} .permission-item {{ display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 5px 0; font-size: 0.85rem; color: var(--text-secondary); }} .permission-item:hover {{ color: var(--accent); }} .permission-item input {{ width: 18px; height: 18px; cursor: pointer; accent-color: var(--accent); }} .permission-item input:disabled {{ opacity: 0.5; cursor: not-allowed; }} @media (max-width: 768px) {{ .permissions-container {{ grid-template-columns: 1fr; }} }} </style> ''' return layout(content) except Exception as e: log_error(f"Ошибка при управлении правами пользователя {user_id}", e) if conn: conn.rollback() flash('Произошла ошибка при управлении правами', 'error') return redirect(url_for('users_list')) finally: if conn: conn.close() @app.route('/users/add', methods=['GET', 'POST']) @admin_required def users_add(): if request.method == 'POST': conn = None try: conn = get_db() cursor = conn.cursor() try: cursor.execute("ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '{}'") conn.commit() except: pass username = request.form.get('username', '').strip() password = request.form.get('password', '').strip() fullname = request.form.get('fullname', '').strip() role = request.form.get('role', 'user') employee_id = request.form.get('employee_id') if not username or not password or not fullname: flash('Все поля обязательны для заполнения', 'error') return redirect(url_for('users_add')) default_permissions = json.dumps({}) cursor.execute("INSERT INTO users (username, password, fullname, role, employee_id, permissions) VALUES (?, ?, ?, ?, ?, ?)", (username, password, fullname, role, employee_id if employee_id else None, default_permissions)) conn.commit() flash('Пользователь добавлен!', 'success') return redirect(url_for('users_list')) except sqlite3.IntegrityError: flash('Пользователь с таким логином уже существует!', 'error') except Exception as e: log_error("Ошибка при добавлении пользователя", e) if conn: conn.rollback() flash('Произошла ошибка при добавлении пользователя', 'error') finally: if conn: conn.close() return redirect(url_for('users_list')) conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT id, name, point_name FROM employees ORDER BY name") employees = cursor.fetchall() employees_opts = '<option value="">— Не привязан —</option>' for e in employees: employees_opts += f'<option value="{e["id"]}">{e["name"]} ({e["point_name"] or ""})</option>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-user-plus"></i> Добавить пользователя</h2><a href="/users"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Логин *</label><input type="text" name="username" required></div> <div class="form-group"><label>Пароль *</label><input type="password" name="password" required></div> </div> <div class="form-row"> <div class="form-group"><label>ФИО *</label><input type="text" name="fullname" required></div> <div class="form-group"><label>Роль</label><select name="role"><option value="user">Пользователь</option><option value="admin">Администратор</option></select></div> </div> <div class="form-group"><label>Привязать к сотруднику</label><select name="employee_id">{employees_opts}</select></div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error("Ошибка при загрузке формы добавления пользователя", e) flash('Произошла ошибка', 'error') return redirect(url_for('users_list')) finally: if conn: conn.close() @app.route('/users/edit/<int:user_id>', methods=['GET', 'POST']) @admin_required def users_edit(user_id): conn = None try: conn = get_db() cursor = conn.cursor() if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '').strip() fullname = request.form.get('fullname', '').strip() role = request.form.get('role', 'user') employee_id = request.form.get('employee_id') if not username or not fullname: flash('Логин и ФИО обязательны', 'error') return redirect(url_for('users_edit', user_id=user_id)) if password: cursor.execute("UPDATE users SET username = ?, password = ?, fullname = ?, role = ?, employee_id = ? WHERE id = ?", (username, password, fullname, role, employee_id if employee_id else None, user_id)) else: cursor.execute("UPDATE users SET username = ?, fullname = ?, role = ?, employee_id = ? WHERE id = ?", (username, fullname, role, employee_id if employee_id else None, user_id)) conn.commit() flash('Пользователь обновлён!', 'success') return redirect(url_for('users_list')) cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if not user: flash('Пользователь не найден', 'error') return redirect(url_for('users_list')) cursor.execute("SELECT id, name, point_name FROM employees ORDER BY name") employees = cursor.fetchall() employees_opts = '<option value="">— Не привязан —</option>' for e in employees: selected = 'selected' if user['employee_id'] == e['id'] else '' employees_opts += f'<option value="{e["id"]}" {selected}>{e["name"]} ({e["point_name"] or ""})</option>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-edit"></i> Редактирование пользователя</h2><a href="/users"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Логин *</label><input type="text" name="username" value="{user['username']}" required></div> <div class="form-group"><label>Пароль</label><input type="password" name="password" placeholder="Оставьте пустым, чтобы не менять"></div> </div> <div class="form-row"> <div class="form-group"><label>ФИО *</label><input type="text" name="fullname" value="{user['fullname']}" required></div> <div class="form-group"><label>Роль</label><select name="role"><option value="user" {'selected' if user['role']=='user' else ''}>Пользователь</option><option value="admin" {'selected' if user['role']=='admin' else ''}>Администратор</option></select></div> </div> <div class="form-group"><label>Привязать к сотруднику</label><select name="employee_id">{employees_opts}</select></div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error(f"Ошибка при редактировании пользователя {user_id}", e) if conn: conn.rollback() flash('Произошла ошибка при редактировании', 'error') return redirect(url_for('users_list')) finally: if conn: conn.close() @app.route('/users/delete/<int:user_id>') @admin_required def users_delete(user_id): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT role FROM users WHERE id = ?", (user_id,)) user = cursor.fetchone() if user and user['role'] == 'admin': cursor.execute("SELECT COUNT(*) FROM users WHERE role = 'admin'") admin_count = cursor.fetchone()[0] if admin_count <= 1: flash('Нельзя удалить последнего администратора!', 'error') return redirect(url_for('users_list')) cursor.execute("DELETE FROM users WHERE id = ?", (user_id,)) conn.commit() flash('Пользователь удалён', 'success') except Exception as e: log_error(f"Ошибка при удалении пользователя {user_id}", e) if conn: conn.rollback() flash('Произошла ошибка при удалении', 'error') finally: if conn: conn.close() return redirect(url_for('users_list')) # ===================== АРЕНДА (ПРОКАТ) ===================== def migrate_rentals_add_group_id(): conn = None try: conn = get_db() cursor = conn.cursor() # Добавляем колонку group_id если её нет try: cursor.execute("ALTER TABLE rentals ADD COLUMN group_id TEXT") conn.commit() except: pass # Добавляем колонку rental_group_id в income если нет try: cursor.execute("ALTER TABLE income ADD COLUMN rental_group_id TEXT") conn.commit() except: pass # Обновляем group_id для старых записей где его нет cursor.execute("SELECT id FROM rentals WHERE group_id IS NULL OR group_id = ''") old_rentals = cursor.fetchall() for r in old_rentals: group_id = f"rental_legacy_{r['id']}_{uuid.uuid4().hex[:4]}" cursor.execute("UPDATE rentals SET group_id = ? WHERE id = ?", (group_id, r['id'])) conn.commit() print("✅ Миграция group_id выполнена", flush=True) except Exception as e: log_error("Ошибка миграции group_id", e) finally: if conn: conn.close() with app.app_context(): migrate_rentals_add_group_id() @app.route('/rentals', methods=['GET']) @login_required def rentals_list(): conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) status_filter = request.args.get('status', 'active') point_filter = request.args.get('point', 'all') page = safe_int(request.args.get('page', 1), 1) per_page = 20 query = "SELECT * FROM rentals WHERE 1=1" params = [] if not is_admin and employee_id: query += " AND employee_id = ?" params.append(employee_id) if status_filter == 'active': query += " AND status = 'active'" elif status_filter == 'closed': query += " AND status = 'closed'" if point_filter != 'all': query += " AND point_name = ?" params.append(point_filter) query += " ORDER BY created_at DESC" cursor.execute(query, params) all_rentals = cursor.fetchall() groups = {} for r in all_rentals: group_id = r['group_id'] if r['group_id'] else f"single_{r['id']}" if group_id not in groups: groups[group_id] = { 'id': r['id'], 'group_id': group_id, 'client_name': r['client_name'], 'client_phone': r['client_phone'], 'client_id': r['client_id'], 'employee_id': r['employee_id'], 'tariff_type': r['tariff_type'], 'has_card': r['has_card'], 'payment_type': r['payment_type'], 'status': r['status'], 'start_time': r['start_time'], 'end_time': r['end_time'], 'created_at': r['created_at'], 'point_name': r['point_name'], 'bikes': [], 'total_price': 0 } groups[group_id]['bikes'].append({ 'id': r['id'], 'inventory_id': r['inventory_id'], 'inventory_name': r['inventory_name'], 'inventory_category': r['inventory_category'], 'price': r['price'] or 0, 'hours': r['hours'], 'half_hours': r['half_hours'], 'days': r['days'] }) groups[group_id]['total_price'] += r['price'] or 0 groups_list = list(groups.values()) groups_list.sort(key=lambda x: x['created_at'] or '', reverse=True) total = len(groups_list) total_pages = max(1, (total + per_page - 1) // per_page) start = (page - 1) * per_page paginated = groups_list[start:start + per_page] rows = '' for g in paginated: status_class = 'active' if g['status'] == 'active' else 'closed' status_text = 'Активен' if g['status'] == 'active' else 'Завершён' bikes_html = '' for bike in g['bikes'][:3]: bikes_html += f'<span class="bike-tag">{bike["inventory_name"]}</span>' if len(g['bikes']) > 3: bikes_html += f'<span class="bike-tag">+{len(g["bikes"]) - 3}</span>' tariff_names = {'hourly': 'Почасовой', 'daily': 'Посуточный', 'night': 'Ночной'} tariff_text = tariff_names.get(g['tariff_type'], g['tariff_type']) rows += f''' <tr class="clickable-row" data-url="/rentals/edit/{g['id']}?group_id={g['group_id']}" data-group-id="{g['group_id']}"> <td>{g['created_at'][:16] if g['created_at'] else '—'}</td> <td> <strong>{g['client_name']}</strong><br> <span style="color: var(--accent); font-size: 0.8rem;">{g['client_phone']}</span> </td> <td><div style="display: flex; flex-wrap: wrap; gap: 4px;">{bikes_html}</div></td> <td>{g['point_name'] or '—'}</td> <td> {tariff_text}<br> {('<span style="color: #fbbf24;">👑 Карта</span>' if g['has_card'] else '')} </td> <td><span class="status-badge status-{g['status']}">{status_text}</span></td> <td><strong style="color: var(--accent);">{g['total_price']:,.0f} ₽</strong></td> <td>{g['payment_type']}</td> <td> <div class="actions-menu"> <button class="btn-icon" onclick="event.stopPropagation(); toggleMenu(this)">⋮</button> <div class="menu-dropdown"> <a href="/rentals/edit/{g['id']}?group_id={g['group_id']}"><i class="fas fa-edit"></i> Редактировать</a> <a href="/rentals/close/{g['id']}?group_id={g['group_id']}"><i class="fas fa-check-circle"></i> Завершить</a> <a href="#" onclick="event.preventDefault(); if(confirm('Удалить прокат?')) location.href='/rentals/delete/{g['id']}?group_id={g['group_id']}';" class="danger"><i class="fas fa-trash-alt"></i> Удалить</a> </div> </div> </td> </tr> ''' if not rows: rows = '<tr><td colspan="9" style="text-align:center;padding:40px;color: var(--text-muted);">Нет прокатов</td></tr>' cursor.execute("SELECT DISTINCT point_name FROM rentals WHERE point_name IS NOT NULL AND point_name != ''") points_opts = '<option value="all">Все пункты</option>' for p in cursor.fetchall(): selected = 'selected' if point_filter == p['point_name'] else '' points_opts += f'<option value="{p["point_name"]}" {selected}>{p["point_name"]}</option>' content = f''' <style> .bike-tag {{ background: var(--bg-primary); padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); }} .status-badge {{ padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 500; }} .status-active {{ background: #065f46; color: #34d399; }} .status-closed {{ background: #374151; color: #9ca3af; }} .clickable-row {{ cursor: pointer; }} </style> <div class="card"> <div class="card-header"> <h2><i class="fas fa-bicycle"></i> Прокаты</h2> <div style="display: flex; gap: 10px;"> <select id="statusFilter" onchange="applyFilter()" class="filter-select"> <option value="active" {'selected' if status_filter == 'active' else ''}>Активные</option> <option value="closed" {'selected' if status_filter == 'closed' else ''}>Завершённые</option> </select> <select id="pointFilter" onchange="applyFilter()" class="filter-select"> {points_opts} </select> <a href="/rentals/add" class="btn-primary"><i class="fas fa-plus"></i> Новый прокат</a> </div> </div> <div style="overflow-x: auto;"> <table class="data-table"> <thead> <tr> <th>Дата</th> <th>Клиент</th> <th>Велосипеды</th> <th>Пункт</th> <th>Тариф</th> <th>Статус</th> <th>Сумма</th> <th>Оплата</th> <th></th> </tr> </thead> <tbody>{rows}</tbody> </table> </div> <div class="pagination"> {f'<a href="?status={status_filter}&point={point_filter}&page={page-1}" class="page-btn">←</a>' if page > 1 else ''} <span>{page} / {total_pages}</span> {f'<a href="?status={status_filter}&point={point_filter}&page={page+1}" class="page-btn">→</a>' if page < total_pages else ''} </div> </div> <script> function applyFilter() {{ const status = document.getElementById('statusFilter').value; const point = document.getElementById('pointFilter').value; window.location.href = `/rentals?status=${{status}}&point=${{point}}`; }} function toggleMenu(btn) {{ event.stopPropagation(); const dropdown = btn.closest('.actions-menu').querySelector('.menu-dropdown'); if (!dropdown) return; document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); dropdown.classList.add('show'); const rect = btn.getBoundingClientRect(); dropdown.style.position = 'fixed'; dropdown.style.left = (rect.left - 150) + 'px'; dropdown.style.top = (rect.bottom + 5) + 'px'; dropdown.style.zIndex = '99999'; }} document.addEventListener('click', (e) => {{ if (!e.target.closest('.actions-menu')) {{ document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); }} }}); document.querySelectorAll('.clickable-row').forEach(row => {{ row.addEventListener('contextmenu', (e) => {{ e.preventDefault(); const menu = row.querySelector('.menu-dropdown'); if (menu) {{ document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); menu.style.position = 'fixed'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; menu.style.zIndex = '99999'; menu.classList.add('show'); setTimeout(() => {{ const closeHandler = (ev) => {{ if (!menu.contains(ev.target)) {{ menu.classList.remove('show'); document.removeEventListener('click', closeHandler); }} }}; document.addEventListener('click', closeHandler); }}, 10); }} }}); row.addEventListener('click', function(e) {{ if (e.target.closest('.actions-menu') || e.target.closest('a')) return; window.location.href = this.dataset.url; }}); }}); </script> ''' return layout(content) except Exception as e: log_error("Ошибка в rentals_list", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/rentals/add', methods=['GET', 'POST']) @login_required def rentals_add(): conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, employee_point_name = get_employee_point(employee_id) if request.method == 'POST': phone = request.form.get('phone', '').strip() last_name = request.form.get('last_name', '').strip() first_name = request.form.get('first_name', '').strip() middle_name = request.form.get('middle_name', '').strip() inventory_ids = request.form.getlist('inventory_ids') tariff_type = request.form.get('tariff_type', 'hourly') payment_type = request.form.get('payment_type', 'Наличные') has_card = 1 if request.form.get('has_card') == 'on' else 0 start_time_str = request.form.get('start_time', '') duration_value = safe_float(request.form.get('duration_value', 1)) duration_unit = request.form.get('duration_unit', 'hours') if not phone or not last_name or not first_name or not inventory_ids: flash('Заполните все обязательные поля', 'error') return redirect(url_for('rentals_add')) cursor.execute("SELECT * FROM clients WHERE phone = ?", (phone,)) client = cursor.fetchone() if not client: cursor.execute(""" INSERT INTO clients (last_name, first_name, middle_name, phone, has_card, created_at) VALUES (?, ?, ?, ?, ?, ?) """, (last_name, first_name, middle_name, phone, has_card, get_now().isoformat())) conn.commit() cursor.execute("SELECT * FROM clients WHERE phone = ?", (phone,)) client = cursor.fetchone() else: if has_card and not client['has_card']: cursor.execute("UPDATE clients SET has_card = 1 WHERE id = ?", (client['id'],)) conn.commit() start_time = datetime.fromisoformat(start_time_str) if start_time_str else get_now() if start_time.tzinfo is None: start_time = CHELYABINSK_TZ.localize(start_time) hours, half_hours, days = 0, 0, 1 if tariff_type == 'hourly': hours = int(duration_value) half_hours = int((duration_value - hours) * 2) elif tariff_type == 'daily': days = int(duration_value) group_id = f"rental_{get_now().strftime('%Y%m%d%H%M%S')}_{uuid.uuid4().hex[:4]}" total_price = 0 for inv_id in inventory_ids: if not inv_id: continue cursor.execute("SELECT * FROM inventory WHERE id = ? AND status = 'free'", (inv_id,)) inv = cursor.fetchone() if not inv: continue category = inv['category'] or 'adult' price = calculate_price(category, hours, half_hours, has_card, tariff_type, days) total_price += price cursor.execute(""" INSERT INTO rentals (client_id, client_name, client_phone, inventory_id, inventory_name, inventory_category, point_name, employee_id, tariff_type, hours, half_hours, days, has_card, price, payment_type, status, start_time, created_at, group_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( client['id'], f"{client['last_name']} {client['first_name']} {client['middle_name'] or ''}".strip(), phone, inv_id, inv['name'], inv['category_name'], inv['point_name'], employee_id, tariff_type, hours, half_hours, days, has_card, price, payment_type, 'active', start_time.isoformat(), get_now().isoformat(), group_id )) cursor.execute("UPDATE inventory SET status = 'rented' WHERE id = ?", (inv_id,)) if total_price > 0: cursor.execute(""" INSERT INTO income (date, category, amount, payment_type, point_name, employee_id, comment, rental_group_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, ( get_now().strftime("%Y-%m-%d"), "Прокат", total_price, payment_type, employee_point_name, employee_id, f"Прокат для {client['last_name']} {client['first_name']}", group_id )) conn.commit() flash(f'✅ Прокат создан! Сумма: {total_price} ₽', 'success') return redirect(url_for('rentals_list')) cursor.execute("SELECT * FROM inventory WHERE status = 'free' ORDER BY name") available_inventory = cursor.fetchall() if not is_admin and employee_point: available_inventory = [i for i in available_inventory if i['point_id'] == employee_point] inventory_js = [] for inv in available_inventory: name_escaped = inv['name'].replace("'", "\\'").replace('"', '"') inventory_js.append(f"{{id: '{inv['id']}', name: '{name_escaped}', category: '{inv['category']}'}}") inventory_js_str = '[' + ','.join(inventory_js) + ']' now = get_now() now_str = now.strftime("%Y-%m-%dT%H:%M") content = f''' <style> .rental-form-container {{ display: grid; grid-template-columns: 1fr 1fr; gap: 24px; max-width: 1200px; margin: 0 auto; }} @media (max-width: 768px) {{ .rental-form-container {{ grid-template-columns: 1fr; }} }} .form-section {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 24px; }} .form-section h3 {{ margin-bottom: 20px; color: var(--accent); font-size: 1.1rem; display: flex; align-items: center; gap: 10px; }} .form-group {{ margin-bottom: 18px; }} .form-group label {{ display: block; margin-bottom: 6px; font-size: 0.85rem; color: var(--text-secondary); }} .form-group input, .form-group select {{ width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); color: var(--text-primary); font-size: 0.95rem; }} .phone-search {{ display: flex; gap: 10px; }} .phone-search input {{ flex: 1; }} .phone-search button {{ padding: 12px 20px; background: var(--accent); color: white; border: none; border-radius: 10px; cursor: pointer; }} .bikes-list {{ max-height: 300px; overflow-y: auto; border: 1px solid var(--border); border-radius: 10px; padding: 10px; }} .bike-checkbox {{ display: flex; align-items: center; gap: 10px; padding: 10px; border-bottom: 1px solid var(--border); cursor: pointer; }} .bike-checkbox:last-child {{ border-bottom: none; }} .bike-checkbox:hover {{ background: var(--bg-primary); }} .bike-checkbox input {{ width: 18px; height: 18px; accent-color: var(--accent); }} .duration-control {{ display: flex; align-items: center; gap: 10px; background: var(--bg-primary); padding: 8px 12px; border-radius: 12px; }} .duration-btn {{ width: 36px; height: 36px; border-radius: 8px; background: var(--accent); color: white; border: none; font-size: 1.3rem; font-weight: bold; cursor: pointer; display: flex; align-items: center; justify-content: center; }} .duration-btn:hover {{ background: var(--accent-hover); }} .duration-input-wrapper {{ flex: 1; display: flex; align-items: center; gap: 8px; }} .duration-input-wrapper input {{ width: 70px; text-align: center; font-size: 1.1rem; font-weight: bold; padding: 8px; }} .duration-input-wrapper select {{ width: auto; min-width: 90px; }} .price-preview {{ background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); border: 2px solid var(--accent); border-radius: 16px; padding: 24px; text-align: center; margin-top: 20px; }} .price-preview .price-label {{ font-size: 0.9rem; color: var(--text-muted); margin-bottom: 8px; text-transform: uppercase; }} .price-preview .price-value {{ font-size: 3rem; font-weight: 800; color: var(--accent); line-height: 1.2; }} .price-details {{ margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); font-size: 0.8rem; color: var(--text-muted); }} .selected-bikes {{ margin-top: 10px; padding: 10px; background: var(--bg-primary); border-radius: 8px; min-height: 50px; }} .selected-bike-tag {{ display: inline-block; background: var(--accent); color: white; padding: 4px 10px; border-radius: 20px; margin: 4px; font-size: 0.8rem; }} .btn-submit {{ width: 100%; padding: 16px; background: var(--accent); color: white; border: none; border-radius: 12px; font-size: 1.1rem; font-weight: bold; cursor: pointer; margin-top: 20px; }} .btn-submit:hover {{ filter: brightness(1.1); }} </style> <form method="POST" id="rentalForm"> <div class="rental-form-container"> <div class="form-section"> <h3><i class="fas fa-user"></i> Клиент</h3> <div class="form-group"> <label>📞 Телефон *</label> <div class="phone-search"> <input type="tel" name="phone" id="phoneInput" placeholder="+7 (900) 123-45-67" required> <button type="button" onclick="searchClient()"><i class="fas fa-search"></i></button> </div> </div> <div class="form-group"><label>👤 Фамилия *</label><input type="text" name="last_name" id="lastName" required></div> <div class="form-group"><label>👤 Имя *</label><input type="text" name="first_name" id="firstName" required></div> <div class="form-group"><label>👤 Отчество</label><input type="text" name="middle_name" id="middleName"></div> <div class="form-group"> <label style="display: flex; align-items: center; gap: 10px;"> <input type="checkbox" name="has_card" id="hasCard" style="width: auto;" onchange="updatePrice()"> <span>👑 Клубная карта</span> </label> </div> </div> <div class="form-section"> <h3><i class="fas fa-bicycle"></i> Прокат</h3> <div class="form-group"> <label>🚲 Велосипеды *</label> <div class="bikes-list" id="bikesList"></div> <div class="selected-bikes" id="selectedBikes"></div> </div> <div class="form-group"> <label>🏷️ Тариф *</label> <select name="tariff_type" id="tariffType" onchange="updatePrice()"> <option value="hourly">⏱ Почасовой</option> <option value="daily">📅 Посуточный</option> <option value="night">🌙 Ночной</option> </select> </div> <div class="form-group"> <label>⏰ Длительность *</label> <div class="duration-control"> <button type="button" class="duration-btn" onclick="adjustDuration(-0.5)">−</button> <div class="duration-input-wrapper"> <input type="number" name="duration_value" id="durationValue" value="1" min="0.5" step="0.5" onchange="updatePrice()"> <select name="duration_unit" id="durationUnit" onchange="updatePrice()"> <option value="hours">часов</option> <option value="days">дней</option> </select> </div> <button type="button" class="duration-btn" onclick="adjustDuration(0.5)">+</button> </div> </div> <div class="form-group"> <label>📅 Время начала</label> <input type="datetime-local" name="start_time" id="startTime" value="{now_str}"> </div> <div class="form-group"> <label>💳 Оплата</label> <select name="payment_type"> <option>Наличные</option> <option>Карта</option> <option>Перевод</option> </select> </div> <div class="price-preview"> <div class="price-label"><i class="fas fa-calculator"></i> СТОИМОСТЬ</div> <div class="price-value" id="pricePreview">0 <small>₽</small></div> <div class="price-details" id="priceDetails">Выберите велосипед</div> </div> </div> </div> <button type="submit" class="btn-submit"><i class="fas fa-play"></i> Начать прокат</button> </form> <script> const TARIFFS = {{ adult: {{ hourly_first: 600, hourly_first_card: 500, hourly_next: 200, daily: 2000, daily_card: 1500, night: 1000, night_card: 800 }}, child: {{ hourly_first: 400, hourly_first_card: 300, hourly_next: 200, daily: 2000, daily_card: 1500, night: 1000, night_card: 800 }} }}; const inventoryData = {inventory_js_str}; function renderBikeList() {{ let html = ''; inventoryData.forEach(bike => {{ const catIcon = bike.category === 'adult' ? '🚲' : (bike.category === 'child' ? '🧒' : '🏄'); html += `<div class="bike-checkbox"> <input type="checkbox" name="inventory_ids" value="${{bike.id}}" onchange="updatePrice()"> <span>${{catIcon}} ${{bike.name}}</span> </div>`; }}); document.getElementById('bikesList').innerHTML = html; updatePrice(); }} function adjustDuration(delta) {{ const input = document.getElementById('durationValue'); let value = parseFloat(input.value) || 1; value = Math.max(0.5, value + delta); input.value = value; updatePrice(); }} function searchClient() {{ const phone = document.getElementById('phoneInput').value; if (!phone) return; fetch('/api/client/search?q=' + encodeURIComponent(phone)) .then(r => r.json()) .then(data => {{ if (data.found && data.clients.length > 0) {{ const c = data.clients[0]; document.getElementById('lastName').value = c.last_name || ''; document.getElementById('firstName').value = c.first_name || ''; document.getElementById('middleName').value = c.middle_name || ''; document.getElementById('hasCard').checked = c.has_card === 1; updatePrice(); }} }}); }} function updatePrice() {{ const tariff = document.getElementById('tariffType').value; const duration = parseFloat(document.getElementById('durationValue').value) || 1; const hasCard = document.getElementById('hasCard').checked; const selected = document.querySelectorAll('input[name="inventory_ids"]:checked'); const container = document.getElementById('selectedBikes'); if (selected.length === 0) {{ container.innerHTML = '<span style="color: var(--text-muted);">Не выбрано</span>'; document.getElementById('pricePreview').innerHTML = '0 <small>₽</small>'; document.getElementById('priceDetails').textContent = 'Выберите велосипед'; return; }} let tags = ''; let total = 0; let details = []; selected.forEach(cb => {{ const bikeId = cb.value; const bike = inventoryData.find(b => b.id == bikeId); if (!bike) return; const span = cb.nextElementSibling; tags += `<span class="selected-bike-tag">${{span.textContent}}</span>`; const cat = bike.category || 'adult'; const t = TARIFFS[cat] || TARIFFS.adult; let price = 0; if (tariff === 'hourly') {{ const firstPrice = hasCard ? t.hourly_first_card : t.hourly_first; if (duration <= 1) {{ price = duration === 0.5 ? firstPrice / 2 : firstPrice; }} else {{ price = firstPrice + (duration - 1) * t.hourly_next; }} details.push(`${{bike.name}}: ${{price}} ₽`); }} else if (tariff === 'daily') {{ price = (hasCard ? t.daily_card : t.daily) * duration; details.push(`${{bike.name}}: ${{price}} ₽`); }} else if (tariff === 'night') {{ price = hasCard ? t.night_card : t.night; details.push(`${{bike.name}}: ${{price}} ₽`); }} total += price; }}); container.innerHTML = tags; document.getElementById('pricePreview').innerHTML = total.toLocaleString() + ' <small>₽</small>'; document.getElementById('priceDetails').textContent = details.join(' • '); }} document.getElementById('tariffType').addEventListener('change', function() {{ document.getElementById('durationUnit').value = this.value === 'daily' ? 'days' : 'hours'; updatePrice(); }}); renderBikeList(); </script> ''' return layout(content) except Exception as e: log_error("Ошибка в rentals_add", e) if conn: conn.rollback() flash(f'Ошибка: {str(e)}', 'error') return redirect(url_for('rentals_list')) finally: if conn: conn.close() @app.route('/rentals/edit/<int:rental_id>', methods=['GET', 'POST']) @login_required def rentals_edit(rental_id): conn = None try: conn = get_db() cursor = conn.cursor() group_id = request.args.get('group_id', '') if group_id: cursor.execute("SELECT * FROM rentals WHERE group_id = ?", (group_id,)) rentals = cursor.fetchall() else: cursor.execute("SELECT * FROM rentals WHERE id = ?", (rental_id,)) rentals = cursor.fetchall() if not rentals: flash('Прокат не найден', 'error') return redirect(url_for('rentals_list')) first = rentals[0] actual_group_id = first['group_id'] if request.method == 'POST': price = safe_float(request.form.get('price', first['price'])) payment_type = request.form.get('payment_type', first['payment_type']) status = request.form.get('status', first['status']) has_card = 1 if request.form.get('has_card') == 'on' else 0 start_time = request.form.get('start_time') end_time = request.form.get('end_time') if request.form.get('end_time') else None for r in rentals: cursor.execute(""" UPDATE rentals SET price = ?, payment_type = ?, status = ?, has_card = ?, start_time = COALESCE(?, start_time), end_time = COALESCE(?, end_time) WHERE id = ? """, (price, payment_type, status, has_card, start_time, end_time, r['id'])) if status == 'closed' and r['status'] == 'active': cursor.execute("UPDATE inventory SET status = 'free' WHERE id = ?", (r['inventory_id'],)) cursor.execute("UPDATE rentals SET closed_at = ? WHERE id = ?", (get_now().isoformat(), r['id'])) elif status == 'active' and r['status'] == 'closed': cursor.execute("UPDATE inventory SET status = 'rented' WHERE id = ?", (r['inventory_id'],)) # Обновляем доход по group_id total_price = price * len(rentals) cursor.execute(""" UPDATE income SET amount = ?, payment_type = ? WHERE rental_group_id = ? AND category = 'Прокат' """, (total_price, payment_type, actual_group_id)) conn.commit() flash('✅ Прокат обновлён', 'success') return redirect(url_for('rentals_list')) bikes_html = ''.join([f'<span class="selected-bike-tag">{r["inventory_name"]}</span>' for r in rentals]) total_price = sum(r['price'] or 0 for r in rentals) now_str = get_now().strftime("%Y-%m-%dT%H:%M") content = f''' <style> .edit-container {{ max-width: 700px; margin: 0 auto; }} .edit-card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 30px; }} .form-group {{ margin-bottom: 20px; }} .form-group label {{ display: block; margin-bottom: 8px; color: var(--text-secondary); }} .form-group input, .form-group select {{ width: 100%; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--input-bg); color: var(--text-primary); }} .bikes-list {{ margin-bottom: 20px; padding: 15px; background: var(--bg-primary); border-radius: 10px; }} .selected-bike-tag {{ display: inline-block; background: var(--accent); color: white; padding: 4px 12px; border-radius: 20px; margin: 4px; }} .btn-submit {{ width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 1rem; cursor: pointer; }} .row-2 {{ display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }} .group-id-badge {{ background: var(--bg-primary); padding: 8px 12px; border-radius: 8px; font-family: monospace; margin-bottom: 15px; }} </style> <div class="edit-container"> <div class="edit-card"> <h2 style="margin-bottom: 10px;"><i class="fas fa-edit"></i> Редактирование</h2> <div class="group-id-badge"> <i class="fas fa-hashtag"></i> ID проката: <strong>{actual_group_id}</strong> </div> <form method="POST" id="editForm"> <div class="form-group"><label>Клиент</label><input value="{first['client_name']}" disabled></div> <div class="form-group"><label>Телефон</label><input value="{first['client_phone']}" disabled></div> <div class="form-group"><label>Велосипеды</label><div class="bikes-list">{bikes_html}</div></div> <div class="row-2"> <div class="form-group"> <label>📅 Время начала</label> <input type="datetime-local" name="start_time" id="startTime" value="{first['start_time'][:16] if first['start_time'] else ''}"> </div> <div class="form-group"> <label>📅 Время окончания</label> <input type="datetime-local" name="end_time" id="endTime" value="{first['end_time'][:16] if first['end_time'] else ''}"> </div> </div> <div class="form-group"> <label>Тариф</label> <input value="{first['tariff_type']}" disabled> </div> <div class="form-group"> <label>Сумма (₽)</label> <input type="number" name="price" id="priceInput" step="0.01" value="{total_price / len(rentals):.2f}"> <small style="color: var(--text-muted);">Общая сумма: <span id="totalPrice">{total_price:,.0f} ₽</span></small> </div> <div class="form-group"> <label>Оплата</label> <select name="payment_type"> <option {'selected' if first['payment_type'] == 'Наличные' else ''}>Наличные</option> <option {'selected' if first['payment_type'] == 'Карта' else ''}>Карта</option> <option {'selected' if first['payment_type'] == 'Перевод' else ''}>Перевод</option> </select> </div> <div class="form-group"> <label>Статус</label> <select name="status"> <option value="active" {'selected' if first['status'] == 'active' else ''}>Активен</option> <option value="closed" {'selected' if first['status'] == 'closed' else ''}>Завершён</option> </select> </div> <div class="form-group"> <label style="display: flex; align-items: center; gap: 10px;"> <input type="checkbox" name="has_card" id="hasCard" {'checked' if first['has_card'] else ''} style="width: auto;"> <span>👑 Клубная карта</span> </label> </div> <button type="submit" class="btn-submit">💾 Сохранить</button> </form> </div> </div> <script> document.getElementById('priceInput').addEventListener('input', function() {{ const price = parseFloat(this.value) || 0; const count = {len(rentals)}; document.getElementById('totalPrice').textContent = (price * count).toLocaleString() + ' ₽'; }}); </script> ''' return layout(content) except Exception as e: log_error(f"Ошибка в rentals_edit {rental_id}", e) flash(f'Ошибка: {str(e)}', 'error') return redirect(url_for('rentals_list')) finally: if conn: conn.close() @app.route('/rentals/close/<int:rental_id>', methods=['GET', 'POST']) @login_required def rentals_close(rental_id): conn = None try: conn = get_db() cursor = conn.cursor() group_id = request.args.get('group_id', '') if group_id: cursor.execute("SELECT * FROM rentals WHERE group_id = ? AND status = 'active'", (group_id,)) rentals = cursor.fetchall() else: cursor.execute("SELECT * FROM rentals WHERE id = ? AND status = 'active'", (rental_id,)) rentals = cursor.fetchall() if not rentals: flash('Прокат уже завершён', 'error') return redirect(url_for('rentals_list')) first = rentals[0] actual_group_id = first['group_id'] if request.method == 'POST': close_option = request.form.get('close_option', 'now') if close_option == 'planned': end_time = datetime.fromisoformat(first['start_time']) if first['start_time'] else get_now() if end_time.tzinfo is None: end_time = CHELYABINSK_TZ.localize(end_time) # Добавляем запланированное время if first['tariff_type'] == 'hourly': hours = first['hours'] or 1 half_hours = first['half_hours'] or 0 end_time += timedelta(hours=hours, minutes=half_hours * 30) elif first['tariff_type'] == 'daily': days = first['days'] or 1 end_time += timedelta(days=days) elif close_option == 'custom': end_time_str = request.form.get('custom_time') end_time = datetime.fromisoformat(end_time_str) if end_time_str else get_now() if end_time.tzinfo is None: end_time = CHELYABINSK_TZ.localize(end_time) else: # now end_time = get_now() total_price = 0 for r in rentals: start = datetime.fromisoformat(r['start_time']) if start.tzinfo is None: start = CHELYABINSK_TZ.localize(start) diff_hours = max(0, (end_time - start).total_seconds() / 3600) hours = int(diff_hours) half_hours = int((diff_hours - hours) * 2) days = max(1, int(diff_hours / 24) + (1 if diff_hours % 24 > 0 else 0)) cat = r['inventory_category'] or 'adult' price = calculate_price(cat, hours, half_hours, r['has_card'], r['tariff_type'], days) total_price += price cursor.execute(""" UPDATE rentals SET status = 'closed', end_time = ?, closed_at = ?, hours = ?, half_hours = ?, days = ?, price = ? WHERE id = ? """, (end_time.isoformat(), get_now().isoformat(), hours, half_hours, days, price, r['id'])) cursor.execute("UPDATE inventory SET status = 'free' WHERE id = ?", (r['inventory_id'],)) # Обновляем доход по group_id cursor.execute(""" UPDATE income SET amount = ?, payment_type = ?, date = ? WHERE rental_group_id = ? AND category = 'Прокат' """, (total_price, first['payment_type'], get_now().strftime("%Y-%m-%d"), actual_group_id)) conn.commit() flash(f'✅ Завершено! Сумма: {total_price:,.0f} ₽', 'success') return redirect(url_for('rentals_list')) now = get_now() planned_end = None if first['start_time']: start = datetime.fromisoformat(first['start_time']) if start.tzinfo is None: start = CHELYABINSK_TZ.localize(start) if first['tariff_type'] == 'hourly': hours = first['hours'] or 1 half_hours = first['half_hours'] or 0 planned_end = start + timedelta(hours=hours, minutes=half_hours * 30) elif first['tariff_type'] == 'daily': days = first['days'] or 1 planned_end = start + timedelta(days=days) content = f''' <style> .close-container {{ max-width: 600px; margin: 0 auto; }} .close-card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 30px; }} .form-group {{ margin-bottom: 20px; }} .form-group label {{ display: block; margin-bottom: 8px; }} .form-group input {{ width: 100%; padding: 12px; border-radius: 10px; background: var(--input-bg); color: var(--text-primary); border: 1px solid var(--border); }} .btn-submit {{ width: 100%; padding: 14px; background: #10b981; color: white; border: none; border-radius: 10px; font-size: 1rem; cursor: pointer; }} .close-options {{ display: flex; flex-direction: column; gap: 12px; margin-bottom: 20px; }} .close-option {{ display: flex; align-items: center; gap: 12px; padding: 15px; background: var(--bg-primary); border: 2px solid var(--border); border-radius: 12px; cursor: pointer; transition: all 0.2s; }} .close-option:hover {{ border-color: var(--accent); }} .close-option.selected {{ border-color: var(--accent); background: rgba(59, 130, 246, 0.1); }} .close-option input {{ display: none; }} .close-option-icon {{ font-size: 1.5rem; width: 40px; text-align: center; }} .close-option-content {{ flex: 1; }} .close-option-title {{ font-weight: bold; margin-bottom: 4px; }} .close-option-desc {{ font-size: 0.8rem; color: var(--text-muted); }} .group-id-badge {{ background: var(--bg-primary); padding: 8px 12px; border-radius: 8px; font-family: monospace; margin-bottom: 15px; }} </style> <div class="close-container"> <div class="close-card"> <h2 style="margin-bottom: 10px;"><i class="fas fa-check-circle" style="color: #10b981;"></i> Завершение</h2> <div class="group-id-badge"> <i class="fas fa-hashtag"></i> ID проката: <strong>{actual_group_id}</strong> </div> <p><strong>Клиент:</strong> {first['client_name']}</p> <p><strong>Телефон:</strong> {first['client_phone']}</p> <p><strong>Начало:</strong> {first['start_time'][:16] if first['start_time'] else '—'}</p> <p><strong>Велосипедов:</strong> {len(rentals)}</p> <p><strong>Тариф:</strong> {first['tariff_type']}</p> <form method="POST"> <div class="close-options"> <label class="close-option" onclick="selectOption('now')"> <input type="radio" name="close_option" value="now" id="opt_now" checked> <span class="close-option-icon">🕐</span> <div class="close-option-content"> <div class="close-option-title">По фактическому времени</div> <div class="close-option-desc">Текущее время: {now.strftime("%d.%m.%Y %H:%M")}</div> </div> </label> <label class="close-option" onclick="selectOption('planned')"> <input type="radio" name="close_option" value="planned" id="opt_planned"> <span class="close-option-icon">📋</span> <div class="close-option-content"> <div class="close-option-title">По запланированному времени</div> <div class="close-option-desc">Окончание: {planned_end.strftime("%d.%m.%Y %H:%M") if planned_end else 'не задано'}</div> </div> </label> <label class="close-option" onclick="selectOption('custom')"> <input type="radio" name="close_option" value="custom" id="opt_custom"> <span class="close-option-icon">✏️</span> <div class="close-option-content"> <div class="close-option-title">Указать вручную</div> <div class="close-option-desc">Выбрать точное время</div> </div> </label> </div> <div class="form-group" id="customTimeBlock" style="display: none;"> <label>Выберите время завершения</label> <input type="datetime-local" name="custom_time" id="customTimeInput" value="{now.strftime('%Y-%m-%dT%H:%M')}"> </div> <button type="submit" class="btn-submit">✅ Завершить прокат</button> </form> </div> </div> <script> function selectOption(opt) {{ document.getElementById('opt_' + opt).checked = true; document.querySelectorAll('.close-option').forEach(el => el.classList.remove('selected')); event.currentTarget.classList.add('selected'); document.getElementById('customTimeBlock').style.display = opt === 'custom' ? 'block' : 'none'; }} // По умолчанию выбрано "по фактическому" document.querySelector('.close-option').classList.add('selected'); </script> ''' return layout(content) except Exception as e: log_error(f"Ошибка в rentals_close {rental_id}", e) flash(f'Ошибка: {str(e)}', 'error') return redirect(url_for('rentals_list')) finally: if conn: conn.close() @app.route('/rentals/delete/<int:rental_id>') @login_required def rentals_delete(rental_id): conn = None try: conn = get_db() cursor = conn.cursor() group_id = request.args.get('group_id', '') if group_id: cursor.execute("SELECT * FROM rentals WHERE group_id = ?", (group_id,)) rentals = cursor.fetchall() else: cursor.execute("SELECT * FROM rentals WHERE id = ?", (rental_id,)) rentals = cursor.fetchall() if rentals: actual_group_id = rentals[0]['group_id'] for r in rentals: if r['status'] == 'active': cursor.execute("UPDATE inventory SET status = 'free' WHERE id = ?", (r['inventory_id'],)) cursor.execute("DELETE FROM rentals WHERE id = ?", (r['id'],)) # Удаляем доход по group_id cursor.execute("DELETE FROM income WHERE rental_group_id = ? AND category = 'Прокат'", (actual_group_id,)) conn.commit() flash(f'✅ Удалено прокатов: {len(rentals)}', 'success') else: flash('Прокат не найден', 'error') except Exception as e: log_error(f"Ошибка в rentals_delete {rental_id}", e) if conn: conn.rollback() flash('Ошибка', 'error') finally: if conn: conn.close() return redirect(url_for('rentals_list')) # ===================== ИНВЕНТАРЬ ===================== @app.route('/inventory') @login_required def inventory(): """Список инвентаря""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) search = request.args.get('search', '').lower() status_filter = request.args.get('status', 'all') category_filter = request.args.get('category', 'all') page = safe_int(request.args.get('page', 1), 1) per_page = 20 query = "SELECT * FROM inventory" params = [] conditions = [] if not is_admin and employee_point: conditions.append("point_id = ?") params.append(employee_point) if status_filter != 'all': conditions.append("status = ?") params.append(status_filter) if category_filter != 'all': conditions.append("category = ?") params.append(category_filter) if conditions: query += " WHERE " + " AND ".join(conditions) query += " ORDER BY id DESC" cursor.execute(query, params) inventory_list = cursor.fetchall() cursor.execute("SELECT DISTINCT category FROM inventory WHERE category IS NOT NULL") categories = [row['category'] for row in cursor.fetchall()] if search: inventory_list = [i for i in inventory_list if search in i['name'].lower()] total = len(inventory_list) total_pages = max(1, (total + per_page - 1) // per_page) start = (page - 1) * per_page paginated = inventory_list[start:start + per_page] status_map = { 'free': ('Свободен', 'badge-free'), 'rented': ('В аренде', 'badge-rented'), 'repair': ('На ремонте', 'badge-warning') } rows = '' for item in paginated: status_info = status_map.get(item['status'], ('Свободен', 'badge-free')) status_text = status_info[0] status_class = status_info[1] rows += f''' <tr> <td><strong>{item['name']}</strong></td> <td>{item['category_name'] or '—'}</td> <td>{item['point_name'] or '—'}</td> <td><span class="badge {status_class}">{status_text}</span></td> <td> <a href="/inventory/edit/{item['id']}" class="btn-icon"><i class="fas fa-edit"></i></a> <a href="/inventory/delete/{item['id']}" class="btn-icon btn-icon-danger" onclick="return confirm('Удалить инвентарь?')"><i class="fas fa-trash-alt"></i></a> </td> </tr> ''' if not rows: rows = '<tr><td colspan="5" style="text-align:center;padding:40px;">Нет инвентаря</td></tr>' prev_btn = f'<button class="page-btn" onclick="window.goToPage({page-1})">←</button>' if page > 1 else '' next_btn = f'<button class="page-btn" onclick="window.goToPage({page+1})">→</button>' if page < total_pages else '' category_opts = '<option value="all">Все категории</option>' for cat in categories: selected = 'selected' if category_filter == cat else '' category_opts += f'<option value="{cat}" {selected}>{cat}</option>' export_button = '' if is_admin: export_button = '<a href="/inventory/export/excel"><button class="btn-success"><i class="fas fa-file-excel"></i> Экспорт Excel</button></a>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-bicycle"></i> Инвентарь</h2> <div style="display: flex; gap: 10px;"> {export_button} <a href="/inventory/add"><button class="btn-primary"><i class="fas fa-plus"></i> Добавить</button></a> </div> </div> <div class="filters-bar"> <input type="text" class="search-input" id="searchInput" placeholder="🔍 Поиск..." value="{request.args.get('search', '')}"> <select class="filter-select" id="statusFilter"> <option value="all">Все статусы</option> <option value="free" {"selected" if status_filter=="free" else ""}>Свободен</option> <option value="rented" {"selected" if status_filter=="rented" else ""}>В аренде</option> <option value="repair" {"selected" if status_filter=="repair" else ""}>На ремонте</option> </select> <select class="filter-select" id="categoryFilter"> {category_opts} </select> <button class="btn-primary" onclick="window.applyFilters()"><i class="fas fa-search"></i></button> <button class="btn-outline" onclick="window.clearFilters()"><i class="fas fa-times"></i> Сбросить</button> </div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>Название</th><th>Категория</th><th>Пункт</th><th>Статус</th><th></th></thead> <tbody>{rows}</tbody> </table> </div> <div class="pagination">{prev_btn}<span>{page} / {total_pages}</span>{next_btn}</div></div> <script> window.applyFilters = function() {{ const search = document.getElementById('searchInput').value; const status = document.getElementById('statusFilter').value; const category = document.getElementById('categoryFilter').value; window.location.href = `/inventory?search=${{encodeURIComponent(search)}}&status=${{status}}&category=${{encodeURIComponent(category)}}&page=1`; }}; window.clearFilters = function() {{ window.location.href = '/inventory'; }}; window.goToPage = function(page) {{ const search = document.getElementById('searchInput').value; const status = document.getElementById('statusFilter').value; const category = document.getElementById('categoryFilter').value; window.location.href = `/inventory?search=${{encodeURIComponent(search)}}&status=${{status}}&category=${{encodeURIComponent(category)}}&page=${{page}}`; }}; </script> ''' return layout(content) except Exception as e: log_error("Ошибка при загрузке инвентаря", e) flash('Произошла ошибка при загрузке', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/inventory/add', methods=['GET', 'POST']) @login_required def inventory_add(): """Добавление инвентаря""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, employee_point_name = get_employee_point(employee_id) if request.method == 'POST': name = request.form.get('name', '').strip() category = request.form.get('category', 'adult') if not name: flash('Название обязательно', 'error') return redirect(url_for('inventory_add')) category_name = {'adult': 'Взрослый', 'child': 'Детский', 'sup': 'Сапборд'}.get(category, 'Взрослый') if is_admin: point_id = safe_int(request.form.get('point_id')) cursor.execute("SELECT name FROM points WHERE id = ?", (point_id,)) point = cursor.fetchone() point_name = point['name'] if point else '' else: point_id = employee_point point_name = employee_point_name cursor.execute("INSERT INTO inventory (name, category, category_name, point_id, point_name, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", (name, category, category_name, point_id, point_name, 'free', get_now().isoformat())) conn.commit() flash('Инвентарь добавлен!', 'success') return redirect(url_for('inventory')) cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = ''.join(f'<option value="{p["id"]}">{p["name"]}</option>' for p in points) if not is_admin: point_field = f''' <div class="form-group"> <label>Пункт</label> <input type="text" value="{employee_point_name}" disabled> <input type="hidden" name="point_id" value="{employee_point}"> </div> ''' else: point_field = f''' <div class="form-group"> <label>Пункт</label> <select name="point_id" required>{points_opts}</select> </div> ''' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-plus-circle"></i> Добавить инвентарь</h2><a href="/inventory"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Название *</label><input type="text" name="name" required></div> <div class="form-group"><label>Категория *</label> <select name="category" required> <option value="adult">Взрослый</option> <option value="child">Детский</option> <option value="sup">Сапборд</option> </select> </div> </div> <div class="form-row">{point_field}</div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error("Ошибка при добавлении инвентаря", e) if conn: conn.rollback() flash('Произошла ошибка при добавлении', 'error') return redirect(url_for('inventory')) finally: if conn: conn.close() @app.route('/inventory/edit/<int:inventory_id>', methods=['GET', 'POST']) @login_required def inventory_edit(inventory_id): """Редактирование инвентаря""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) cursor.execute("SELECT * FROM inventory WHERE id = ?", (inventory_id,)) item = cursor.fetchone() if not item: flash('Инвентарь не найден', 'error') return redirect(url_for('inventory')) if not is_admin and item['point_id'] != employee_point: flash('Нет доступа', 'error') return redirect(url_for('inventory')) if request.method == 'POST': name = request.form.get('name', '').strip() category = request.form.get('category', 'adult') if not name: flash('Название обязательно', 'error') return redirect(url_for('inventory_edit', inventory_id=inventory_id)) category_name = {'adult': 'Взрослый', 'child': 'Детский', 'sup': 'Сапборд'}.get(category, 'Взрослый') if is_admin: point_id = safe_int(request.form.get('point_id')) cursor.execute("SELECT name FROM points WHERE id = ?", (point_id,)) point = cursor.fetchone() point_name = point['name'] if point else '' cursor.execute("UPDATE inventory SET name = ?, category = ?, category_name = ?, point_id = ?, point_name = ?, status = ? WHERE id = ?", (name, category, category_name, point_id, point_name, request.form.get('status'), inventory_id)) else: cursor.execute("UPDATE inventory SET name = ?, category = ?, category_name = ?, status = ? WHERE id = ?", (name, category, category_name, request.form.get('status'), inventory_id)) conn.commit() flash('Инвентарь обновлён!', 'success') return redirect(url_for('inventory')) cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = ''.join(f'<option value="{p["id"]}" {"selected" if p["id"] == item["point_id"] else ""}>{p["name"]}</option>' for p in points) if not is_admin: point_field = f''' <div class="form-group"> <label>Пункт</label> <input type="text" value="{item['point_name']}" disabled> <input type="hidden" name="point_id" value="{item['point_id']}"> </div> ''' else: point_field = f''' <div class="form-group"> <label>Пункт</label> <select name="point_id">{points_opts}</select> </div> ''' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-edit"></i> Редактирование инвентаря</h2><a href="/inventory"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Название *</label><input type="text" name="name" value="{item['name']}" required></div> <div class="form-group"><label>Категория</label> <select name="category"> <option value="adult" {"selected" if item['category']=='adult' else ""}>Взрослый</option> <option value="child" {"selected" if item['category']=='child' else ""}>Детский</option> <option value="sup" {"selected" if item['category']=='sup' else ""}>Сапборд</option> </select> </div> </div> <div class="form-row"> {point_field} <div class="form-group"><label>Статус</label> <select name="status"> <option value="free" {"selected" if item['status']=='free' else ""}>Свободен</option> <option value="rented" {"selected" if item['status']=='rented' else ""}>В аренде</option> <option value="repair" {"selected" if item['status']=='repair' else ""}>На ремонте</option> </select> </div> </div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error(f"Ошибка при редактировании инвентаря {inventory_id}", e) if conn: conn.rollback() flash('Произошла ошибка при редактировании', 'error') return redirect(url_for('inventory')) finally: if conn: conn.close() @app.route('/inventory/delete/<int:inventory_id>') @login_required def inventory_delete(inventory_id): """Удаление инвентаря""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) cursor.execute("SELECT point_id FROM inventory WHERE id = ?", (inventory_id,)) item = cursor.fetchone() if item and (is_admin or item['point_id'] == employee_point): cursor.execute("SELECT COUNT(*) FROM rentals WHERE inventory_id = ? AND status = 'active'", (inventory_id,)) active_rentals = cursor.fetchone()[0] if active_rentals > 0: flash('Нельзя удалить инвентарь, который находится в активной аренде', 'error') else: cursor.execute("DELETE FROM inventory WHERE id = ?", (inventory_id,)) conn.commit() flash('Инвентарь удалён', 'success') else: flash('Нет доступа', 'error') except Exception as e: log_error(f"Ошибка при удалении инвентаря {inventory_id}", e) if conn: conn.rollback() flash('Произошла ошибка при удалении', 'error') finally: if conn: conn.close() return redirect(url_for('inventory')) @app.route('/inventory/export/excel') @login_required @admin_required def inventory_export_excel(): """Экспорт инвентаря в Excel""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) if not is_admin and employee_point: cursor.execute("SELECT * FROM inventory WHERE point_id = ? ORDER BY id", (employee_point,)) else: cursor.execute("SELECT * FROM inventory ORDER BY id") inventory = cursor.fetchall() output = io.BytesIO() workbook = xlsxwriter.Workbook(output, {'in_memory': True}) worksheet = workbook.add_worksheet('Инвентарь') header_format = workbook.add_format({'bold': True, 'bg_color': '#366092', 'font_color': 'white', 'border': 1}) cell_format = workbook.add_format({'border': 1}) headers = ['ID', 'Название', 'Категория', 'Пункт', 'Статус', 'Дата добавления'] status_map = {'free': 'Свободен', 'rented': 'В аренде', 'repair': 'На ремонте'} for col, header in enumerate(headers): worksheet.write(0, col, header, header_format) for row, item in enumerate(inventory, start=1): worksheet.write(row, 0, item['id'], cell_format) worksheet.write(row, 1, item['name'], cell_format) worksheet.write(row, 2, item['category_name'] or '—', cell_format) worksheet.write(row, 3, item['point_name'] or '—', cell_format) worksheet.write(row, 4, status_map.get(item['status'], 'Свободен'), cell_format) worksheet.write(row, 5, item['created_at'] or '', cell_format) worksheet.set_column(0, 0, 8) worksheet.set_column(1, 1, 25) worksheet.set_column(2, 2, 15) worksheet.set_column(3, 3, 20) worksheet.set_column(4, 4, 12) worksheet.set_column(5, 5, 20) workbook.close() output.seek(0) return send_file(output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=f'инвентарь_{get_now().strftime("%Y-%m-%d")}.xlsx') except Exception as e: log_error("Ошибка при экспорте инвентаря", e) flash('Произошла ошибка при экспорте', 'error') return redirect(url_for('inventory')) finally: if conn: conn.close() # ===================== РЕМОНТ ===================== @app.route('/service/repairs', methods=['GET']) @login_required def service_repairs(): """Список ремонтов с формой создания нового""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, employee_point_name = get_employee_point(employee_id) # Создаём таблицу repair_parts если её нет cursor.execute(''' CREATE TABLE IF NOT EXISTS repair_parts ( id INTEGER PRIMARY KEY AUTOINCREMENT, repair_id INTEGER, part_id INTEGER, quantity INTEGER, price REAL ) ''') conn.commit() # Добавляем колонку repair_id в income если её нет try: cursor.execute("ALTER TABLE income ADD COLUMN repair_id INTEGER") conn.commit() except: pass # Фильтры status_filter = request.args.get('status', 'active') point_filter = request.args.get('point', 'all') employee_filter = request.args.get('employee', 'all') page = safe_int(request.args.get('page', 1), 1) per_page = 20 # Пункты для фильтра cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = '<option value="all">🏢 Все пункты</option>' for p in points: selected = 'selected' if point_filter == str(p['id']) else '' points_opts += f'<option value="{p["id"]}" {selected}>📍 {p["name"]}</option>' # Сотрудники для фильтра cursor.execute("SELECT id, name FROM employees ORDER BY name") employees = cursor.fetchall() employees_opts = '<option value="all">👥 Все сотрудники</option>' for e in employees: selected = 'selected' if employee_filter == str(e['id']) else '' employees_opts += f'<option value="{e["id"]}" {selected}>👤 {e["name"]}</option>' # Основной запрос query = """ SELECT r.*, e.name as employee_name, e.point_name as emp_point_name FROM repairs r LEFT JOIN employees e ON r.employee_id = e.id WHERE 1=1 """ params = [] if not is_admin and employee_id: query += " AND r.employee_id = ?" params.append(employee_id) if status_filter == 'active': query += " AND r.status IN ('pending', 'progress', 'done')" elif status_filter == 'closed': query += " AND r.status = 'issued'" if point_filter != 'all': query += " AND e.point_id = ?" params.append(point_filter) if employee_filter != 'all': query += " AND r.employee_id = ?" params.append(employee_filter) query += " ORDER BY r.created_at DESC" cursor.execute(query, params) repairs = cursor.fetchall() total = len(repairs) total_pages = max(1, (total + per_page - 1) // per_page) start = (page - 1) * per_page paginated = repairs[start:start + per_page] status_map = { 'pending': ('Ожидает', 'pending'), 'progress': ('В работе', 'progress'), 'done': ('Готов', 'done'), 'issued': ('Выдан', 'closed') } rows = '' for r in paginated: status_info = status_map.get(r['status'], ('Ожидает', 'pending')) status_text = status_info[0] status_class = status_info[1] payment_status_map = { 'unpaid': ('Не оплачен', 'danger'), 'partial': ('Частично', 'warning'), 'paid': ('Оплачен', 'success') } payment_info = payment_status_map.get(r['payment_status'], ('Не оплачен', 'danger')) payment_text = payment_info[0] payment_class = payment_info[1] services_preview = (r['service_names'] or '—')[:40] if len(r['service_names'] or '') > 40: services_preview += '...' rows += f''' <tr class="clickable-row" data-url="/service/repair_edit/{r['id']}" data-repair-id="{r['id']}"> <td><span class="date-badge">{r['created_at'][:16] if r['created_at'] else '—'}</span></td> <td> <strong>{r['client_name']}</strong><br> <span style="color: var(--accent); font-size: 0.75rem;">{r['client_phone']}</span> </td> <td>{r['bike_name'] or '—'}</td> <td><span class="service-preview">{services_preview}</span></td> <td><span class="status-badge status-{status_class}">{status_text}</span></td> <td><strong style="color: var(--accent);">{r['price']:,.0f} ₽</strong></td> <td><span class="payment-badge payment-{payment_class}">{payment_text}</span></td> <td>{r['employee_name'] or '—'}</td> <td> <div class="actions-menu"> <button class="btn-icon" onclick="event.stopPropagation(); toggleMenu(this)">⋮</button> <div class="menu-dropdown"> <a href="/service/repair_edit/{r['id']}"><i class="fas fa-edit"></i> Редактировать</a> <a href="#" onclick="event.preventDefault(); completeRepair({r['id']});"><i class="fas fa-check-circle"></i> Завершить</a> <a href="#" onclick="event.preventDefault(); if(confirm('Удалить ремонт?')) location.href='/service/repair_delete/{r['id']}';" class="danger"><i class="fas fa-trash-alt"></i> Удалить</a> </div> </div> </td> </tr> ''' if not rows: rows = '<tr><td colspan="9" style="text-align:center;padding:40px;color: var(--text-muted);">Нет ремонтов</td></tr>' # Данные для формы создания cursor.execute("SELECT * FROM services ORDER BY category, name") services_list = cursor.fetchall() if not is_admin and employee_point: cursor.execute("SELECT * FROM parts WHERE point_id = ? AND quantity > 0 ORDER BY name", (employee_point,)) else: cursor.execute("SELECT * FROM parts WHERE quantity > 0 ORDER BY name") parts_list = cursor.fetchall() employees_for_form = '<option value="">— Выберите мастера —</option>' for e in employees: selected = 'selected' if e['id'] == employee_id else '' employees_for_form += f'<option value="{e["id"]}" {selected}>{e["name"]}</option>' # Группируем услуги services_by_cat = {} for s in services_list: cat = s['category'] or 'Прочее' if cat not in services_by_cat: services_by_cat[cat] = [] services_by_cat[cat].append(s) services_html = '' for cat, cat_services in services_by_cat.items(): services_html += f'<div class="category-title"><i class="fas fa-folder-open"></i> {cat}</div><div class="category-items">' for s in cat_services: services_html += f''' <div class="service-item" data-id="{s['id']}" data-price="{s['price']}" data-name="{s['name']}" data-type="service" onclick="toggleItem(this)"> <span>🔧 {s['name']}</span> <span class="item-price">{s['price']} ₽</span> </div> ''' services_html += '</div>' # Группируем запчасти parts_by_point = {} for p in parts_list: point = p['point_name'] or 'СКЛАД' if point not in parts_by_point: parts_by_point[point] = [] parts_by_point[point].append(p) parts_html = '' for point, point_parts in parts_by_point.items(): parts_html += f'<div class="category-title"><i class="fas fa-map-marker-alt"></i> {point}</div><div class="category-items">' for p in point_parts: parts_html += f''' <div class="service-item" data-id="{p['id']}" data-price="{p['selling_price']}" data-name="{p['name']}" data-type="part" data-max="{p['quantity']}" onclick="toggleItem(this)"> <span>🔩 {p['name']}</span> <span class="item-stock">({p['quantity']} шт)</span> <span class="item-price">{p['selling_price']} ₽</span> </div> ''' parts_html += '</div>' content = f''' <style> .repairs-page {{ display: flex; gap: 24px; }} .repairs-form {{ width: 380px; flex-shrink: 0; }} .repairs-list {{ flex: 1; min-width: 0; }} .form-card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; position: sticky; top: 76px; max-height: calc(100vh - 100px); overflow-y: auto; }} .form-card h3 {{ margin-bottom: 16px; color: var(--accent); font-size: 1rem; }} .form-group {{ margin-bottom: 14px; }} .form-group label {{ display: block; margin-bottom: 4px; font-size: 0.75rem; color: var(--text-muted); }} .form-group input, .form-group select, .form-group textarea {{ width: 100%; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); font-size: 0.85rem; }} .phone-search {{ display: flex; gap: 8px; }} .phone-search input {{ flex: 1; }} .phone-search button {{ padding: 10px 16px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }} .tabs {{ display: flex; gap: 4px; margin-bottom: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }} .tab {{ padding: 6px 16px; background: transparent; border: none; color: var(--text-secondary); cursor: pointer; border-radius: 20px; font-size: 0.8rem; }} .tab.active {{ background: var(--accent); color: white; }} .items-container {{ max-height: 250px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--bg-primary); }} .category-title {{ padding: 6px 8px; background: var(--bg-header); border-radius: 4px; margin: 8px 0 4px; font-size: 0.7rem; font-weight: bold; color: var(--accent); }} .category-items {{ display: flex; flex-wrap: wrap; gap: 4px; }} .service-item {{ display: flex; align-items: center; justify-content: space-between; padding: 6px 10px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; cursor: pointer; font-size: 0.75rem; }} .service-item.selected {{ background: var(--accent); color: white; border-color: var(--accent); }} .service-item.selected .item-price {{ color: white; }} .item-price {{ font-weight: bold; color: var(--accent); margin-left: 8px; }} .item-stock {{ color: var(--text-muted); font-size: 0.65rem; margin-left: 4px; }} .selected-items {{ margin-top: 16px; padding: 12px; background: var(--bg-primary); border-radius: 8px; }} .selected-items h4 {{ margin-bottom: 8px; font-size: 0.8rem; }} .selected-item-row {{ display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 0.75rem; border-bottom: 1px solid var(--border); }} .selected-item-row:last-child {{ border-bottom: none; }} .qty-control {{ display: flex; align-items: center; gap: 6px; }} .qty-control button {{ width: 20px; height: 20px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); cursor: pointer; }} .remove-item {{ color: var(--danger); cursor: pointer; margin-left: 8px; }} .total-price {{ margin-top: 12px; text-align: right; font-weight: bold; color: var(--accent); }} .btn-submit {{ width: 100%; padding: 12px; background: var(--accent); color: white; border: none; border-radius: 8px; font-weight: bold; cursor: pointer; margin-top: 16px; }} .filter-bar {{ display: flex; gap: 8px; flex-wrap: wrap; padding: 12px 16px; background: var(--bg-header); border-bottom: 1px solid var(--border); margin-bottom: 0; }} .filter-select {{ padding: 6px 12px; border-radius: 40px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); font-size: 0.75rem; }} .date-badge {{ background: var(--bg-primary); padding: 4px 8px; border-radius: 20px; font-size: 0.7rem; }} .service-preview {{ max-width: 180px; display: inline-block; color: var(--text-secondary); font-size: 0.7rem; }} .status-badge {{ padding: 4px 8px; border-radius: 4px; font-size: 0.7rem; font-weight: 500; }} .status-pending {{ background: #78350f; color: #fbbf24; }} .status-progress {{ background: #1e3a5f; color: #60a5fa; }} .status-done {{ background: #065f46; color: #34d399; }} .status-closed {{ background: #374151; color: #9ca3af; }} .payment-badge {{ padding: 4px 8px; border-radius: 4px; font-size: 0.7rem; }} .payment-danger {{ background: #450a0a; color: #f87171; }} .payment-warning {{ background: #78350f; color: #fbbf24; }} .payment-success {{ background: #065f46; color: #34d399; }} .clickable-row {{ cursor: pointer; }} @media (max-width: 900px) {{ .repairs-page {{ flex-direction: column; }} .repairs-form {{ width: 100%; }} }} </style> <div class="repairs-page"> <div class="repairs-form"> <div class="form-card"> <h3><i class="fas fa-plus-circle"></i> Новая заявка</h3> <form method="POST" action="/service/repair_add" id="repairForm"> <div class="form-group"> <label>📞 Телефон *</label> <div class="phone-search"> <input type="tel" name="phone_search" id="phoneSearch" placeholder="+79001234567" required> <button type="button" onclick="searchClient()"><i class="fas fa-search"></i></button> </div> </div> <div id="clientFields" style="display:none;"> <div class="form-group"><input type="text" name="last_name" id="lastName" placeholder="Фамилия"></div> <div class="form-group"><input type="text" name="first_name" id="firstName" placeholder="Имя"></div> <div class="form-group"><input type="text" name="middle_name" id="middleName" placeholder="Отчество"></div> </div> <div id="newClientFields"> <div class="form-group"><input type="text" name="last_name_new" id="lastNameNew" placeholder="Фамилия *" required></div> <div class="form-group"><input type="text" name="first_name_new" id="firstNameNew" placeholder="Имя *" required></div> <div class="form-group"><input type="text" name="middle_name_new" id="middleNameNew" placeholder="Отчество"></div> </div> <input type="hidden" name="phone" id="phoneInput"> <div class="form-group"><input type="text" name="bike_name" id="bikeName" placeholder="🚲 Велосипед клиента"></div> <div class="form-group"><textarea name="comment" rows="2" placeholder="💬 Комментарий"></textarea></div> <div class="form-group"> <select name="payment_type" id="paymentType"> <option value="Наличные">Наличные</option> <option value="Карта">Карта</option> <option value="Перевод">Перевод</option> </select> </div> <div class="form-group"> <select name="employee_id">{employees_for_form}</select> </div> <div class="tabs"> <button type="button" class="tab active" onclick="switchTab('services')">🔧 Услуги</button> <button type="button" class="tab" onclick="switchTab('parts')">🔩 Запчасти</button> </div> <div id="servicesBlock"> <input type="text" id="serviceSearch" placeholder="🔍 Поиск..." style="width:100%; padding:8px; margin-bottom:8px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> <div class="items-container" id="servicesContainer">{services_html}</div> </div> <div id="partsBlock" style="display:none;"> <input type="text" id="partSearch" placeholder="🔍 Поиск..." style="width:100%; padding:8px; margin-bottom:8px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> <div class="items-container" id="partsContainer">{parts_html}</div> </div> <div class="selected-items"> <h4><i class="fas fa-shopping-cart"></i> Выбрано</h4> <div id="selectedItemsList"><div style="text-align:center;padding:10px;color:var(--text-muted);">Нет позиций</div></div> <div class="total-price">Итого: <span id="totalPrice">0</span> ₽</div> </div> <input type="hidden" name="service_items" id="selectedServices" value=""> <input type="hidden" name="part_items" id="selectedParts" value=""> <input type="hidden" name="part_quantities" id="selectedPartQuantities" value=""> <button type="submit" class="btn-submit"><i class="fas fa-plus"></i> Создать заявку</button> </form> </div> </div> <div class="repairs-list"> <div class="card"> <div class="filter-bar"> <select id="statusFilter" onchange="applyFilter()" class="filter-select"> <option value="active" {'selected' if status_filter == 'active' else ''}>🟢 Активные</option> <option value="closed" {'selected' if status_filter == 'closed' else ''}>✅ Завершённые</option> <option value="all" {'selected' if status_filter == 'all' else ''}>📋 Все</option> </select> <select id="pointFilter" onchange="applyFilter()" class="filter-select">{points_opts}</select> <select id="employeeFilter" onchange="applyFilter()" class="filter-select">{employees_opts}</select> <button class="btn-outline" onclick="resetFilters()" style="padding:6px 12px;"><i class="fas fa-times"></i></button> </div> <div style="overflow-x:auto;"> <table class="data-table"> <thead> <tr><th>Дата</th><th>Клиент</th><th>Вело</th><th>Услуги</th><th>Статус</th><th>Сумма</th><th>Оплата</th><th>Мастер</th><th></th></tr> </thead> <tbody>{rows}</tbody> </table> </div> <div class="pagination"> {f'<a href="?status={status_filter}&point={point_filter}&employee={employee_filter}&page={page-1}" class="page-btn">←</a>' if page > 1 else ''} <span>{page} / {total_pages}</span> {f'<a href="?status={status_filter}&point={point_filter}&employee={employee_filter}&page={page+1}" class="page-btn">→</a>' if page < total_pages else ''} </div> </div> </div> </div> <div id="completeModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.5); z-index:10000; justify-content:center; align-items:center;"> <div style="background:var(--bg-card); border-radius:20px; padding:30px; width:90%; max-width:450px;"> <h3><i class="fas fa-check-circle"></i> Завершение ремонта</h3> <div id="modalClientInfo" style="margin:15px 0; padding:15px; background:var(--bg-primary); border-radius:10px;"></div> <div class="form-group"><label>Сумма к оплате</label><input type="number" id="modalAmount" step="0.01" style="width:100%; padding:12px; border-radius:10px; background:var(--input-bg); color:var(--text-primary); border:1px solid var(--border);"></div> <div class="form-group"><label>Способ оплаты</label><select id="modalPaymentType" style="width:100%; padding:12px; border-radius:10px; background:var(--input-bg); color:var(--text-primary); border:1px solid var(--border);"><option>Наличные</option><option>Карта</option><option>Перевод</option></select></div> <div style="display:flex; gap:10px; margin-top:20px;"> <button onclick="closeCompleteModal()" class="btn-outline" style="flex:1;">Отмена</button> <button onclick="confirmComplete()" class="btn-primary" style="flex:1;">Завершить</button> </div> </div> </div> <script> let selectedItems = []; let currentRepairId = null; function switchTab(tab) {{ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); event.target.classList.add('active'); document.getElementById('servicesBlock').style.display = tab === 'services' ? 'block' : 'none'; document.getElementById('partsBlock').style.display = tab === 'parts' ? 'block' : 'none'; }} function searchClient() {{ const phone = document.getElementById('phoneSearch').value; if (!phone) return; fetch('/api/client/search?q=' + encodeURIComponent(phone)) .then(r => r.json()) .then(data => {{ if (data.found && data.clients.length > 0) {{ const c = data.clients[0]; document.getElementById('clientFields').style.display = 'block'; document.getElementById('newClientFields').style.display = 'none'; document.getElementById('lastName').value = c.last_name || ''; document.getElementById('firstName').value = c.first_name || ''; document.getElementById('middleName').value = c.middle_name || ''; document.getElementById('phoneInput').value = c.phone; document.getElementById('lastNameNew').required = false; document.getElementById('firstNameNew').required = false; }} else {{ document.getElementById('clientFields').style.display = 'none'; document.getElementById('newClientFields').style.display = 'block'; document.getElementById('phoneInput').value = phone; document.getElementById('lastNameNew').required = true; document.getElementById('firstNameNew').required = true; }} }}); }} function toggleItem(el) {{ const id = el.dataset.id; const name = el.dataset.name; const price = parseFloat(el.dataset.price); const type = el.dataset.type; const max = el.dataset.max ? parseInt(el.dataset.max) : null; const existing = selectedItems.find(i => i.id === id && i.type === type); if (existing) {{ existing.qty += 1; if (max && existing.qty > max) {{ existing.qty = max; alert('Доступно: ' + max); }} }} else {{ selectedItems.push({{ id, name, price, type, qty: 1, max }}); el.classList.add('selected'); }} updateSelectedList(); }} function removeItem(index) {{ const item = selectedItems[index]; selectedItems.splice(index, 1); const hasOther = selectedItems.some(i => i.id === item.id && i.type === item.type); if (!hasOther) {{ document.querySelectorAll(`[data-id="${{item.id}}"]`).forEach(el => {{ if (el.dataset.type === item.type) el.classList.remove('selected'); }}); }} updateSelectedList(); }} function changeQty(index, delta) {{ const item = selectedItems[index]; item.qty = Math.max(1, item.qty + delta); if (item.max && item.qty > item.max) {{ item.qty = item.max; }} updateSelectedList(); }} function updateSelectedList() {{ const container = document.getElementById('selectedItemsList'); const totalSpan = document.getElementById('totalPrice'); if (selectedItems.length === 0) {{ container.innerHTML = '<div style="text-align:center;padding:10px;color:var(--text-muted);">Нет позиций</div>'; totalSpan.textContent = '0'; document.getElementById('selectedServices').value = ''; document.getElementById('selectedParts').value = ''; document.getElementById('selectedPartQuantities').value = '{{}}'; return; }} let html = ''; let total = 0; const serviceIds = []; const partIds = []; const quantities = {{}}; selectedItems.forEach((item, i) => {{ const itemTotal = item.price * item.qty; total += itemTotal; html += `<div class="selected-item-row"> <span>${{item.type === 'service' ? '🔧' : '🔩'}} ${{item.name}}</span> <div class="qty-control"> <button type="button" onclick="changeQty(${{i}}, -1)">−</button> <span>${{item.qty}}</span> <button type="button" onclick="changeQty(${{i}}, 1)">+</button> <span>${{itemTotal}} ₽</span> <span class="remove-item" onclick="removeItem(${{i}})">✕</span> </div> </div>`; if (item.type === 'service') {{ for (let j = 0; j < item.qty; j++) serviceIds.push(item.id); }} else {{ partIds.push(item.id); quantities[item.id] = item.qty; }} }}); container.innerHTML = html; totalSpan.textContent = total.toLocaleString(); document.getElementById('selectedServices').value = serviceIds.join(','); document.getElementById('selectedParts').value = partIds.join(','); document.getElementById('selectedPartQuantities').value = JSON.stringify(quantities); }} document.getElementById('serviceSearch').addEventListener('input', function() {{ const term = this.value.toLowerCase(); document.querySelectorAll('#servicesContainer .service-item').forEach(el => {{ el.style.display = el.textContent.toLowerCase().includes(term) ? 'flex' : 'none'; }}); }}); document.getElementById('partSearch').addEventListener('input', function() {{ const term = this.value.toLowerCase(); document.querySelectorAll('#partsContainer .service-item').forEach(el => {{ el.style.display = el.textContent.toLowerCase().includes(term) ? 'flex' : 'none'; }}); }}); function applyFilter() {{ const status = document.getElementById('statusFilter').value; const point = document.getElementById('pointFilter').value; const employee = document.getElementById('employeeFilter').value; window.location.href = `/service/repairs?status=${{status}}&point=${{point}}&employee=${{employee}}`; }} function resetFilters() {{ window.location.href = '/service/repairs'; }} function toggleMenu(btn) {{ event.stopPropagation(); const dropdown = btn.closest('.actions-menu').querySelector('.menu-dropdown'); if (!dropdown) return; document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); dropdown.classList.add('show'); const rect = btn.getBoundingClientRect(); dropdown.style.position = 'fixed'; dropdown.style.left = (rect.left - 150) + 'px'; dropdown.style.top = (rect.bottom + 5) + 'px'; dropdown.style.zIndex = '99999'; }} document.addEventListener('click', (e) => {{ if (!e.target.closest('.actions-menu')) {{ document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); }} }}); document.querySelectorAll('.clickable-row').forEach(row => {{ row.addEventListener('contextmenu', (e) => {{ e.preventDefault(); const menu = row.querySelector('.menu-dropdown'); if (menu) {{ document.querySelectorAll('.menu-dropdown.show').forEach(m => m.classList.remove('show')); menu.style.position = 'fixed'; menu.style.left = e.clientX + 'px'; menu.style.top = e.clientY + 'px'; menu.style.zIndex = '99999'; menu.classList.add('show'); setTimeout(() => {{ const closeHandler = (ev) => {{ if (!menu.contains(ev.target)) {{ menu.classList.remove('show'); document.removeEventListener('click', closeHandler); }} }}; document.addEventListener('click', closeHandler); }}, 10); }} }}); row.addEventListener('click', function(e) {{ if (e.target.closest('.actions-menu') || e.target.closest('a')) return; window.location.href = this.dataset.url; }}); }}); function completeRepair(id) {{ currentRepairId = id; fetch(`/service/repair_info/${{id}}`).then(r => r.json()).then(data => {{ document.getElementById('modalClientInfo').innerHTML = `<strong>${{data.client_name}}</strong><br>${{data.client_phone}}<br>Велосипед: ${{data.bike_name || '—'}}`; document.getElementById('modalAmount').value = data.price; document.getElementById('completeModal').style.display = 'flex'; }}); }} function closeCompleteModal() {{ document.getElementById('completeModal').style.display = 'none'; currentRepairId = null; }} function confirmComplete() {{ const amount = document.getElementById('modalAmount').value; const paymentType = document.getElementById('modalPaymentType').value; fetch(`/service/repair_complete/${{currentRepairId}}`, {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ amount: amount, payment_type: paymentType }}) }}).then(r => r.json()).then(data => {{ if (data.success) {{ location.reload(); }} else {{ alert('Ошибка: ' + data.error); }} }}); }} document.getElementById('completeModal').addEventListener('click', function(e) {{ if (e.target === this) closeCompleteModal(); }}); </script> ''' return layout(content) except Exception as e: log_error("Ошибка в service_repairs", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/service/repair_add', methods=['POST']) @login_required def service_repair_add(): """Добавление нового ремонта""" conn = None try: conn = get_db() cursor = conn.cursor() phone = request.form.get('phone') or request.form.get('phone_search') if not phone: flash('❌ Не указан номер телефона', 'error') return redirect(url_for('service_repairs')) cursor.execute("SELECT * FROM clients WHERE phone = ?", (phone,)) client = cursor.fetchone() if not client: last_name = request.form.get('last_name_new', '').strip() first_name = request.form.get('first_name_new', '').strip() middle_name = request.form.get('middle_name_new', '').strip() if not last_name: last_name = request.form.get('last_name', '').strip() if not first_name: first_name = request.form.get('first_name', '').strip() if not last_name or not first_name: flash('❌ Необходимо указать фамилию и имя клиента', 'error') return redirect(url_for('service_repairs')) cursor.execute(""" INSERT INTO clients (last_name, first_name, middle_name, phone, has_card, created_at) VALUES (?, ?, ?, ?, ?, ?) """, (last_name, first_name, middle_name, phone, 0, get_now().isoformat())) conn.commit() cursor.execute("SELECT * FROM clients WHERE phone = ?", (phone,)) client = cursor.fetchone() flash('✅ Новый клиент добавлен в базу!', 'success') service_ids_str = request.form.get('service_items', '') service_ids = [sid for sid in service_ids_str.split(',') if sid.strip()] part_ids_str = request.form.get('part_items', '') part_ids = [pid for pid in part_ids_str.split(',') if pid.strip()] part_quantities_str = request.form.get('part_quantities', '{}') try: part_quantities = json.loads(part_quantities_str) except: part_quantities = {} bike_name = request.form.get('bike_name', '').strip() comment = request.form.get('comment', '').strip() payment_type = request.form.get('payment_type', 'Наличные') employee_id_from_form = request.form.get('employee_id') if employee_id_from_form and employee_id_from_form.strip(): employee_id = int(employee_id_from_form) else: employee_id = session.get('employee_id') services_total = 0 services_names = [] for sid in service_ids: cursor.execute("SELECT * FROM services WHERE id = ?", (sid,)) s = cursor.fetchone() if s: services_total += s['price'] services_names.append(s['name']) parts_total = 0 parts_names = [] for pid in part_ids: qty = part_quantities.get(pid, 1) cursor.execute("SELECT * FROM parts WHERE id = ?", (pid,)) p = cursor.fetchone() if p: if p['quantity'] < qty: flash(f'⚠️ Недостаточно запчасти "{p["name"]}". Доступно: {p["quantity"]}', 'error') continue cursor.execute("UPDATE parts SET quantity = quantity - ? WHERE id = ?", (qty, pid)) parts_total += (p['selling_price'] or 0) * qty parts_names.append(f"{p['name']} x{qty}") total_price = services_total + parts_total all_names = services_names + parts_names service_names_str = ', '.join(all_names) if all_names else 'Без услуг' all_ids = service_ids + part_ids service_ids_str = ','.join(all_ids) cursor.execute(""" INSERT INTO repairs (client_id, client_name, client_phone, employee_id, service_ids, service_names, bike_name, description, price, payment_type, payment_status, status, created_at, admin_name) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (client['id'], f"{client['last_name']} {client['first_name']} {client['middle_name'] or ''}".strip(), phone, employee_id, service_ids_str, service_names_str, bike_name, comment, total_price, payment_type, 'unpaid', 'pending', get_now().isoformat(), session.get('fullname', 'Admin'))) repair_id = cursor.lastrowid if total_price > 0: cursor.execute(""" INSERT INTO income (date, category, amount, payment_type, employee_id, comment, repair_id) VALUES (?, ?, ?, ?, ?, ?, ?) """, (get_now().strftime("%Y-%m-%d"), 'Ремонт', total_price, payment_type, employee_id, f"Ремонт для {client['last_name']} {client['first_name']}", repair_id)) conn.commit() flash(f'✅ Ремонт создан! Сумма: {total_price} ₽', 'success') except Exception as e: log_error("Ошибка при добавлении ремонта", e) if conn: conn.rollback() flash(f'❌ Ошибка при создании ремонта: {str(e)}', 'error') finally: if conn: conn.close() return redirect(url_for('service_repairs')) @app.route('/service/repair_edit/<int:repair_id>', methods=['GET', 'POST']) @login_required def service_repair_edit(repair_id): """Редактирование ремонта""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, _ = get_employee_point(employee_id) cursor.execute("SELECT * FROM repairs WHERE id = ?", (repair_id,)) repair = cursor.fetchone() if not repair: flash('Ремонт не найден', 'error') return redirect(url_for('service_repairs')) if request.method == 'POST': bike_name = request.form.get('bike_name', '').strip() description = request.form.get('comment', '').strip() status = request.form.get('status', repair['status']) payment_status = request.form.get('payment_status', repair['payment_status']) emp_id = request.form.get('employee_id', repair['employee_id']) payment_type = request.form.get('payment_type', repair['payment_type']) # Получаем данные из скрытых полей service_ids_str = request.form.get('service_ids', '') part_ids_str = request.form.get('part_ids', '') part_quantities_str = request.form.get('part_quantities', '{}') service_ids = [s.strip() for s in service_ids_str.split(',') if s.strip()] part_ids = [p.strip() for p in part_ids_str.split(',') if p.strip()] try: part_quantities = json.loads(part_quantities_str) except: part_quantities = {} # Возвращаем старые запчасти на склад old_service_ids = repair['service_ids'].split(',') if repair['service_ids'] else [] for sid in old_service_ids: if sid and sid.isdigit(): cursor.execute("UPDATE parts SET quantity = quantity + 1 WHERE id = ?", (sid,)) # Рассчитываем стоимость total_price = 0 services_names = [] for sid in service_ids: if not sid: continue cursor.execute("SELECT * FROM services WHERE id = ?", (sid,)) s = cursor.fetchone() if s: total_price += s['price'] services_names.append(s['name']) for pid in part_ids: if not pid: continue qty = part_quantities.get(pid, 1) cursor.execute("SELECT * FROM parts WHERE id = ?", (pid,)) p = cursor.fetchone() if p: if p['quantity'] < qty: flash(f'Недостаточно запчасти "{p["name"]}"', 'error') return redirect(url_for('service_repair_edit', repair_id=repair_id)) cursor.execute("UPDATE parts SET quantity = quantity - ? WHERE id = ?", (qty, pid)) total_price += (p['selling_price'] or 0) * qty services_names.append(f"{p['name']} x{qty}") service_names_str = ', '.join(services_names) if services_names else 'Без услуг' all_service_ids = ','.join(service_ids) # Обновляем доход по repair_id cursor.execute(""" UPDATE income SET amount = ?, payment_type = ?, date = ?, employee_id = ? WHERE repair_id = ? AND category = 'Ремонт' """, (total_price, payment_type, get_now().strftime("%Y-%m-%d"), emp_id, repair_id)) if cursor.rowcount == 0: cursor.execute(""" INSERT INTO income (date, category, amount, payment_type, employee_id, comment, repair_id) VALUES (?, ?, ?, ?, ?, ?, ?) """, (get_now().strftime("%Y-%m-%d"), 'Ремонт', total_price, payment_type, emp_id, f"Ремонт для {repair['client_name']}", repair_id)) # Обновляем ремонт cursor.execute(""" UPDATE repairs SET bike_name = ?, description = ?, status = ?, payment_status = ?, employee_id = ?, service_ids = ?, service_names = ?, price = ?, payment_type = ? WHERE id = ? """, (bike_name, description, status, payment_status, emp_id, all_service_ids, service_names_str, total_price, payment_type, repair_id)) conn.commit() flash('✅ Ремонт обновлён', 'success') return redirect(url_for('service_repairs')) # GET - показываем форму cursor.execute("SELECT * FROM services ORDER BY category, name") services_list = cursor.fetchall() if not is_admin and employee_point: cursor.execute("SELECT * FROM parts WHERE point_id = ? ORDER BY name", (employee_point,)) else: cursor.execute("SELECT * FROM parts ORDER BY name") parts_list = cursor.fetchall() cursor.execute("SELECT id, name FROM employees ORDER BY name") employees = cursor.fetchall() employees_opts = '' for e in employees: selected = 'selected' if e['id'] == repair['employee_id'] else '' employees_opts += f'<option value="{e["id"]}" {selected}>{e["name"]}</option>' # Группируем услуги services_by_cat = {} for s in services_list: cat = s['category'] or 'Прочее' if cat not in services_by_cat: services_by_cat[cat] = [] services_by_cat[cat].append(s) services_html = '' for cat, cat_services in services_by_cat.items(): services_html += f'<div class="category-title"><i class="fas fa-folder-open"></i> {cat}</div><div class="category-items">' for s in cat_services: services_html += f''' <div class="service-item" data-id="{s['id']}" data-price="{s['price']}" data-name="{s['name']}" data-type="service" onclick="toggleItem(this)"> <span>🔧 {s['name']}</span> <span class="item-price">{s['price']} ₽</span> </div> ''' services_html += '</div>' # Группируем запчасти parts_by_point = {} for p in parts_list: point = p['point_name'] or 'СКЛАД' if point not in parts_by_point: parts_by_point[point] = [] parts_by_point[point].append(p) parts_html = '' for point, point_parts in parts_by_point.items(): parts_html += f'<div class="category-title"><i class="fas fa-map-marker-alt"></i> {point}</div><div class="category-items">' for p in point_parts: parts_html += f''' <div class="service-item" data-id="{p['id']}" data-price="{p['selling_price']}" data-name="{p['name']}" data-type="part" data-max="{p['quantity']}" onclick="toggleItem(this)"> <span>🔩 {p['name']}</span> <span class="item-stock">({p['quantity']} шт)</span> <span class="item-price">{p['selling_price']} ₽</span> </div> ''' parts_html += '</div>' content = f''' <style> .edit-container {{ max-width: 800px; margin: 0 auto; }} .edit-card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 30px; }} .form-row {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }} .form-group {{ margin-bottom: 20px; }} .form-group label {{ display: block; margin-bottom: 8px; color: var(--text-secondary); }} .form-group input, .form-group select, .form-group textarea {{ width: 100%; padding: 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); }} .tabs {{ display: flex; gap: 4px; margin-bottom: 15px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }} .tab {{ padding: 8px 20px; background: transparent; border: none; color: var(--text-secondary); cursor: pointer; border-radius: 20px; }} .tab.active {{ background: var(--accent); color: white; }} .items-container {{ max-height: 250px; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 10px; background: var(--bg-primary); }} .category-title {{ padding: 6px 10px; background: var(--bg-header); border-radius: 4px; margin: 10px 0 5px; font-size: 0.8rem; font-weight: bold; color: var(--accent); }} .category-items {{ display: flex; flex-wrap: wrap; gap: 5px; }} .service-item {{ display: inline-flex; align-items: center; gap: 8px; padding: 6px 12px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; cursor: pointer; font-size: 0.8rem; }} .service-item.selected {{ background: var(--accent); color: white; border-color: var(--accent); }} .service-item.selected .item-price {{ color: white; }} .item-price {{ font-weight: bold; color: var(--accent); }} .item-stock {{ color: var(--text-muted); font-size: 0.7rem; }} .selected-items {{ margin-top: 20px; padding: 15px; background: var(--bg-primary); border-radius: 10px; }} .selected-item-row {{ display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px solid var(--border); }} .qty-control {{ display: flex; align-items: center; gap: 8px; }} .qty-control button {{ width: 24px; height: 24px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-card); color: var(--text-primary); cursor: pointer; }} .remove-item {{ color: var(--danger); cursor: pointer; margin-left: 10px; }} .total-price {{ margin-top: 15px; text-align: right; font-size: 1.2rem; font-weight: bold; color: var(--accent); }} .btn-submit {{ width: 100%; padding: 14px; background: var(--accent); color: white; border: none; border-radius: 10px; font-size: 1rem; cursor: pointer; margin-top: 20px; }} </style> <div class="edit-container"> <div class="edit-card"> <h2 style="margin-bottom: 24px;"><i class="fas fa-edit"></i> Редактирование заявки #{repair['id']}</h2> <form method="POST" id="editForm"> <div class="form-row"> <div class="form-group"><label>Клиент</label><input value="{repair['client_name']}" disabled></div> <div class="form-group"><label>Телефон</label><input value="{repair['client_phone']}" disabled></div> </div> <div class="form-group"><label>Велосипед</label><input type="text" name="bike_name" value="{repair['bike_name'] or ''}"></div> <div class="form-group"><label>Описание</label><textarea name="comment" rows="2">{repair['description'] or ''}</textarea></div> <div class="form-row"> <div class="form-group"><label>Статус</label><select name="status"><option value="pending" {'selected' if repair['status']=='pending' else ''}>Ожидает</option><option value="progress" {'selected' if repair['status']=='progress' else ''}>В работе</option><option value="done" {'selected' if repair['status']=='done' else ''}>Готов</option><option value="issued" {'selected' if repair['status']=='issued' else ''}>Выдан</option></select></div> <div class="form-group"><label>Оплата</label><select name="payment_status"><option value="unpaid" {'selected' if repair['payment_status']=='unpaid' else ''}>Не оплачен</option><option value="partial" {'selected' if repair['payment_status']=='partial' else ''}>Частично</option><option value="paid" {'selected' if repair['payment_status']=='paid' else ''}>Оплачен</option></select></div> </div> <div class="form-row"> <div class="form-group"><label>Способ оплаты</label><select name="payment_type"><option value="Наличные" {'selected' if repair['payment_type']=='Наличные' else ''}>Наличные</option><option value="Карта" {'selected' if repair['payment_type']=='Карта' else ''}>Карта</option><option value="Перевод" {'selected' if repair['payment_type']=='Перевод' else ''}>Перевод</option></select></div> <div class="form-group"><label>Мастер</label><select name="employee_id">{employees_opts}</select></div> </div> <div class="tabs"> <button type="button" class="tab active" onclick="switchTab('services')">🔧 Услуги</button> <button type="button" class="tab" onclick="switchTab('parts')">🔩 Запчасти</button> </div> <div id="servicesBlock"> <input type="text" id="serviceSearch" placeholder="🔍 Поиск услуг..." style="width:100%; padding:10px; margin-bottom:10px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> <div class="items-container" id="servicesContainer">{services_html}</div> </div> <div id="partsBlock" style="display:none;"> <input type="text" id="partSearch" placeholder="🔍 Поиск запчастей..." style="width:100%; padding:10px; margin-bottom:10px; border-radius:8px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> <div class="items-container" id="partsContainer">{parts_html}</div> </div> <div class="selected-items"> <h4 style="margin-bottom:10px;"><i class="fas fa-shopping-cart"></i> Выбранные позиции</h4> <div id="selectedItemsList"></div> <div class="total-price">Итого: <span id="totalPrice">{repair['price']:,.0f}</span> ₽</div> </div> <input type="hidden" name="service_ids" id="selectedServices" value="{repair['service_ids'] or ''}"> <input type="hidden" name="part_ids" id="selectedParts" value=""> <input type="hidden" name="part_quantities" id="selectedPartQuantities" value=""> <button type="submit" class="btn-submit">💾 Сохранить изменения</button> </form> </div> </div> <script> let selectedItems = []; // Загружаем существующие услуги const existingServices = '{repair["service_ids"] or ""}'.split(',').filter(id => id && !id.match(/^\d+$/)); const existingParts = '{repair["service_ids"] or ""}'.split(',').filter(id => id && id.match(/^\d+$/)); // Отмечаем выбранные элементы existingServices.forEach(id => {{ const el = document.querySelector(`.service-item[data-id="${{id}}"]`); if (el) {{ selectedItems.push({{ id: id, name: el.dataset.name, price: parseFloat(el.dataset.price), type: 'service', qty: 1 }}); }} }}); existingParts.forEach(id => {{ const el = document.querySelector(`.service-item[data-id="${{id}}"]`); if (el) {{ selectedItems.push({{ id: id, name: el.dataset.name, price: parseFloat(el.dataset.price), type: 'part', qty: 1, max: parseInt(el.dataset.max) }}); }} }}); function switchTab(tab) {{ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); event.target.classList.add('active'); document.getElementById('servicesBlock').style.display = tab === 'services' ? 'block' : 'none'; document.getElementById('partsBlock').style.display = tab === 'parts' ? 'block' : 'none'; }} function toggleItem(el) {{ const id = el.dataset.id; const name = el.dataset.name; const price = parseFloat(el.dataset.price); const type = el.dataset.type; const max = el.dataset.max ? parseInt(el.dataset.max) : null; const existing = selectedItems.find(i => i.id === id && i.type === type); if (existing) {{ existing.qty += 1; if (max && existing.qty > max) {{ existing.qty = max; alert('Доступно: ' + max); }} }} else {{ selectedItems.push({{ id, name, price, type, qty: 1, max }}); el.classList.add('selected'); }} updateSelectedList(); }} function removeItem(index) {{ const item = selectedItems[index]; selectedItems.splice(index, 1); const hasOther = selectedItems.some(i => i.id === item.id && i.type === item.type); if (!hasOther) {{ document.querySelectorAll(`[data-id="${{item.id}}"]`).forEach(el => {{ if (el.dataset.type === item.type) el.classList.remove('selected'); }}); }} updateSelectedList(); }} function changeQty(index, delta) {{ const item = selectedItems[index]; item.qty = Math.max(1, item.qty + delta); if (item.max && item.qty > item.max) {{ item.qty = item.max; }} updateSelectedList(); }} function updateSelectedList() {{ const container = document.getElementById('selectedItemsList'); const totalSpan = document.getElementById('totalPrice'); if (selectedItems.length === 0) {{ container.innerHTML = '<div style="text-align:center;padding:10px;color:var(--text-muted);">Нет позиций</div>'; totalSpan.textContent = '0'; document.getElementById('selectedServices').value = ''; document.getElementById('selectedParts').value = ''; document.getElementById('selectedPartQuantities').value = '{{}}'; return; }} let html = ''; let total = 0; const serviceIds = []; const partIds = []; const quantities = {{}}; selectedItems.forEach((item, i) => {{ const itemTotal = item.price * item.qty; total += itemTotal; html += `<div class="selected-item-row"> <span>${{item.type === 'service' ? '🔧' : '🔩'}} ${{item.name}}</span> <div class="qty-control"> <button type="button" onclick="changeQty(${{i}}, -1)">−</button> <span>${{item.qty}}</span> <button type="button" onclick="changeQty(${{i}}, 1)">+</button> <span>${{itemTotal}} ₽</span> <span class="remove-item" onclick="removeItem(${{i}})">✕</span> </div> </div>`; if (item.type === 'service') {{ for (let j = 0; j < item.qty; j++) serviceIds.push(item.id); }} else {{ partIds.push(item.id); quantities[item.id] = item.qty; }} }}); container.innerHTML = html; totalSpan.textContent = total.toLocaleString(); document.getElementById('selectedServices').value = serviceIds.join(','); document.getElementById('selectedParts').value = partIds.join(','); document.getElementById('selectedPartQuantities').value = JSON.stringify(quantities); }} document.getElementById('serviceSearch').addEventListener('input', function() {{ const term = this.value.toLowerCase(); document.querySelectorAll('#servicesContainer .service-item').forEach(el => {{ el.style.display = el.textContent.toLowerCase().includes(term) ? 'inline-flex' : 'none'; }}); }}); document.getElementById('partSearch').addEventListener('input', function() {{ const term = this.value.toLowerCase(); document.querySelectorAll('#partsContainer .service-item').forEach(el => {{ el.style.display = el.textContent.toLowerCase().includes(term) ? 'inline-flex' : 'none'; }}); }}); // Отмечаем выбранные selectedItems.forEach(item => {{ document.querySelectorAll(`[data-id="${{item.id}}"]`).forEach(el => {{ if (el.dataset.type === item.type) el.classList.add('selected'); }}); }}); updateSelectedList(); </script> ''' return layout(content) except Exception as e: log_error(f"Ошибка в service_repair_edit {repair_id}", e) flash(f'Ошибка: {str(e)}', 'error') return redirect(url_for('service_repairs')) finally: if conn: conn.close() @app.route('/service/repair_info/<int:repair_id>') @login_required def service_repair_info(repair_id): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM repairs WHERE id = ?", (repair_id,)) repair = cursor.fetchone() if repair: return jsonify(dict(repair)) return jsonify({'error': 'Не найден'}) except Exception as e: return jsonify({'error': str(e)}) finally: if conn: conn.close() @app.route('/service/repair_complete/<int:repair_id>', methods=['POST']) @login_required def service_repair_complete(repair_id): """Завершение ремонта с оплатой""" conn = None try: conn = get_db() cursor = conn.cursor() req_data = json.loads(request.data) amount = safe_float(req_data.get('amount', 0)) payment_type = req_data.get('payment_type', 'Наличные') cursor.execute("SELECT * FROM repairs WHERE id = ?", (repair_id,)) repair = cursor.fetchone() if not repair: return jsonify({'success': False, 'error': 'Ремонт не найден'}) payment_status = 'paid' if amount >= repair['price'] else 'partial' cursor.execute("UPDATE repairs SET payment_status = ?, status = ?, payment_type = ?, closed_at = ? WHERE id = ?", (payment_status, 'issued', payment_type, get_now().isoformat(), repair_id)) cursor.execute("UPDATE income SET payment_type = ?, amount = ? WHERE repair_id = ? AND category = 'Ремонт'", (payment_type, amount, repair_id)) conn.commit() return jsonify({'success': True}) except Exception as e: log_error(f"Ошибка при оплате ремонта {repair_id}", e) if conn: conn.rollback() return jsonify({'success': False, 'error': str(e)}) finally: if conn: conn.close() @app.route('/service/repair_delete/<int:repair_id>') @login_required def service_repair_delete(repair_id): """Удаление ремонта""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM repairs WHERE id = ?", (repair_id,)) repair = cursor.fetchone() if repair: if repair['service_ids']: part_ids = [pid for pid in repair['service_ids'].split(',') if pid.isdigit()] for pid in part_ids: cursor.execute("UPDATE parts SET quantity = quantity + 1 WHERE id = ?", (pid,)) cursor.execute("DELETE FROM income WHERE repair_id = ?", (repair_id,)) cursor.execute("DELETE FROM repairs WHERE id = ?", (repair_id,)) conn.commit() flash('✅ Ремонт удалён!', 'success') else: flash('❌ Ремонт не найден', 'error') except Exception as e: log_error(f"Ошибка при удалении ремонта {repair_id}", e) if conn: conn.rollback() flash(f'❌ Ошибка: {str(e)}', 'error') finally: if conn: conn.close() return redirect(url_for('service_repairs')) # ===================== УСЛУГИ ===================== @app.route('/services', methods=['GET', 'POST']) @login_required def services(): """Управление услугами""" conn = None try: conn = get_db() cursor = conn.cursor() try: cursor.execute("ALTER TABLE services ADD COLUMN category TEXT") conn.commit() except: pass if request.method == 'POST': name = request.form.get('name', '').strip() price = safe_float(request.form.get('price', 0)) description = request.form.get('description', '').strip() category = request.form.get('category', 'ПРОЧИЕ').strip() if not name or price <= 0: flash('Название и цена обязательны', 'error') return redirect(url_for('services')) cursor.execute("INSERT INTO services (name, price, description, category, created_at) VALUES (?, ?, ?, ?, ?)", (name, price, description, category, get_now().isoformat())) conn.commit() flash('Услуга добавлена!', 'success') return redirect(url_for('services')) category_filter = request.args.get('category', 'all') cursor.execute("SELECT DISTINCT category FROM services WHERE category IS NOT NULL AND category != '' ORDER BY category") categories = [row['category'] for row in cursor.fetchall()] if category_filter != 'all': cursor.execute("SELECT * FROM services WHERE category = ? ORDER BY name", (category_filter,)) else: cursor.execute("SELECT * FROM services ORDER BY category, name") services_list = cursor.fetchall() grouped_services = {} for s in services_list: cat = s['category'] if s['category'] else 'БЕЗ КАТЕГОРИИ' if cat not in grouped_services: grouped_services[cat] = [] grouped_services[cat].append(s) rows = '' for cat_name, cat_services in grouped_services.items(): rows += f'<tr style="background: var(--bg-header);"><td colspan="5"><strong><i class="fas fa-folder-open"></i> {cat_name}</strong></td></tr>' for s in cat_services: desc = (s['description'] or '')[:50] rows += f''' <tr> <td>{s['name']}</td> <td>{s['price']} ₽</td> <td>{desc}{'...' if len(s['description'] or '') > 50 else ''}</td> <td>{s['category'] or '—'}</td> <td> <a href="/services/edit/{s['id']}" class="btn-icon"><i class="fas fa-edit"></i></a> <a href="/services/delete/{s['id']}" class="btn-icon btn-icon-danger" onclick="return confirm('Удалить услугу?')"><i class="fas fa-trash-alt"></i></a> </td> </tr> ''' if not rows: rows = '<tr><td colspan="5" style="text-align:center;padding:40px;">Нет услуг</td></tr>' category_options = '<option value="all">📁 Все категории</option>' for cat in categories: selected = 'selected' if category_filter == cat else '' category_options += f'<option value="{cat}" {selected}>{cat}</option>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-plus-circle"></i> Добавить услугу</h2></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Название *</label><input name="name" required></div> <div class="form-group"><label>Цена *</label><input type="number" name="price" step="0.01" min="0" required></div> </div> <div class="form-group"><label>Категория</label> <select name="category" id="categorySelect"> <option value="">— Выберите или введите —</option> {''.join([f'<option value="{cat}">{cat}</option>' for cat in categories])} <option value="___new___">➕ Создать новую</option> </select> <input type="text" name="category_new" id="categoryNew" placeholder="Новая категория" style="margin-top:8px; display:none;"> </div> <div class="form-group"><label>Описание</label><textarea name="description" rows="2"></textarea></div> <button class="btn-primary"><i class="fas fa-plus"></i> Добавить услугу</button> </form></div> <div class="card"><div class="card-header"><h2><i class="fas fa-clipboard-list"></i> Каталог услуг</h2></div> <div class="filters-bar"> <select class="filter-select" id="categoryFilter" onchange="window.applyFilter()"> {category_options} </select> <button class="btn-primary" onclick="window.applyFilter()"><i class="fas fa-filter"></i> Фильтр</button> <button class="btn-outline" onclick="window.clearFilter()"><i class="fas fa-times"></i> Сбросить</button> </div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>Название</th><th>Цена</th><th>Описание</th><th>Категория</th><th></th></thead> <tbody>{rows}</tbody> </table> </div></div> <script> const categorySelect = document.getElementById('categorySelect'); const categoryNew = document.getElementById('categoryNew'); if (categorySelect) {{ categorySelect.addEventListener('change', function() {{ if (this.value === '___new___') {{ categoryNew.style.display = 'block'; categoryNew.name = 'category'; this.name = ''; }} else {{ categoryNew.style.display = 'none'; categoryNew.name = ''; this.name = 'category'; }} }}); }} window.applyFilter = function() {{ const category = document.getElementById('categoryFilter').value; window.location.href = `/services?category=${{encodeURIComponent(category)}}`; }}; window.clearFilter = function() {{ window.location.href = '/services'; }}; </script> ''' return layout(content) except Exception as e: log_error("Ошибка в services", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/services/edit/<int:service_id>', methods=['GET', 'POST']) @login_required def services_edit(service_id): """Редактирование услуги""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM services WHERE id = ?", (service_id,)) service = cursor.fetchone() if not service: flash('Услуга не найдена', 'error') return redirect(url_for('services')) cursor.execute("SELECT DISTINCT category FROM services WHERE category IS NOT NULL AND category != '' ORDER BY category") categories = [row['category'] for row in cursor.fetchall()] if request.method == 'POST': name = request.form.get('name', '').strip() price = safe_float(request.form.get('price', 0)) description = request.form.get('description', '').strip() category = request.form.get('category', '').strip() if category == '___new___' and request.form.get('category_new'): category = request.form.get('category_new').strip() if not name or price <= 0: flash('Название и цена обязательны', 'error') return redirect(url_for('services_edit', service_id=service_id)) cursor.execute("UPDATE services SET name = ?, price = ?, description = ?, category = ? WHERE id = ?", (name, price, description, category, service_id)) conn.commit() flash('Услуга обновлена!', 'success') return redirect(url_for('services')) category_options = '' for cat in categories: selected = 'selected' if cat == service['category'] else '' category_options += f'<option value="{cat}" {selected}>{cat}</option>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-edit"></i> Редактирование услуги</h2> <a href="/services"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Название *</label><input type="text" name="name" value="{service['name']}" required></div> <div class="form-group"><label>Цена *</label><input type="number" name="price" step="0.01" min="0" value="{service['price']}" required></div> </div> <div class="form-group"><label>Категория</label> <select name="category" id="categorySelect"> <option value="">— Без категории —</option> {category_options} <option value="___new___">➕ Создать новую</option> </select> <input type="text" name="category_new" id="categoryNew" placeholder="Новая категория" style="margin-top:8px; display:none;"> </div> <div class="form-group"><label>Описание</label><textarea name="description" rows="3">{service['description'] or ''}</textarea></div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> <script> const categorySelect = document.getElementById('categorySelect'); const categoryNew = document.getElementById('categoryNew'); if (categorySelect) {{ categorySelect.addEventListener('change', function() {{ if (this.value === '___new___') {{ categoryNew.style.display = 'block'; categoryNew.name = 'category'; this.name = ''; }} else {{ categoryNew.style.display = 'none'; categoryNew.name = ''; this.name = 'category'; }} }}); }} </script> ''' return layout(content) except Exception as e: log_error(f"Ошибка при редактировании услуги {service_id}", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') return redirect(url_for('services')) finally: if conn: conn.close() @app.route('/services/delete/<int:service_id>') @admin_required def services_delete(service_id): """Удаление услуги""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM services WHERE id = ?", (service_id,)) conn.commit() flash('Услуга удалена', 'success') except Exception as e: log_error(f"Ошибка при удалении услуги {service_id}", e) flash('Произошла ошибка', 'error') finally: if conn: conn.close() return redirect(url_for('services')) # ===================== ЗАПЧАСТИ ===================== @app.route('/parts', methods=['GET', 'POST']) @login_required def parts(): """Управление запчастями""" conn = None try: conn = get_db() cursor = conn.cursor() # Автоматически добавляем недостающие колонки cursor.execute("PRAGMA table_info(parts)") cols = [col[1] for col in cursor.fetchall()] if 'sku' not in cols: cursor.execute("ALTER TABLE parts ADD COLUMN sku TEXT") if 'purchase_price' not in cols: cursor.execute("ALTER TABLE parts ADD COLUMN purchase_price REAL DEFAULT 0") if 'selling_price' not in cols: cursor.execute("ALTER TABLE parts ADD COLUMN selling_price REAL DEFAULT 0") conn.commit() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' employee_point, employee_point_name = get_employee_point(employee_id) if request.method == 'POST': # ---- ДОБАВЛЕНИЕ ---- if 'add_part' in request.form: name = request.form.get('name', '').strip() sku = request.form.get('sku', '').strip() quantity = safe_int(request.form.get('quantity', 0)) purchase_price = safe_float(request.form.get('purchase_price', 0)) selling_price = safe_float(request.form.get('selling_price', 0)) if not name: flash('❌ Название обязательно', 'error') else: if is_admin: point_id = safe_int(request.form.get('point_id')) cursor.execute("SELECT name FROM points WHERE id = ?", (point_id,)) pt = cursor.fetchone() point_name = pt['name'] if pt else '' else: point_id = employee_point point_name = employee_point_name cursor.execute(""" INSERT INTO parts (name, sku, point_id, point_name, quantity, purchase_price, selling_price, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (name, sku, point_id, point_name, quantity, purchase_price, selling_price, get_now().isoformat())) conn.commit() flash('✅ Запчасть добавлена', 'success') # ---- РЕДАКТИРОВАНИЕ ---- elif 'edit_part' in request.form: part_id = safe_int(request.form.get('part_id')) name = request.form.get('name', '').strip() sku = request.form.get('sku', '').strip() quantity = safe_int(request.form.get('quantity', 0)) purchase_price = safe_float(request.form.get('purchase_price', 0)) selling_price = safe_float(request.form.get('selling_price', 0)) cursor.execute(""" UPDATE parts SET name = ?, sku = ?, quantity = ?, purchase_price = ?, selling_price = ? WHERE id = ? """, (name, sku, quantity, purchase_price, selling_price, part_id)) conn.commit() flash('✅ Запчасть обновлена', 'success') # ---- УДАЛЕНИЕ ---- elif 'delete_part' in request.form: part_id = safe_int(request.form.get('part_id')) cursor.execute("DELETE FROM parts WHERE id = ?", (part_id,)) conn.commit() flash('✅ Запчасть удалена', 'success') return redirect(url_for('parts')) # ========== GET ========== point_filter = request.args.get('point', 'all') search = request.args.get('search', '').strip() query = "SELECT * FROM parts WHERE 1=1" params = [] if not is_admin and employee_point: query += " AND point_id = ?" params.append(employee_point) elif point_filter != 'all': query += " AND point_id = ?" params.append(point_filter) if search: query += " AND (LOWER(name) LIKE ? OR LOWER(sku) LIKE ?)" params.extend([f'%{search.lower()}%', f'%{search.lower()}%']) query += " ORDER BY point_name, name" cursor.execute(query, params) parts_list = cursor.fetchall() # Группировка по пунктам parts_by_point = {} for p in parts_list: point = p['point_name'] or 'Без пункта' if point not in parts_by_point: parts_by_point[point] = [] parts_by_point[point].append(p) rows = '' for point, point_parts in parts_by_point.items(): rows += f'<tr class="point-header"><td colspan="6"><strong><i class="fas fa-map-marker-alt"></i> {point}</strong></td></tr>' for p in point_parts: profit = (p['selling_price'] or 0) - (p['purchase_price'] or 0) qty_class = 'qty-low' if p['quantity'] < 5 else 'qty-ok' if p['quantity'] == 0: qty_class = 'qty-zero' rows += f''' <tr> <td> <strong>{p['name']}</strong><br> <small style="color: var(--text-muted);">{p['sku'] or '—'}</small> </td> <td><span class="qty-badge {qty_class}">{p['quantity']} шт</span></td> <td>{p['purchase_price']:,.0f} ₽</td> <td>{p['selling_price']:,.0f} ₽</td> <td style="color: {'#10b981' if profit >= 0 else '#dc2626'};">{profit:+,.0f} ₽</td> <td> <button class="btn-icon" onclick="openEditModal({p['id']}, '{p['name'].replace("'", "\\'")}', '{p['sku'] or ''}', {p['quantity']}, {p['purchase_price']}, {p['selling_price']})" title="Редактировать"><i class="fas fa-edit"></i></button> <form method="POST" style="display: inline;" onsubmit="return confirm('Удалить запчасть?')"> <input type="hidden" name="delete_part" value="1"> <input type="hidden" name="part_id" value="{p['id']}"> <button type="submit" class="btn-icon btn-icon-danger" title="Удалить"><i class="fas fa-trash-alt"></i></button> </form> </td> </tr> ''' if not rows: rows = '<tr><td colspan="6" style="text-align:center;padding:40px;">Нет запчастей</td></tr>' # Пункты для фильтра и формы cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = '<option value="all">🏢 Все пункты</option>' points_form_opts = '' for p in points: selected = 'selected' if point_filter == str(p['id']) else '' points_opts += f'<option value="{p["id"]}" {selected}>📍 {p["name"]}</option>' points_form_opts += f'<option value="{p["id"]}">{p["name"]}</option>' content = f''' <style> .parts-container {{ display: flex; gap: 24px; flex-wrap: wrap; }} .parts-form {{ flex: 1; min-width: 320px; }} .parts-list {{ flex: 2; min-width: 500px; }} .form-card {{ background: var(--bg-card); border: 1px solid var(--border); border-radius: 16px; padding: 24px; }} .form-card h3 {{ margin-bottom: 20px; color: var(--accent); }} .form-row {{ display: flex; gap: 12px; flex-wrap: wrap; }} .form-group {{ flex: 1; min-width: 140px; margin-bottom: 15px; }} .form-group label {{ display: block; margin-bottom: 6px; font-size: 0.8rem; color: var(--text-muted); }} .form-group input, .form-group select {{ width: 100%; padding: 10px; border-radius: 8px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); }} .btn-primary {{ padding: 10px 20px; background: var(--accent); color: white; border: none; border-radius: 8px; cursor: pointer; }} .filter-bar {{ display: flex; gap: 10px; margin-bottom: 20px; align-items: center; }} .search-input {{ flex: 1; padding: 10px 15px; border-radius: 40px; border: 1px solid var(--border); background: var(--input-bg); color: var(--text-primary); }} .point-header {{ background: var(--bg-header); }} .point-header td {{ padding: 10px 15px !important; }} .qty-badge {{ padding: 4px 10px; border-radius: 20px; font-size: 0.75rem; font-weight: 500; }} .qty-ok {{ background: #065f46; color: #34d399; }} .qty-low {{ background: #78350f; color: #fbbf24; }} .qty-zero {{ background: #450a0a; color: #f87171; }} .modal {{ display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 10000; justify-content: center; align-items: center; }} .modal-card {{ background: var(--bg-card); border-radius: 16px; padding: 30px; width: 90%; max-width: 500px; }} </style> <div class="parts-container"> <div class="parts-form"> <div class="form-card"> <h3><i class="fas fa-plus-circle"></i> Добавить запчасть</h3> <form method="POST"> <input type="hidden" name="add_part" value="1"> <div class="form-group"><label>Название *</label><input type="text" name="name" required></div> <div class="form-row"> <div class="form-group"><label>Артикул</label><input type="text" name="sku"></div> <div class="form-group"><label>Количество *</label><input type="number" name="quantity" min="0" value="0" required></div> </div> <div class="form-row"> <div class="form-group"><label>Цена закупки (₽)</label><input type="number" name="purchase_price" step="0.01" min="0" value="0"></div> <div class="form-group"><label>Цена продажи (₽)</label><input type="number" name="selling_price" step="0.01" min="0" value="0"></div> </div> {f'<div class="form-group"><label>Пункт</label><select name="point_id">{points_form_opts}</select></div>' if is_admin else f'<input type="hidden" name="point_id" value="{employee_point}"><div class="form-group"><label>Пункт</label><input value="{employee_point_name}" disabled></div>'} <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Добавить</button> </form> </div> </div> <div class="parts-list"> <div class="filter-bar"> <input type="text" class="search-input" id="searchInput" placeholder="🔍 Поиск по названию или артикулу..." value="{search}"> {f'<select class="filter-select" id="pointFilter">{points_opts}</select>' if is_admin else ''} <button class="btn-primary" onclick="applyFilters()"><i class="fas fa-search"></i></button> <button class="btn-outline" onclick="resetFilters()"><i class="fas fa-times"></i> Сбросить</button> </div> <div style="overflow-x: auto;"> <table class="data-table"> <thead> <tr><th>Название / Артикул</th><th>Кол-во</th><th>Закуп</th><th>Продажа</th><th>Прибыль</th><th></th></tr> </thead> <tbody>{rows}</tbody> </table> </div> </div> </div> <div class="modal" id="editModal"> <div class="modal-card"> <h3 style="margin-bottom: 20px;"><i class="fas fa-edit"></i> Редактировать запчасть</h3> <form method="POST"> <input type="hidden" name="edit_part" value="1"> <input type="hidden" name="part_id" id="editPartId"> <div class="form-group"><label>Название</label><input type="text" name="name" id="editName" required></div> <div class="form-group"><label>Артикул</label><input type="text" name="sku" id="editSku"></div> <div class="form-group"><label>Количество</label><input type="number" name="quantity" id="editQty" min="0" required></div> <div class="form-row"> <div class="form-group"><label>Цена закупки</label><input type="number" name="purchase_price" id="editPurchase" step="0.01" min="0"></div> <div class="form-group"><label>Цена продажи</label><input type="number" name="selling_price" id="editSelling" step="0.01" min="0"></div> </div> <div style="display: flex; gap: 10px; margin-top: 20px;"> <button type="button" class="btn-outline" style="flex: 1;" onclick="closeEditModal()">Отмена</button> <button type="submit" class="btn-primary" style="flex: 1;">Сохранить</button> </div> </form> </div> </div> <script> function applyFilters() {{ const search = document.getElementById('searchInput').value; const point = document.getElementById('pointFilter')?.value || 'all'; window.location.href = `/parts?search=${{encodeURIComponent(search)}}&point=${{point}}`; }} function resetFilters() {{ window.location.href = '/parts'; }} function openEditModal(id, name, sku, qty, purchase, selling) {{ document.getElementById('editPartId').value = id; document.getElementById('editName').value = name; document.getElementById('editSku').value = sku; document.getElementById('editQty').value = qty; document.getElementById('editPurchase').value = purchase; document.getElementById('editSelling').value = selling; document.getElementById('editModal').style.display = 'flex'; }} function closeEditModal() {{ document.getElementById('editModal').style.display = 'none'; }} document.getElementById('editModal').addEventListener('click', function(e) {{ if (e.target === this) closeEditModal(); }}); </script> ''' return layout(content) except Exception as e: log_error("Ошибка в parts", e) if conn: conn.rollback() flash(f'Ошибка: {str(e)}', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() # ===================== КЛИЕНТЫ ===================== @app.route('/clients') @login_required def clients(): """Список клиентов""" conn = None try: conn = get_db() cursor = conn.cursor() search = request.args.get('search', '').lower() is_admin = session.get('role') == 'admin' page = safe_int(request.args.get('page', 1), 1) per_page = 20 if search: cursor.execute(""" SELECT * FROM clients WHERE LOWER(last_name) LIKE ? OR LOWER(first_name) LIKE ? OR phone LIKE ? ORDER BY id DESC """, (f'%{search}%', f'%{search}%', f'%{search}%')) else: cursor.execute("SELECT * FROM clients ORDER BY id DESC") clients_list = cursor.fetchall() total = len(clients_list) total_pages = max(1, (total + per_page - 1) // per_page) start = (page - 1) * per_page paginated = clients_list[start:start + per_page] rows = '' for c in paginated: has_card_badge = '<span class="badge badge-success" style="background:#10b981; margin-left:8px;"><i class="fas fa-id-card"></i> Карта</span>' if c['has_card'] else '' rows += f''' <tr> <td>{c["id"]}</td> <td>{c["last_name"]} {c["first_name"]} {c["middle_name"] or ""} {has_card_badge}</td> <td><a href="tel:{c["phone"]}" class="phone-link">{c["phone"]}</a></td> <td>{c["created_at"][:10] if c["created_at"] else ""}</td> <td> <a href="/clients/edit/{c['id']}" class="btn-icon"><i class="fas fa-edit"></i></a> <a href="/clients/delete/{c['id']}" class="btn-icon btn-icon-danger" onclick="return confirm('Удалить клиента?')"><i class="fas fa-trash-alt"></i></a> </td> </tr> ''' if not rows: rows = '<tr><td colspan="5" style="text-align:center;padding:40px;">Нет клиентов</td></tr>' prev_btn = f'<button class="page-btn" onclick="window.goToPage({page-1})">←</button>' if page > 1 else '' next_btn = f'<button class="page-btn" onclick="window.goToPage({page+1})">→</button>' if page < total_pages else '' export_button = '' if is_admin: export_button = '<a href="/clients/export/excel"><button class="btn-success"><i class="fas fa-file-excel"></i> Экспорт Excel</button></a>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-user-friends"></i> Клиенты</h2> <div style="display: flex; gap: 10px;"> {export_button} <a href="/clients/add"><button class="btn-primary"><i class="fas fa-user-plus"></i> Добавить</button></a> </div> </div> <div class="filters-bar"> <input type="text" class="search-input" id="searchInput" placeholder="🔍 Поиск по имени или телефону..." value="{request.args.get('search', '')}"> <button class="btn-primary" onclick="window.applyFilters()"><i class="fas fa-search"></i></button> <button class="btn-outline" onclick="window.clearFilters()"><i class="fas fa-times"></i> Сбросить</button> </div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>ID</th><th>ФИО</th><th>Телефон</th><th>Дата регистрации</th><th></th></thead> <tbody>{rows}</tbody> </table> </div> <div class="pagination">{prev_btn}<span>{page} / {total_pages}</span>{next_btn}</div></div> <script> window.applyFilters = function() {{ const search = document.getElementById('searchInput').value; window.location.href = `/clients?search=${{encodeURIComponent(search)}}&page=1`; }}; window.clearFilters = function() {{ window.location.href = '/clients'; }}; window.goToPage = function(page) {{ const search = document.getElementById('searchInput').value; window.location.href = `/clients?search=${{encodeURIComponent(search)}}&page=${{page}}`; }}; </script> ''' return layout(content) except Exception as e: log_error("Ошибка при загрузке клиентов", e) flash('Произошла ошибка при загрузке', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/clients/add', methods=['GET', 'POST']) @login_required def client_add(): """Добавление клиента""" conn = None try: conn = get_db() cursor = conn.cursor() if request.method == 'POST': last_name = request.form.get('last_name', '').strip() first_name = request.form.get('first_name', '').strip() middle_name = request.form.get('middle_name', '').strip() phone = request.form.get('phone', '').strip() passport = request.form.get('passport', '').strip() address = request.form.get('address', '').strip() has_card = 1 if 'has_card' in request.form else 0 if not last_name or not first_name or not phone: flash('Фамилия, имя и телефон обязательны', 'error') return redirect(url_for('client_add')) cursor.execute(""" INSERT INTO clients (last_name, first_name, middle_name, phone, passport, address, has_card, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, (last_name, first_name, middle_name, phone, passport, address, has_card, get_now().isoformat())) client_id = cursor.lastrowid if 'photos' in request.files: files = request.files.getlist('photos') for file in files: if file and allowed_file(file.filename): ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else 'jpg' filename = f"{uuid.uuid4().hex}.{ext}" filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) file.save(filepath) if os.path.exists(filepath) and os.path.getsize(filepath) > 0: cursor.execute("INSERT INTO client_photos (client_id, filename, created_at) VALUES (?, ?, ?)", (client_id, filename, get_now().isoformat())) conn.commit() flash('Клиент добавлен!', 'success') return redirect(url_for('clients')) content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-user-plus"></i> Новый клиент</h2><a href="/clients"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" enctype="multipart/form-data" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Фамилия *</label><input type="text" name="last_name" required></div> <div class="form-group"><label>Имя *</label><input type="text" name="first_name" required></div> <div class="form-group"><label>Отчество</label><input type="text" name="middle_name"></div> </div> <div class="form-group"><label>Телефон *</label><input type="tel" name="phone" required placeholder="+7 (900) 123-45-67"></div> <div class="form-group"><label>Паспортные данные</label><textarea name="passport" rows="2" placeholder="Серия и номер паспорта"></textarea></div> <div class="form-group"><label>Адрес</label><textarea name="address" rows="2"></textarea></div> <div class="form-group"> <label style="display:flex; align-items:center; gap:10px; cursor:pointer;"> <input type="checkbox" name="has_card" style="width:20px; height:20px;"> <i class="fas fa-id-card"></i> Клубная карта </label> </div> <div class="form-group"><label>Фотографии</label><input type="file" name="photos" multiple accept="image/*"></div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error("Ошибка при добавлении клиента", e) if conn: conn.rollback() flash('Произошла ошибка при добавлении', 'error') return redirect(url_for('clients')) finally: if conn: conn.close() @app.route('/clients/edit/<int:client_id>', methods=['GET', 'POST']) @login_required def client_edit(client_id): """Редактирование клиента""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM clients WHERE id = ?", (client_id,)) client = cursor.fetchone() if not client: flash('Клиент не найден', 'error') return redirect(url_for('clients')) if request.method == 'POST': last_name = request.form.get('last_name', '').strip() first_name = request.form.get('first_name', '').strip() middle_name = request.form.get('middle_name', '').strip() phone = request.form.get('phone', '').strip() passport = request.form.get('passport', '').strip() address = request.form.get('address', '').strip() has_card = 1 if 'has_card' in request.form else 0 if not last_name or not first_name or not phone: flash('Фамилия, имя и телефон обязательны', 'error') return redirect(url_for('client_edit', client_id=client_id)) cursor.execute(""" UPDATE clients SET last_name = ?, first_name = ?, middle_name = ?, phone = ?, passport = ?, address = ?, has_card = ? WHERE id = ? """, (last_name, first_name, middle_name, phone, passport, address, has_card, client_id)) if 'photos' in request.files: files = request.files.getlist('photos') for file in files: if file and allowed_file(file.filename): ext = file.filename.rsplit('.', 1)[1].lower() if '.' in file.filename else 'jpg' filename = f"{uuid.uuid4().hex}.{ext}" filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) file.save(filepath) if os.path.exists(filepath) and os.path.getsize(filepath) > 0: cursor.execute("INSERT INTO client_photos (client_id, filename, created_at) VALUES (?, ?, ?)", (client_id, filename, get_now().isoformat())) conn.commit() flash('Клиент обновлён!', 'success') return redirect(url_for('clients')) cursor.execute("SELECT filename FROM client_photos WHERE client_id = ?", (client_id,)) photos = cursor.fetchall() photos_html = '' for p in photos: photos_html += f''' <div style="display:inline-block; margin:10px; position:relative;"> <img src="/photos/{p['filename']}" style="width:150px; height:150px; border-radius:10px; cursor:pointer; object-fit:cover;" onclick="window.open('/photos/{p['filename']}', '_blank')" onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22%3E%3Crect width=%22100%22 height=%22100%22 fill=%22%23ddd%22/%3E%3Ctext x=%2250%22 y=%2250%22 text-anchor=%22middle%22 dy=%22.3em%22 fill=%22%23999%22%3EОшибка%3C/text%3E%3C/svg%3E'"> <br> <button type="button" class="btn-outline" style="padding:4px 10px; font-size:0.75rem; margin-top:8px;" onclick="deletePhoto('{p['filename']}', {client_id})"><i class="fas fa-trash-alt"></i> Удалить</button> </div> ''' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-edit"></i> Редактирование клиента</h2><a href="/clients"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" enctype="multipart/form-data" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>Фамилия *</label><input type="text" name="last_name" value="{client['last_name']}" required></div> <div class="form-group"><label>Имя *</label><input type="text" name="first_name" value="{client['first_name']}" required></div> <div class="form-group"><label>Отчество</label><input type="text" name="middle_name" value="{client['middle_name'] or ''}"></div> </div> <div class="form-group"><label>Телефон *</label><input type="tel" name="phone" value="{client['phone']}" required></div> <div class="form-group"><label>Паспортные данные</label><textarea name="passport" rows="2">{client['passport'] or ''}</textarea></div> <div class="form-group"><label>Адрес</label><textarea name="address" rows="2">{client['address'] or ''}</textarea></div> <div class="form-group"> <label style="display:flex; align-items:center; gap:10px; cursor:pointer;"> <input type="checkbox" name="has_card" style="width:20px; height:20px;" {'checked' if client['has_card'] else ''}> <i class="fas fa-id-card"></i> Клубная карта </label> </div> <div class="form-group"> <label>Фотографии</label> <div id="photos-container">{photos_html}</div> <input type="file" name="photos" multiple accept="image/*"> </div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> <script> function deletePhoto(filename, clientId) {{ if(confirm('Удалить фото?')) {{ fetch(`/delete-photo/${{filename}}/${{clientId}}`, {{method: 'POST'}}) .then(() => location.reload()); }} }} </script> ''' return layout(content) except Exception as e: log_error(f"Ошибка при редактировании клиента {client_id}", e) if conn: conn.rollback() flash('Произошла ошибка при редактировании', 'error') return redirect(url_for('clients')) finally: if conn: conn.close() @app.route('/clients/delete/<int:client_id>') @login_required def client_delete(client_id): """Удаление клиента""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM rentals WHERE client_id = ? AND status = 'active'", (client_id,)) active_rentals = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM repairs WHERE client_id = ? AND status IN ('pending', 'progress')", (client_id,)) active_repairs = cursor.fetchone()[0] if active_rentals > 0 or active_repairs > 0: flash('Нельзя удалить клиента с активными прокатами или ремонтами', 'error') else: cursor.execute("SELECT filename FROM client_photos WHERE client_id = ?", (client_id,)) photos = cursor.fetchall() for p in photos: filepath = os.path.join(app.config['UPLOAD_FOLDER'], p['filename']) if os.path.exists(filepath): os.remove(filepath) cursor.execute("DELETE FROM client_photos WHERE client_id = ?", (client_id,)) cursor.execute("DELETE FROM clients WHERE id = ?", (client_id,)) conn.commit() flash('Клиент удалён', 'success') except Exception as e: log_error(f"Ошибка при удалении клиента {client_id}", e) if conn: conn.rollback() flash('Произошла ошибка при удалении', 'error') finally: if conn: conn.close() return redirect(url_for('clients')) @app.route('/delete-photo/<filename>/<int:client_id>', methods=['POST']) @login_required def delete_photo(filename, client_id): """Удаление фотографии клиента""" try: filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) if os.path.exists(filepath): os.remove(filepath) conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM client_photos WHERE filename = ? AND client_id = ?", (filename, client_id)) conn.commit() conn.close() except Exception as e: log_error(f"Ошибка при удалении фото {filename}", e) return '', 200 @app.route('/clients/export/excel') @login_required @admin_required def clients_export_excel(): """Экспорт клиентов в Excel""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM clients ORDER BY id DESC") clients = cursor.fetchall() output = io.BytesIO() workbook = xlsxwriter.Workbook(output, {'in_memory': True}) worksheet = workbook.add_worksheet('Клиенты') header_format = workbook.add_format({'bold': True, 'bg_color': '#366092', 'font_color': 'white', 'border': 1}) cell_format = workbook.add_format({'border': 1}) headers = ['ID', 'Фамилия', 'Имя', 'Отчество', 'Телефон', 'Паспорт', 'Адрес', 'Клубная карта', 'Дата регистрации'] for col, header in enumerate(headers): worksheet.write(0, col, header, header_format) for row, client in enumerate(clients, start=1): worksheet.write(row, 0, client['id'], cell_format) worksheet.write(row, 1, client['last_name'], cell_format) worksheet.write(row, 2, client['first_name'], cell_format) worksheet.write(row, 3, client['middle_name'] or '', cell_format) worksheet.write(row, 4, client['phone'], cell_format) worksheet.write(row, 5, client['passport'] or '', cell_format) worksheet.write(row, 6, client['address'] or '', cell_format) worksheet.write(row, 7, 'Да' if client['has_card'] else 'Нет', cell_format) worksheet.write(row, 8, client['created_at'] or '', cell_format) for col in range(len(headers)): worksheet.set_column(col, col, 15) workbook.close() output.seek(0) return send_file(output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=f'клиенты_{get_now().strftime("%Y-%m-%d")}.xlsx') except Exception as e: log_error("Ошибка при экспорте клиентов", e) flash('Произошла ошибка при экспорте', 'error') return redirect(url_for('clients')) finally: if conn: conn.close() # ===================== СОТРУДНИКИ ===================== @app.route('/employees') @login_required def employees(): """Список сотрудников""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' if not is_admin and employee_id: cursor.execute("SELECT * FROM employees WHERE id = ?", (employee_id,)) else: cursor.execute("SELECT * FROM employees ORDER BY name") employees_list = cursor.fetchall() rows = '' for e in employees_list: stats_btn = f'<a href="/employees/stats/{e["id"]}" class="btn-icon" title="Статистика"><i class="fas fa-chart-line"></i></a>' if is_admin else '' rows += f''' <tr> <td>{e["id"]}</td> <td>{e["name"]}</td> <td>{e["point_name"] or "—"}</td> <td>{e["salary_per_shift"] or 2000} ₽/смена</td> <td>{e["repair_percent"] or 50}% / {e["parts_percent"] or 10}%</td> <td> <a href="/salary?employee={e['id']}" class="btn-icon" title="Зарплата"><i class="fas fa-money-bill-wave"></i></a> <a href="/work_schedule?employee={e['id']}" class="btn-icon" title="График"><i class="fas fa-calendar-alt"></i></a> {stats_btn} <a href="/employees/edit/{e['id']}" class="btn-icon"><i class="fas fa-edit"></i></a> <a href="/employees/delete/{e['id']}" class="btn-icon btn-icon-danger" onclick="return confirm('Удалить сотрудника?')"><i class="fas fa-trash-alt"></i></a> </td> </tr> ''' if not rows: rows = '<tr><td colspan="6" style="text-align:center;padding:40px;">Нет сотрудников</td></tr>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-user-cog"></i> Сотрудники</h2> <a href="/employees/add"><button class="btn-primary"><i class="fas fa-user-plus"></i> Добавить</button></a></div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>ID</th><th>ФИО</th><th>Пункт</th><th>Ставка</th><th>Бонусы</th><th></th></thead> <tbody>{rows}</tbody> </table> </div></div> ''' return layout(content) except Exception as e: log_error("Ошибка в employees", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/employees/add', methods=['GET', 'POST']) @login_required @admin_required def employee_add(): """Добавление сотрудника""" conn = None try: conn = get_db() cursor = conn.cursor() if request.method == 'POST': name = request.form.get('name', '').strip() point_id = safe_int(request.form.get('point_id')) phone = request.form.get('phone', '').strip() passport = request.form.get('passport', '').strip() address = request.form.get('address', '').strip() salary_per_shift = safe_float(request.form.get('salary_per_shift', 2000)) repair_percent = safe_float(request.form.get('repair_percent', 50)) parts_percent = safe_float(request.form.get('parts_percent', 10)) if not name: flash('ФИО обязательно', 'error') return redirect(url_for('employee_add')) cursor.execute("SELECT name FROM points WHERE id = ?", (point_id,)) point = cursor.fetchone() point_name = point['name'] if point else '' cursor.execute(""" INSERT INTO employees (name, point_id, point_name, phone, passport, address, salary_per_shift, repair_percent, parts_percent, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (name, point_id, point_name, phone, passport, address, salary_per_shift, repair_percent, parts_percent, get_now().isoformat())) conn.commit() flash('Сотрудник добавлен', 'success') return redirect(url_for('employees')) cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = ''.join(f'<option value="{p["id"]}">{p["name"]}</option>' for p in points) content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-user-plus"></i> Добавить сотрудника</h2> <a href="/employees"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>ФИО *</label><input type="text" name="name" required></div> <div class="form-group"><label>Пункт *</label><select name="point_id" required>{points_opts}</select></div> </div> <div class="form-row"> <div class="form-group"><label>Телефон</label><input type="tel" name="phone"></div> <div class="form-group"><label>Паспорт</label><textarea name="passport" rows="2"></textarea></div> </div> <div class="form-group"><label>Адрес</label><textarea name="address" rows="2"></textarea></div> <h3 style="margin:20px 0 10px 0; color:var(--accent);"><i class="fas fa-money-bill-wave"></i> Настройки зарплаты</h3> <div class="form-row"> <div class="form-group"><label>Ставка за смену (₽)</label><input type="number" name="salary_per_shift" step="0.01" value="2000" required></div> <div class="form-group"><label>% от ремонта</label><input type="number" name="repair_percent" step="0.01" value="50" required></div> <div class="form-group"><label>% от запчастей</label><input type="number" name="parts_percent" step="0.01" value="10" required></div> </div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error("Ошибка при добавлении сотрудника", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') return redirect(url_for('employees')) finally: if conn: conn.close() @app.route('/employees/edit/<int:employee_id>', methods=['GET', 'POST']) @login_required @admin_required def employee_edit(employee_id): """Редактирование сотрудника""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM employees WHERE id = ?", (employee_id,)) emp = cursor.fetchone() if not emp: flash('Сотрудник не найден', 'error') return redirect(url_for('employees')) if request.method == 'POST': name = request.form.get('name', '').strip() point_id = safe_int(request.form.get('point_id')) phone = request.form.get('phone', '').strip() passport = request.form.get('passport', '').strip() address = request.form.get('address', '').strip() salary_per_shift = safe_float(request.form.get('salary_per_shift', 2000)) repair_percent = safe_float(request.form.get('repair_percent', 50)) parts_percent = safe_float(request.form.get('parts_percent', 10)) if not name: flash('ФИО обязательно', 'error') return redirect(url_for('employee_edit', employee_id=employee_id)) cursor.execute("SELECT name FROM points WHERE id = ?", (point_id,)) point = cursor.fetchone() point_name = point['name'] if point else '' cursor.execute(""" UPDATE employees SET name = ?, point_id = ?, point_name = ?, phone = ?, passport = ?, address = ?, salary_per_shift = ?, repair_percent = ?, parts_percent = ? WHERE id = ? """, (name, point_id, point_name, phone, passport, address, salary_per_shift, repair_percent, parts_percent, employee_id)) conn.commit() flash('Сотрудник обновлён', 'success') return redirect(url_for('employees')) cursor.execute("SELECT id, name FROM points ORDER BY name") points = cursor.fetchall() points_opts = ''.join(f'<option value="{p["id"]}" {"selected" if p["id"] == emp["point_id"] else ""}>{p["name"]}</option>' for p in points) content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-edit"></i> Редактирование сотрудника</h2> <a href="/employees"><button type="button" class="btn-outline"><i class="fas fa-times"></i> Отмена</button></a></div> <form method="POST" style="padding:20px;"> <div class="form-row"> <div class="form-group"><label>ФИО *</label><input type="text" name="name" value="{emp['name']}" required></div> <div class="form-group"><label>Пункт *</label><select name="point_id" required>{points_opts}</select></div> </div> <div class="form-row"> <div class="form-group"><label>Телефон</label><input type="tel" name="phone" value="{emp['phone'] or ''}"></div> <div class="form-group"><label>Паспорт</label><textarea name="passport" rows="2">{emp['passport'] or ''}</textarea></div> </div> <div class="form-group"><label>Адрес</label><textarea name="address" rows="2">{emp['address'] or ''}</textarea></div> <h3 style="margin:20px 0 10px 0; color:var(--accent);"><i class="fas fa-money-bill-wave"></i> Настройки зарплаты</h3> <div class="form-row"> <div class="form-group"><label>Ставка за смену (₽)</label><input type="number" name="salary_per_shift" step="0.01" value="{emp['salary_per_shift'] or 2000}" required></div> <div class="form-group"><label>% от ремонта</label><input type="number" name="repair_percent" step="0.01" value="{emp['repair_percent'] or 50}" required></div> <div class="form-group"><label>% от запчастей</label><input type="number" name="parts_percent" step="0.01" value="{emp['parts_percent'] or 10}" required></div> </div> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> </form></div> ''' return layout(content) except Exception as e: log_error(f"Ошибка при редактировании сотрудника {employee_id}", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') return redirect(url_for('employees')) finally: if conn: conn.close() @app.route('/employees/delete/<int:employee_id>') @login_required @admin_required def employee_delete(employee_id): """Удаление сотрудника""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT COUNT(*) FROM rentals WHERE employee_id = ?", (employee_id,)) rentals_count = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM repairs WHERE employee_id = ?", (employee_id,)) repairs_count = cursor.fetchone()[0] if rentals_count > 0 or repairs_count > 0: flash(f'Нельзя удалить сотрудника. Связанных записей: аренд - {rentals_count}, ремонтов - {repairs_count}', 'error') else: cursor.execute("DELETE FROM employees WHERE id = ?", (employee_id,)) conn.commit() flash('Сотрудник удалён', 'success') except Exception as e: log_error(f"Ошибка при удалении сотрудника {employee_id}", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') finally: if conn: conn.close() return redirect(url_for('employees')) @app.route('/employees/stats/<int:employee_id>') @login_required @admin_required def employee_stats(employee_id): """Статистика сотрудника""" conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("SELECT * FROM employees WHERE id = ?", (employee_id,)) employee = cursor.fetchone() if not employee: flash('Сотрудник не найден', 'error') return redirect(url_for('employees')) period = request.args.get('period', 'month') today = get_now().date() if period == 'today': date_from = today.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "Сегодня" elif period == 'week': week_ago = today - timedelta(days=7) date_from = week_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "7 дней" elif period == 'month': month_ago = today - timedelta(days=30) date_from = month_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "30 дней" else: date_from = request.args.get('date_from', today.strftime("%Y-%m-%d")) date_to = request.args.get('date_to', today.strftime("%Y-%m-%d")) period_text = f"{date_from} - {date_to}" cursor.execute(""" SELECT COUNT(*) FROM shifts WHERE employee_id = ? AND status = 'completed' AND date BETWEEN ? AND ? """, (employee_id, date_from, date_to)) shifts_count = cursor.fetchone()[0] base_salary = shifts_count * (employee['salary_per_shift'] or 2000) cursor.execute(""" SELECT SUM(amount) FROM income WHERE employee_id = ? AND category = 'Ремонт' AND date BETWEEN ? AND ? """, (employee_id, date_from, date_to)) repair_revenue = cursor.fetchone()[0] or 0 repair_bonus = repair_revenue * ((employee['repair_percent'] or 50) / 100) cursor.execute(""" SELECT SUM(amount) FROM income WHERE employee_id = ? AND category = 'Запчасти' AND date BETWEEN ? AND ? """, (employee_id, date_from, date_to)) parts_revenue = cursor.fetchone()[0] or 0 parts_bonus = parts_revenue * ((employee['parts_percent'] or 10) / 100) cursor.execute(""" SELECT SUM(price) FROM rentals WHERE employee_id = ? AND status = 'closed' AND created_at BETWEEN ? AND ? """, (employee_id, f"{date_from} 00:00:00", f"{date_to} 23:59:59")) rental_revenue = cursor.fetchone()[0] or 0 total_salary = base_salary + repair_bonus + parts_bonus content = f''' <div class="card"><div class="card-header"> <h2><i class="fas fa-user-chart"></i> Статистика: {employee['name']}</h2> <a href="/employees"><button type="button" class="btn-outline"><i class="fas fa-arrow-left"></i> Назад</button></a> </div></div> <div class="card"><div class="card-header"><h2><i class="fas fa-calendar-alt"></i> Период</h2></div> <div style="padding:15px;"> <form method="GET" style="display:flex; gap:10px; flex-wrap:wrap;"> <select name="period" onchange="this.form.submit()" style="padding:8px 12px;"> <option value="today" {'selected' if period == 'today' else ''}>Сегодня</option> <option value="week" {'selected' if period == 'week' else ''}>7 дней</option> <option value="month" {'selected' if period == 'month' else ''}>30 дней</option> </select> <button type="submit" class="btn-primary">Применить</button> </form> </div></div> <div class="dashboard-stats"> <div class="dashboard-card"><h3><i class="fas fa-calendar-check"></i> Смен</h3><div class="number">{shifts_count}</div></div> <div class="dashboard-card"><h3><i class="fas fa-ruble-sign"></i> Базовая ЗП</h3><div class="number">{base_salary:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-tools"></i> Ремонты</h3><div class="number">{repair_revenue:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-percent"></i> Бонус рем.</h3><div class="number">{repair_bonus:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-microchip"></i> Запчасти</h3><div class="number">{parts_revenue:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-percent"></i> Бонус зап.</h3><div class="number">{parts_bonus:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-bicycle"></i> Прокат</h3><div class="number">{rental_revenue:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-money-bill-wave"></i> Итого</h3><div class="number" style="color:#10b981;">{total_salary:,.0f} ₽</div></div> </div> ''' return layout(content) except Exception as e: log_error(f"Ошибка в статистике сотрудника {employee_id}", e) flash('Произошла ошибка', 'error') return redirect(url_for('employees')) finally: if conn: conn.close() # ===================== ЗАРПЛАТА ===================== @app.route('/salary') @login_required def salary(): """Расчёт зарплаты сотрудников""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' selected_employee = request.args.get('employee', 'all') period = request.args.get('period', 'month') today = get_now().date() if period == 'today': date_from = today.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "Сегодня" elif period == 'week': week_ago = today - timedelta(days=7) date_from = week_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "7 дней" elif period == 'month': month_ago = today - timedelta(days=30) date_from = month_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") period_text = "30 дней" else: date_from = request.args.get('date_from', today.strftime("%Y-%m-%d")) date_to = request.args.get('date_to', today.strftime("%Y-%m-%d")) period_text = f"{date_from} - {date_to}" cursor.execute("SELECT * FROM employees ORDER BY name") all_employees = cursor.fetchall() if selected_employee != 'all' and selected_employee.isdigit(): emp_id = int(selected_employee) cursor.execute("SELECT * FROM employees WHERE id = ?", (emp_id,)) employee = cursor.fetchone() if employee: cursor.execute(""" SELECT id, date, status, started_at, completed_at FROM shifts WHERE employee_id = ? AND date BETWEEN ? AND ? ORDER BY date DESC """, (emp_id, date_from, date_to)) shifts_list = cursor.fetchall() cursor.execute("SELECT SUM(amount) FROM income WHERE employee_id = ? AND category = 'Ремонт' AND date BETWEEN ? AND ?", (emp_id, date_from, date_to)) repair_sum = cursor.fetchone()[0] or 0 cursor.execute("SELECT SUM(amount) FROM income WHERE employee_id = ? AND category = 'Запчасти' AND date BETWEEN ? AND ?", (emp_id, date_from, date_to)) parts_sum = cursor.fetchone()[0] or 0 cursor.execute("SELECT COUNT(*) FROM shifts WHERE employee_id = ? AND status = 'completed' AND date BETWEEN ? AND ?", (emp_id, date_from, date_to)) days_worked = cursor.fetchone()[0] or 0 base_salary = days_worked * (employee['salary_per_shift'] or 2000) repair_bonus = repair_sum * ((employee['repair_percent'] or 50) / 100) parts_bonus = parts_sum * ((employee['parts_percent'] or 10) / 100) total_salary = base_salary + repair_bonus + parts_bonus shifts_rows = '' for shift in shifts_list: status_badge = '🟢 Активна' if shift['status'] == 'active' else '✅ Завершена' shifts_rows += f''' <tr class="clickable-row" data-url="/work_schedule?employee={emp_id}&highlight={shift['date']}"> <td>{shift['date']}</td> <td>{status_badge}</td> <td>{shift['started_at'][:16] if shift['started_at'] else '—'}</td> <td>{shift['completed_at'][:16] if shift['completed_at'] else '—'}</td> <td style="text-align:center;"> <form method="POST" action="/salary/delete_shift" style="display:inline;" onsubmit="return confirm('Удалить смену?')"> <input type="hidden" name="shift_id" value="{shift['id']}"> <input type="hidden" name="employee_id" value="{emp_id}"> <input type="hidden" name="period" value="{period}"> <button type="submit" class="btn-icon btn-icon-danger" title="Удалить"><i class="fas fa-trash-alt"></i></button> </form> </td> </tr> ''' if not shifts_rows: shifts_rows = '<tr><td colspan="5" style="text-align:center;padding:20px;">Нет смен</td></tr>' content = f''' <div class="card"><div class="card-header"> <h2><i class="fas fa-user"></i> {employee['name']}</h2> <a href="/salary" class="btn-outline"><i class="fas fa-arrow-left"></i> Назад</a> </div></div> <div class="dashboard-stats"> <div class="dashboard-card"><h3><i class="fas fa-calendar-check"></i> Смен</h3><div class="number">{days_worked}</div></div> <div class="dashboard-card"><h3><i class="fas fa-ruble-sign"></i> Ставка</h3><div class="number">{employee['salary_per_shift']} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-tools"></i> Ремонты</h3><div class="number">{repair_sum:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-percent"></i> Бонус рем.</h3><div class="number">{repair_bonus:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-microchip"></i> Запчасти</h3><div class="number">{parts_sum:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-percent"></i> Бонус зап.</h3><div class="number">{parts_bonus:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-money-bill-wave"></i> Итого</h3><div class="number" style="color:#10b981;">{total_salary:,.0f} ₽</div></div> </div> <div class="card"><div class="card-header"> <h2><i class="fas fa-calendar-alt"></i> Смены ({period_text})</h2> <button class="btn-primary" onclick="document.getElementById('addShiftForm').style.display='flex'"><i class="fas fa-plus"></i> Добавить</button> </div> <div style="padding:20px;"> <div id="addShiftForm" style="display:none; background:var(--bg-primary); padding:15px; border-radius:12px; margin-bottom:20px;"> <form method="POST" action="/salary/add_shift"> <input type="hidden" name="employee_id" value="{emp_id}"> <input type="hidden" name="period" value="{period}"> <div class="form-row"> <div class="form-group"><label>Дата</label><input type="date" name="shift_date" required></div> <div class="form-group"><label>Статус</label> <select name="status"> <option value="completed">Завершена</option> <option value="active">Активна</option> </select> </div> <div class="form-group" style="display:flex; align-items:flex-end;"> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить</button> <button type="button" class="btn-outline" style="margin-left:10px;" onclick="document.getElementById('addShiftForm').style.display='none'">Отмена</button> </div> </div> </form> </div> <div style="overflow-x:auto;"> <table class="data-table"> <thead><th>Дата</th><th>Статус</th><th>Начало</th><th>Завершение</th><th></th></thead> <tbody>{shifts_rows}</tbody> </table> </div> </div></div> <div class="card"><div class="card-header"><h2><i class="fas fa-chart-line"></i> Детали расчёта</h2></div> <div style="padding:20px;"> <ul style="color:var(--text-muted); margin-left:20px;"> <li>Базовая зарплата = {days_worked} смен × {employee['salary_per_shift']} ₽ = <strong>{base_salary:,.0f} ₽</strong></li> <li>Бонус за ремонт = {repair_sum:,.0f} ₽ × {employee['repair_percent']}% = <strong>{repair_bonus:,.0f} ₽</strong></li> <li>Бонус за запчасти = {parts_sum:,.0f} ₽ × {employee['parts_percent']}% = <strong>{parts_bonus:,.0f} ₽</strong></li> <li>Итого = <strong style="color:#10b981;">{total_salary:,.0f} ₽</strong></li> </ul> </div></div> ''' return layout(content) employees_html = '' for emp in all_employees: emp_id = emp['id'] emp_name = emp['name'] emp_point = emp['point_name'] or '—' cursor.execute("SELECT COUNT(*) FROM shifts WHERE employee_id = ? AND status = 'completed' AND date BETWEEN ? AND ?", (emp_id, date_from, date_to)) days_count = cursor.fetchone()[0] or 0 employees_html += f''' <div class="employee-card" onclick="window.location.href='/salary?employee={emp_id}&period={period}'"> <div class="employee-card-avatar"><i class="fas fa-user-circle"></i></div> <div class="employee-card-info"> <div class="employee-card-name">{emp_name}</div> <div class="employee-card-point"><i class="fas fa-map-marker-alt"></i> {emp_point}</div> <div class="employee-card-stats"> <span><i class="fas fa-calendar-check"></i> {days_count} смен</span> <span><i class="fas fa-ruble-sign"></i> {emp['salary_per_shift']} ₽/смена</span> </div> </div> <div class="employee-card-arrow"><i class="fas fa-chevron-right"></i></div> </div> ''' if not employees_html: employees_html = '<div class="no-employees">Нет сотрудников</div>' content = f''' <div class="card"><div class="card-header"> <h2><i class="fas fa-users"></i> Сотрудники</h2> <div class="period-selector"> <a href="?period=today" class="period-btn {'active' if period == 'today' else ''}">Сегодня</a> <a href="?period=week" class="period-btn {'active' if period == 'week' else ''}">7 дней</a> <a href="?period=month" class="period-btn {'active' if period == 'month' else ''}">30 дней</a> </div> </div> <div class="employees-grid"> {employees_html} </div></div> <style> .employees-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; padding: 20px; }} .employee-card {{ background: var(--bg-card); border-radius: 16px; border: 1px solid var(--border); padding: 16px; display: flex; align-items: center; gap: 16px; cursor: pointer; transition: all 0.2s ease; }} .employee-card:hover {{ transform: translateY(-2px); border-color: var(--accent); }} .employee-card-avatar {{ font-size: 3rem; color: var(--accent); }} .employee-card-info {{ flex: 1; }} .employee-card-name {{ font-weight: 600; font-size: 1rem; color: var(--text-primary); margin-bottom: 4px; }} .employee-card-point {{ font-size: 0.7rem; color: var(--text-muted); margin-bottom: 8px; }} .employee-card-stats {{ display: flex; gap: 12px; font-size: 0.7rem; color: var(--text-secondary); }} .employee-card-arrow {{ color: var(--text-muted); font-size: 1.2rem; }} .period-selector {{ display: flex; gap: 5px; background: var(--bg-primary); border-radius: 40px; padding: 3px; }} .period-btn {{ padding: 6px 16px; border-radius: 40px; text-decoration: none; font-size: 0.8rem; color: var(--text-secondary); }} .period-btn.active {{ background: var(--accent); color: white; }} .no-employees {{ text-align: center; padding: 60px; color: var(--text-muted); }} </style> ''' return layout(content) except Exception as e: log_error("Ошибка в salary", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/salary/add_shift', methods=['POST']) @login_required @admin_required def salary_add_shift(): """Добавление смены сотруднику""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = safe_int(request.form.get('employee_id')) shift_date = request.form.get('shift_date') status = request.form.get('status', 'completed') period = request.form.get('period', 'month') if not employee_id or not shift_date: flash('Не указаны данные', 'error') return redirect(url_for('salary')) cursor.execute("SELECT id FROM shifts WHERE employee_id = ? AND date = ?", (employee_id, shift_date)) existing = cursor.fetchone() if existing: flash('Смена на эту дату уже существует', 'error') else: started_at = f"{shift_date}T10:00:00" completed_at = f"{shift_date}T22:00:00" if status == 'completed' else None cursor.execute(""" INSERT INTO shifts (employee_id, date, status, started_at, completed_at) VALUES (?, ?, ?, ?, ?) """, (employee_id, shift_date, status, started_at, completed_at)) conn.commit() flash('Смена добавлена', 'success') return redirect(url_for('salary', employee=employee_id, period=period)) except Exception as e: log_error("Ошибка при добавлении смены", e) if conn: conn.rollback() flash('Произошла ошибка', 'error') return redirect(url_for('salary')) finally: if conn: conn.close() @app.route('/salary/delete_shift', methods=['POST']) @login_required @admin_required def salary_delete_shift(): """Удаление смены""" conn = None try: conn = get_db() cursor = conn.cursor() shift_id = safe_int(request.form.get('shift_id')) employee_id = safe_int(request.form.get('employee_id')) period = request.form.get('period', 'month') cursor.execute("DELETE FROM shifts WHERE id = ?", (shift_id,)) conn.commit() flash('Смена удалена', 'success') return redirect(url_for('salary', employee=employee_id, period=period)) except Exception as e: log_error("Ошибка при удалении смены", e) flash('Произошла ошибка', 'error') return redirect(url_for('salary')) finally: if conn: conn.close() # ===================== ГРАФИК РАБОТЫ ===================== def update_shifts_status(): """Автоматически завершает активные смены, если прошло больше 24 часов""" conn = None try: conn = get_db() cursor = conn.cursor() now = get_now() yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d") cursor.execute(""" UPDATE shifts SET status = 'completed', completed_at = ? WHERE status = 'active' AND date <= ? """, (now.isoformat(), yesterday)) conn.commit() except Exception as e: log_error("Ошибка при обновлении статусов смен", e) finally: if conn: conn.close() @app.route('/work_schedule') @login_required def work_schedule(): """График работы сотрудников""" update_shifts_status() conn = None try: conn = get_db() cursor = conn.cursor() is_admin = session.get('role') == 'admin' employee_id = session.get('employee_id') year = safe_int(request.args.get('year')) month = safe_int(request.args.get('month')) filter_employee = safe_int(request.args.get('employee')) highlight_date = request.args.get('highlight', '') now = get_now() if not year or not month: year = now.year month = now.month month_names = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'] first_day = datetime(year, month, 1) if month == 12: last_day = datetime(year + 1, 1, 1) - timedelta(days=1) else: last_day = datetime(year, month + 1, 1) - timedelta(days=1) days_in_month = last_day.day first_weekday = first_day.weekday() weekdays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'] if filter_employee: cursor.execute("SELECT id, name, point_name FROM employees WHERE id = ?", (filter_employee,)) employees = cursor.fetchall() elif not is_admin and employee_id: cursor.execute("SELECT id, name, point_name FROM employees WHERE id = ?", (employee_id,)) employees = cursor.fetchall() else: cursor.execute("SELECT id, name, point_name FROM employees ORDER BY name") employees = cursor.fetchall() date_from = f"{year}-{month:02d}-01" date_to = f"{year}-{month:02d}-{days_in_month:02d}" cursor.execute(""" SELECT employee_id, date, status FROM shifts WHERE date BETWEEN ? AND ? """, (date_from, date_to)) shifts_data = cursor.fetchall() shifts_dict = {} for shift in shifts_data: if shift['employee_id'] not in shifts_dict: shifts_dict[shift['employee_id']] = {} shifts_dict[shift['employee_id']][shift['date']] = shift['status'] today = get_now().date() schedule_rows = '' for emp in employees: emp_id = emp['id'] emp_name = emp['name'] emp_point = emp['point_name'] or '—' schedule_rows += f'<tr><td class="emp-name"><strong>{emp_name}</strong><br><small>{emp_point}</small></td>' for day in range(1, days_in_month + 1): date_str = f"{year}-{month:02d}-{day:02d}" shift_status = shifts_dict.get(emp_id, {}).get(date_str, None) is_highlight = (highlight_date == date_str) highlight_class = ' highlight-cell' if is_highlight else '' date_obj = datetime(year, month, day).date() if shift_status == 'active': cell_class = 'shift-active' + highlight_class cell_title = 'Активная смена' cell_icon = '🟢' elif shift_status == 'completed': cell_class = 'shift-completed' + highlight_class cell_title = 'Завершённая смена' cell_icon = '✅' else: cell_class = 'shift-free' + highlight_class cell_title = 'Выходной (кликните, чтобы добавить)' cell_icon = '⚪' if is_admin: schedule_rows += f'<td class="{cell_class}" onclick="window.toggleShift({emp_id}, \'{date_str}\')" title="{cell_title}">{cell_icon}</td>' else: schedule_rows += f'<td class="{cell_class}" title="{cell_title}">{cell_icon}</td>' schedule_rows += '</tr>' header_cells = '' for day in range(1, days_in_month + 1): weekday_idx = (first_weekday + day - 1) % 7 weekday_name = weekdays[weekday_idx] date_obj = datetime(year, month, day).date() is_today = (date_obj == today) today_class = ' today-header' if is_today else '' header_cells += f'<th class="day-header{today_class}">{day}<br><small>{weekday_name}</small></th>' prev_month = month - 1 if month > 1 else 12 prev_year = year if month > 1 else year - 1 next_month = month + 1 if month < 12 else 1 next_year = year if month < 12 else year + 1 back_link = '' if filter_employee: back_link = f'<a href="/salary?employee={filter_employee}" class="btn-outline"><i class="fas fa-arrow-left"></i> Назад</a>' elif not is_admin and employee_id: back_link = f'<a href="/salary?employee={employee_id}" class="btn-outline"><i class="fas fa-arrow-left"></i> Моя зарплата</a>' content = f''' <div class="card"><div class="card-header"> <h2><i class="fas fa-calendar-alt"></i> График работы</h2> <div style="display: flex; gap: 10px; flex-wrap: wrap;"> {back_link} <a href="/work_schedule?year={prev_year}&month={prev_month}{'&employee='+str(filter_employee) if filter_employee else ''}" class="btn-outline"><i class="fas fa-chevron-left"></i> {month_names[prev_month-1]}</a> <span style="font-size: 1.2rem; font-weight: bold;">{month_names[month-1]} {year}</span> <a href="/work_schedule?year={next_year}&month={next_month}{'&employee='+str(filter_employee) if filter_employee else ''}" class="btn-outline">{month_names[next_month-1]} <i class="fas fa-chevron-right"></i></a> </div> </div> <div class="schedule-legend" style="padding:15px; display:flex; gap:20px; justify-content:center; border-bottom:1px solid var(--border); flex-wrap:wrap;"> <span><span style="display:inline-block; width:20px; height:20px; background:#3b82f6; border-radius:4px;"></span> 🟢 Будущая смена</span> <span><span style="display:inline-block; width:20px; height:20px; background:#10b981; border-radius:4px;"></span> ✅ Отработанная смена</span> <span><span style="display:inline-block; width:20px; height:20px; background:#334155; border-radius:4px;"></span> ⚪ Выходной</span> <span><span style="display:inline-block; width:20px; height:20px; background:var(--bg-header); border:2px solid var(--accent); border-radius:4px;"></span> 📅 Сегодня</span> </div> <div style="overflow-x:auto; padding:0;"> <table class="schedule-table"> <thead> <tr><th class="emp-col">Сотрудник / Пункт</th>{header_cells}</tr> </thead> <tbody>{schedule_rows}</tbody> </table> </div></div> <style> .schedule-table {{ width: 100%; border-collapse: collapse; font-size: 0.75rem; min-width: 800px; }} .schedule-table th, .schedule-table td {{ border: 1px solid var(--border); padding: 8px 4px; text-align: center; }} .schedule-table th {{ background: var(--bg-header); color: var(--accent); font-weight: 600; }} .emp-col {{ position: sticky; left: 0; background: var(--bg-card); min-width: 140px; text-align: left; }} .day-header {{ min-width: 45px; }} .today-header {{ background: var(--accent); color: white; }} .shift-active {{ background: #1e3a5f; color: #60a5fa; cursor: pointer; }} .shift-completed {{ background: #064e3b; color: #34d399; cursor: pointer; }} .shift-free {{ background: var(--bg-primary); color: var(--text-muted); cursor: pointer; }} .shift-active:hover, .shift-completed:hover, .shift-free:hover {{ filter: brightness(1.1); }} .highlight-cell {{ box-shadow: inset 0 0 0 2px var(--accent); }} </style> <script> window.toggleShift = function(employeeId, dateStr) {{ fetch('/work_schedule/toggle_shift', {{ method: 'POST', headers: {{ 'Content-Type': 'application/json' }}, body: JSON.stringify({{ employee_id: employeeId, date: dateStr }}) }}).then(res => res.json()).then(data => {{ if (data.success) {{ location.reload(); }} else {{ alert('Ошибка: ' + data.error); }} }}); }}; </script> ''' return layout(content) except Exception as e: log_error("Ошибка в work_schedule", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() @app.route('/work_schedule/toggle_shift', methods=['POST']) @admin_required def work_schedule_toggle_shift(): """Переключение смены (добавление/удаление)""" conn = None try: conn = get_db() cursor = conn.cursor() data = json.loads(request.data) employee_id = data.get('employee_id') date_str = data.get('date') if not employee_id or not date_str: return jsonify({'success': False, 'error': 'Не указаны данные'}) cursor.execute("SELECT id, status FROM shifts WHERE employee_id = ? AND date = ?", (employee_id, date_str)) existing = cursor.fetchone() today = get_now().date() shift_date_obj = datetime.strptime(date_str, "%Y-%m-%d").date() if existing: cursor.execute("DELETE FROM shifts WHERE id = ?", (existing['id'],)) conn.commit() return jsonify({'success': True, 'action': 'deleted'}) else: started_at = f"{date_str}T10:00:00" if shift_date_obj > today: status = 'active' completed_at = None else: status = 'completed' completed_at = f"{date_str}T22:00:00" cursor.execute(""" INSERT INTO shifts (employee_id, date, status, started_at, completed_at) VALUES (?, ?, ?, ?, ?) """, (employee_id, date_str, status, started_at, completed_at)) conn.commit() return jsonify({'success': True, 'action': 'added'}) except Exception as e: log_error("Ошибка при переключении смены", e) return jsonify({'success': False, 'error': str(e)}) finally: if conn: conn.close() # ===================== ОТЧЁТЫ ===================== @app.route('/reports') @login_required def reports(): """Отчёты по доходам и кассе""" conn = None try: conn = get_db() cursor = conn.cursor() employee_id = session.get('employee_id') is_admin = session.get('role') == 'admin' point_filter = request.args.get('point', 'all') category_filter = request.args.get('category', 'all') payment_filter = request.args.get('payment', 'all') period = request.args.get('period', 'today') date_from = request.args.get('date_from', '') date_to = request.args.get('date_to', '') today = get_now().date() if period == 'today': date_from = today.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'yesterday': yesterday = today - timedelta(days=1) date_from = yesterday.strftime("%Y-%m-%d") date_to = yesterday.strftime("%Y-%m-%d") elif period == 'week': week_ago = today - timedelta(days=7) date_from = week_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'month': month_ago = today - timedelta(days=30) date_from = month_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'custom': if not date_from or not date_to: date_from = today.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") cursor.execute("SELECT id, name FROM points ORDER BY name") all_points = cursor.fetchall() points_opts = '<option value="all">🏢 Все пункты</option>' for p in all_points: selected = 'selected' if point_filter == p['name'] else '' points_opts += f'<option value="{p["name"]}" {selected}>📍 {p["name"]}</option>' query = """ SELECT i.*, e.point_name as emp_point_name FROM income i LEFT JOIN employees e ON i.employee_id = e.id WHERE i.date BETWEEN ? AND ? AND i.amount > 0 """ params = [date_from, date_to] if point_filter != 'all': query += " AND (i.point_name = ? OR e.point_name = ?)" params.append(point_filter) params.append(point_filter) if category_filter != 'all': query += " AND i.category = ?" params.append(category_filter) if payment_filter != 'all': query += " AND i.payment_type = ?" params.append(payment_filter) query += " ORDER BY i.date DESC, i.id DESC" cursor.execute(query, params) incomes_raw = cursor.fetchall() incomes = [] for row in incomes_raw: row_dict = dict(row) if not row_dict.get('point_name') and row_dict.get('emp_point_name'): row_dict['point_name'] = row_dict['emp_point_name'] elif not row_dict.get('point_name'): row_dict['point_name'] = '—' incomes.append(row_dict) total = sum(i['amount'] for i in incomes) cash_total = sum(i['amount'] for i in incomes if i['payment_type'] == 'Наличные') card_total = sum(i['amount'] for i in incomes if i['payment_type'] == 'Карта') transfer_total = sum(i['amount'] for i in incomes if i['payment_type'] == 'Перевод') rent_total = sum(i['amount'] for i in incomes if 'Прокат' in i['category']) repair_total = sum(i['amount'] for i in incomes if i['category'] == 'Ремонт') parts_total = sum(i['amount'] for i in incomes if i['category'] == 'Запчасти') try: cursor.execute("SELECT cash_start FROM shifts LIMIT 1") has_cash_columns = True except: has_cash_columns = False shifts_rows = '' if has_cash_columns: if is_admin: cursor.execute(""" SELECT s.*, e.name as employee_name FROM shifts s LEFT JOIN employees e ON s.employee_id = e.id WHERE s.date BETWEEN ? AND ? AND s.status = 'completed' ORDER BY s.date DESC """, (date_from, date_to)) else: cursor.execute(""" SELECT s.*, e.name as employee_name FROM shifts s LEFT JOIN employees e ON s.employee_id = e.id WHERE s.employee_id = ? AND s.date BETWEEN ? AND ? AND s.status = 'completed' ORDER BY s.date DESC """, (employee_id, date_from, date_to)) shifts = cursor.fetchall() for s in shifts: cursor.execute(""" SELECT SUM(amount) FROM income WHERE employee_id = ? AND date = ? AND payment_type = 'Наличные' """, (s['employee_id'], s['date'])) shift_cash = cursor.fetchone()[0] or 0 cash_expected = (s['cash_start'] or 0) + shift_cash cash_diff = (s['cash_end'] or 0) - cash_expected shifts_rows += f''' <tr> <td><span class="date-badge">{s['date']}</span></td> <td><span class="employee-name">{s['employee_name'] or '—'}</span></td> <td><span class="cash-value">{s['cash_start']:,.0f} ₽</span></td> <td><span class="cash-income">+{shift_cash:,.0f} ₽</span></td> <td><span class="cash-expected">{cash_expected:,.0f} ₽</span></td> <td><span class="cash-fact">{s['cash_end']:,.0f} ₽</span></td> <td><span class="cash-diff {'positive' if cash_diff >= 0 else 'negative'}">{'+' if cash_diff >= 0 else ''}{cash_diff:,.0f} ₽</span></td> </tr> ''' if not shifts_rows: shifts_rows = '<tr><td colspan="7" class="empty-row"><div class="empty-state"><i class="fas fa-calendar-times"></i><span>Нет завершённых смен</span></div></td></tr>' else: shifts_rows = '<tr><td colspan="7" class="empty-row"><div class="empty-state"><i class="fas fa-database"></i><span>Колонки кассы не найдены</span></div></td></tr>' rows = '' for i in incomes[:50]: cat_icon = '🚲' if 'Прокат' in i['category'] else ('🔧' if i['category'] == 'Ремонт' else ('🔩' if i['category'] == 'Запчасти' else '💰')) pay_icon = '💵' if i['payment_type'] == 'Наличные' else ('💳' if i['payment_type'] == 'Карта' else '📱') delete_btn = '' if is_admin: delete_btn = f''' <button onclick="window.deleteIncome({i['id']}, '{i['comment']}')" class="delete-btn" title="Удалить доход"> <i class="fas fa-trash-alt"></i> </button> ''' rows += f''' <tr> <td><span class="date-badge">{i["date"]}</span></td> <td><span class="category-badge">{cat_icon} {i["category"]}</span></td> <td><span class="amount-value">{i["amount"]:,.0f} ₽</span></td> <td><span class="payment-badge">{pay_icon} {i["payment_type"]}</span></td> <td><span class="point-badge"><i class="fas fa-map-marker-alt"></i> {i["point_name"] or '—'}</span></td> <td><span class="comment-text">{i["comment"] or '—'}</span></td> <td class="action-cell">{delete_btn}</td> </tr> ''' if not rows: rows = f''' <tr><td colspan="7" class="empty-row"> <div class="empty-state"> <i class="fas fa-receipt"></i> <span>Нет доходов за выбранный период</span> <small>Попробуйте изменить фильтры</small> </div> </td></tr> ''' period_text = { 'today': 'Сегодня', 'yesterday': 'Вчера', 'week': '7 дней', 'month': '30 дней', 'custom': 'Произвольный' }.get(period, 'Сегодня') date_range_text = f"{date_from} — {date_to}" if period == 'custom' else period_text content = f''' <style> .reports-container {{ display: flex; flex-direction: column; gap: 24px; max-width: 1400px; margin: 0 auto; }} .filter-card {{ background: var(--bg-card); border-radius: 28px; border: 1px solid var(--border); overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }} .filter-header {{ padding: 20px 28px; background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 14px; }} .filter-header i {{ font-size: 1.4rem; color: var(--accent); background: rgba(59, 130, 246, 0.1); padding: 10px; border-radius: 16px; }} .filter-header h2 {{ font-size: 1.4rem; font-weight: 700; color: var(--text-primary); margin: 0; }} .filter-body {{ padding: 28px; }} .filter-row {{ display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; }} .filter-group {{ flex: 1; min-width: 160px; }} .filter-group label {{ display: block; font-size: 0.8rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); margin-bottom: 8px; }} .filter-group select, .filter-group input {{ width: 100%; padding: 14px 16px; border-radius: 18px; border: 2px solid var(--border); background: var(--input-bg); color: var(--text-primary); font-size: 0.95rem; font-weight: 500; }} .filter-actions {{ display: flex; gap: 12px; align-items: center; }} .btn-apply {{ padding: 14px 28px; background: var(--accent); color: white; border: none; border-radius: 18px; cursor: pointer; font-weight: 600; font-size: 0.95rem; display: flex; align-items: center; gap: 10px; }} .btn-reset {{ padding: 14px 24px; background: transparent; color: var(--text-secondary); border: 2px solid var(--border); border-radius: 18px; cursor: pointer; font-weight: 500; font-size: 0.95rem; display: flex; align-items: center; gap: 10px; text-decoration: none; }} .stats-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 18px; margin-bottom: 24px; }} .stats-grid.secondary {{ grid-template-columns: repeat(3, 1fr); }} .stat-card {{ background: linear-gradient(145deg, var(--bg-card) 0%, var(--bg-header) 100%); border-radius: 24px; padding: 24px 20px; text-align: center; border: 1px solid var(--border); box-shadow: 0 4px 16px rgba(0,0,0,0.06); }} .stat-card.total {{ background: linear-gradient(145deg, #1e3a5f 0%, #152940 100%); border-color: var(--accent); }} .stat-card.total .stat-value {{ color: #10b981; }} .stat-icon {{ font-size: 2rem; margin-bottom: 12px; opacity: 0.7; }} .stat-label {{ font-size: 0.85rem; font-weight: 600; text-transform: uppercase; color: var(--text-muted); margin-bottom: 10px; }} .stat-value {{ font-size: 2.2rem; font-weight: 800; color: var(--accent); line-height: 1.2; }} .table-card {{ background: var(--bg-card); border-radius: 28px; border: 1px solid var(--border); overflow: hidden; margin-bottom: 24px; }} .table-header {{ padding: 24px 28px; background: linear-gradient(135deg, var(--bg-header) 0%, var(--bg-secondary) 100%); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }} .table-header h2 {{ font-size: 1.5rem; font-weight: 700; color: var(--text-primary); margin: 0; display: flex; align-items: center; gap: 14px; }} .period-badge {{ background: var(--accent); color: white; padding: 10px 20px; border-radius: 40px; font-size: 0.9rem; font-weight: 600; display: flex; align-items: center; gap: 8px; }} .table-wrapper {{ overflow-x: auto; padding: 8px 0; }} .data-table {{ width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.9rem; }} .data-table th {{ background: var(--bg-primary); color: var(--text-primary); font-weight: 700; font-size: 0.85rem; text-transform: uppercase; padding: 18px 16px; text-align: left; border-bottom: 2px solid var(--border); white-space: nowrap; }} .data-table td {{ padding: 16px; border-bottom: 1px solid var(--border); color: var(--text-secondary); }} .data-table tr:hover td {{ background: var(--bg-primary); }} .date-badge {{ background: var(--bg-primary); padding: 6px 12px; border-radius: 20px; font-weight: 500; font-size: 0.85rem; display: inline-block; }} .employee-name {{ font-weight: 600; color: var(--text-primary); }} .category-badge {{ background: var(--bg-primary); padding: 6px 14px; border-radius: 24px; font-weight: 500; display: inline-block; }} .amount-value {{ font-weight: 800; font-size: 1.1rem; color: var(--accent); }} .payment-badge {{ background: var(--bg-primary); padding: 6px 12px; border-radius: 20px; font-weight: 500; }} .point-badge {{ color: var(--text-secondary); font-weight: 500; }} .comment-text {{ max-width: 250px; color: var(--text-muted); font-style: italic; }} .cash-value, .cash-expected, .cash-fact {{ font-weight: 600; color: var(--text-primary); }} .cash-income {{ color: #10b981; font-weight: 700; }} .cash-diff {{ font-weight: 700; padding: 6px 12px; border-radius: 20px; display: inline-block; }} .cash-diff.positive {{ background: rgba(16, 185, 129, 0.1); color: #10b981; }} .cash-diff.negative {{ background: rgba(239, 68, 68, 0.1); color: #ef4444; }} .delete-btn {{ background: none; border: none; color: var(--danger); cursor: pointer; font-size: 1.1rem; padding: 8px 12px; border-radius: 12px; opacity: 0.6; }} .delete-btn:hover {{ background: rgba(239, 68, 68, 0.1); opacity: 1; }} .action-cell {{ text-align: center; width: 60px; }} .empty-row {{ text-align: center !important; }} .empty-state {{ display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 40px; color: var(--text-muted); }} .empty-state i {{ font-size: 4rem; opacity: 0.3; margin-bottom: 20px; }} .empty-state span {{ font-size: 1.2rem; font-weight: 600; margin-bottom: 8px; }} .share-menu-container {{ position: relative; display: inline-block; }} .share-main-btn {{ padding: 12px 24px; border-radius: 40px; border: none; cursor: pointer; font-size: 0.95rem; font-weight: 600; display: flex; align-items: center; gap: 10px; color: white; background: var(--accent); }} .share-dropdown {{ display: none; position: absolute; top: calc(100% + 8px); right: 0; background: var(--bg-card); border: 1px solid var(--border); border-radius: 20px; box-shadow: 0 12px 32px rgba(0,0,0,0.2); z-index: 1000; min-width: 200px; overflow: hidden; }} .share-dropdown.show {{ display: block; }} .share-dropdown a {{ display: flex; align-items: center; gap: 14px; padding: 16px 20px; color: var(--text-primary); text-decoration: none; font-size: 1rem; font-weight: 500; border-bottom: 1px solid var(--border); }} .share-dropdown a:last-child {{ border-bottom: none; }} .share-dropdown a:hover {{ background: var(--bg-primary); padding-left: 24px; }} @media (max-width: 1000px) {{ .stats-grid {{ grid-template-columns: repeat(2, 1fr); }} .stats-grid.secondary {{ grid-template-columns: repeat(2, 1fr); }} }} @media (max-width: 768px) {{ .filter-row {{ flex-direction: column; }} .filter-group {{ width: 100%; }} .stats-grid {{ grid-template-columns: 1fr; }} .stats-grid.secondary {{ grid-template-columns: 1fr; }} .table-header {{ flex-direction: column; align-items: flex-start; }} .stat-value {{ font-size: 1.8rem; }} }} </style> <div class="reports-container"> <div class="filter-card"> <div class="filter-header"> <i class="fas fa-sliders-h"></i> <h2>Фильтры отчёта</h2> </div> <div class="filter-body"> <form method="GET"> <div class="filter-row"> <div class="filter-group"> <label><i class="far fa-calendar-alt"></i> Период</label> <select name="period" id="periodSelect" onchange="window.toggleCustomDate()"> <option value="today" {'selected' if period == 'today' else ''}>📅 Сегодня</option> <option value="yesterday" {'selected' if period == 'yesterday' else ''}>📅 Вчера</option> <option value="week" {'selected' if period == 'week' else ''}>📆 7 дней</option> <option value="month" {'selected' if period == 'month' else ''}>📆 30 дней</option> <option value="custom" {'selected' if period == 'custom' else ''}>📆 Произвольный</option> </select> </div> <div class="filter-group" id="customDateGroup" style="display: {'flex' if period == 'custom' else 'none'}; gap: 12px;"> <div style="flex:1;"> <label>Начало</label> <input type="date" name="date_from" value="{date_from}"> </div> <div style="flex:1;"> <label>Конец</label> <input type="date" name="date_to" value="{date_to}"> </div> </div> <div class="filter-group"> <label><i class="fas fa-map-marker-alt"></i> Пункт</label> <select name="point">{points_opts}</select> </div> <div class="filter-group"> <label><i class="fas fa-tag"></i> Категория</label> <select name="category"> <option value="all">📂 Все категории</option> <option value="Прокат взрослый" {'selected' if category_filter=='Прокат взрослый' else ''}>🚲 Прокат (взрослый)</option> <option value="Прокат детский" {'selected' if category_filter=='Прокат детский' else ''}>🧒 Прокат (детский)</option> <option value="Ремонт" {'selected' if category_filter=='Ремонт' else ''}>🔧 Ремонт</option> <option value="Запчасти" {'selected' if category_filter=='Запчасти' else ''}>🔩 Запчасти</option> </select> </div> <div class="filter-group"> <label><i class="fas fa-credit-card"></i> Способ оплаты</label> <select name="payment"> <option value="all">💳 Все способы</option> <option value="Наличные" {'selected' if payment_filter=='Наличные' else ''}>💵 Наличные</option> <option value="Карта" {'selected' if payment_filter=='Карта' else ''}>💳 Карта</option> <option value="Перевод" {'selected' if payment_filter=='Перевод' else ''}>📱 Перевод</option> </select> </div> <div class="filter-actions"> <button type="submit" class="btn-apply"> <i class="fas fa-search"></i> Применить </button> <a href="/reports" class="btn-reset"> <i class="fas fa-redo-alt"></i> Сбросить </a> </div> </div> </form> </div> </div> <div class="stats-grid"> <div class="stat-card total"> <div class="stat-icon">💰</div> <div class="stat-label">Общая выручка</div> <div class="stat-value">{total:,.0f} ₽</div> </div> <div class="stat-card"> <div class="stat-icon">💵</div> <div class="stat-label">Наличные</div> <div class="stat-value">{cash_total:,.0f} ₽</div> </div> <div class="stat-card"> <div class="stat-icon">💳</div> <div class="stat-label">Карта</div> <div class="stat-value">{card_total:,.0f} ₽</div> </div> <div class="stat-card"> <div class="stat-icon">📱</div> <div class="stat-label">Перевод</div> <div class="stat-value">{transfer_total:,.0f} ₽</div> </div> </div> <div class="stats-grid secondary"> <div class="stat-card"> <div class="stat-icon">🚲</div> <div class="stat-label">Прокат</div> <div class="stat-value">{rent_total:,.0f} ₽</div> </div> <div class="stat-card"> <div class="stat-icon">🔧</div> <div class="stat-label">Ремонт</div> <div class="stat-value">{repair_total:,.0f} ₽</div> </div> <div class="stat-card"> <div class="stat-icon">🔩</div> <div class="stat-label">Запчасти</div> <div class="stat-value">{parts_total:,.0f} ₽</div> </div> </div> <div class="table-card"> <div class="table-header"> <h2><i class="fas fa-cash-register"></i> Кассовый отчёт</h2> <span class="period-badge"><i class="far fa-calendar-check"></i> {date_range_text}</span> </div> <div class="table-wrapper"> <table class="data-table"> <thead> <tr> <th>Дата</th> <th>Сотрудник</th> <th>Начальный остаток</th> <th>Приход</th> <th>Ожидалось</th> <th>Фактический остаток</th> <th>Разница</th> </tr> </thead> <tbody>{shifts_rows}</tbody> </table> </div> </div> <div class="table-card"> <div class="table-header"> <h2><i class="fas fa-list-ul"></i> Детализация доходов</h2> <div style="display: flex; gap: 16px; align-items: center;"> <span class="period-badge"><i class="far fa-calendar-alt"></i> {date_range_text}</span> <div class="share-menu-container"> <button class="share-main-btn" onclick="window.toggleShareMenu(event)"> <i class="fas fa-share-alt"></i> Поделиться </button> <div class="share-dropdown" id="shareDropdown"> <a href="#" onclick="window.shareToTelegram(); return false;"> <i class="fab fa-telegram" style="color:#0088cc;"></i> Telegram </a> <a href="#" onclick="window.shareToVK(); return false;"> <i class="fab fa-vk" style="color:#0077ff;"></i> ВКонтакте </a> <a href="#" onclick="window.copyReport(); return false;"> <i class="fas fa-copy" style="color:#64748b;"></i> Скопировать </a> </div> </div> </div> </div> <div class="table-wrapper"> <table class="data-table"> <thead> <tr> <th>Дата</th> <th>Категория</th> <th>Сумма</th> <th>Способ оплаты</th> <th>Пункт</th> <th>Комментарий</th> <th style="width:60px;"></th> </tr> </thead> <tbody>{rows}</tbody> </table> </div> </div> </div> <script> window.toggleCustomDate = function() {{ var period = document.getElementById('periodSelect').value; document.getElementById('customDateGroup').style.display = period === 'custom' ? 'flex' : 'none'; }}; window.toggleShareMenu = function(event) {{ event.stopPropagation(); var dropdown = document.getElementById('shareDropdown'); dropdown.classList.toggle('show'); }}; document.addEventListener('click', function() {{ var dropdown = document.getElementById('shareDropdown'); if (dropdown) dropdown.classList.remove('show'); }}); window.deleteIncome = function(id, comment) {{ if (!confirm('Удалить доход: ' + comment + '?')) return; fetch('/income/delete/' + id, {{ method: 'POST' }}) .then(r => r.json()) .then(d => {{ if (d.success) location.reload(); else alert('Ошибка: ' + d.error); }}); }}; window.getReportData = function() {{ var periodText = document.querySelector('.period-badge')?.innerText?.trim() || 'Сегодня'; var totalRevenue = document.querySelector('.stat-card.total .stat-value')?.innerText || '0 ₽'; var statCards = document.querySelectorAll('.stats-grid'); var paymentCards = statCards[0]?.querySelectorAll('.stat-card') || []; var cashTotal = paymentCards[1]?.querySelector('.stat-value')?.innerText || '0 ₽'; var cardTotal = paymentCards[2]?.querySelector('.stat-value')?.innerText || '0 ₽'; var transferTotal = paymentCards[3]?.querySelector('.stat-value')?.innerText || '0 ₽'; var categoryCards = statCards[1]?.querySelectorAll('.stat-card') || []; var rentTotal = categoryCards[0]?.querySelector('.stat-value')?.innerText || '0 ₽'; var repairTotal = categoryCards[1]?.querySelector('.stat-value')?.innerText || '0 ₽'; var partsTotal = categoryCards[2]?.querySelector('.stat-value')?.innerText || '0 ₽'; var pointSelect = document.querySelector('select[name="point"]'); var pointText = pointSelect?.options[pointSelect.selectedIndex]?.text || 'Все пункты'; var cashStart = '0'; var cashEnd = '0'; var cashDiff = '0'; var cashRows = document.querySelectorAll('.table-card:first-of-type .data-table tbody tr'); if (cashRows.length > 0 && !cashRows[0].innerText.includes('Нет завершённых')) {{ var firstRow = cashRows[0]; var cells = firstRow.querySelectorAll('td'); if (cells.length >= 6) {{ cashStart = cells[2]?.innerText?.replace(/[^0-9]/g, '') || '0'; cashEnd = cells[5]?.innerText?.replace(/[^0-9]/g, '') || '0'; cashDiff = cells[6]?.innerText?.replace(/[^0-9-]/g, '') || '0'; }} }} return {{ periodText, totalRevenue, cashTotal, cardTotal, transferTotal, rentTotal, repairTotal, partsTotal, pointText, cashStart, cashEnd, cashDiff }}; }}; window.generateFullReportText = function() {{ var d = window.getReportData(); var text = `📊 ОТЧЁТ ВЕЛОДОСТУП%0A`; text += `📅 Период: ${{d.periodText}}%0A`; text += `📍 Пункт: ${{d.pointText}}%0A%0A`; text += `💰 ОБЩАЯ ВЫРУЧКА: ${{d.totalRevenue}}%0A%0A`; text += `💵 Наличные: ${{d.cashTotal}}%0A`; text += `💳 Карта: ${{d.cardTotal}}%0A`; text += `📱 Перевод: ${{d.transferTotal}}%0A%0A`; text += `🚲 Прокат: ${{d.rentTotal}}%0A`; text += `🔧 Ремонт: ${{d.repairTotal}}%0A`; text += `🔩 Запчасти: ${{d.partsTotal}}%0A%0A`; text += `🏦 КАССА:%0A`; text += `💰 Начальный остаток: ${{parseInt(d.cashStart).toLocaleString()}} ₽%0A`; text += `💵 Остаток на конец: ${{parseInt(d.cashEnd).toLocaleString()}} ₽%0A`; text += `📊 Разница: ${{parseInt(d.cashDiff) >= 0 ? '+' : ''}}${{parseInt(d.cashDiff).toLocaleString()}} ₽%0A%0A`; text += `🕐 ${{new Date().toLocaleString('ru-RU')}}`; return text; }}; window.generatePlainText = function() {{ var d = window.getReportData(); var text = `📊 ОТЧЁТ ВЕЛОДОСТУП\\n`; text += `📅 Период: ${{d.periodText}}\\n`; text += `📍 Пункт: ${{d.pointText}}\\n\\n`; text += `💰 ОБЩАЯ ВЫРУЧКА: ${{d.totalRevenue}}\\n\\n`; text += `💵 Наличные: ${{d.cashTotal}}\\n`; text += `💳 Карта: ${{d.cardTotal}}\\n`; text += `📱 Перевод: ${{d.transferTotal}}\\n\\n`; text += `🚲 Прокат: ${{d.rentTotal}}\\n`; text += `🔧 Ремонт: ${{d.repairTotal}}\\n`; text += `🔩 Запчасти: ${{d.partsTotal}}\\n\\n`; text += `🏦 КАССА:\\n`; text += `💰 Начальный остаток: ${{parseInt(d.cashStart).toLocaleString()}} ₽\\n`; text += `💵 Остаток на конец: ${{parseInt(d.cashEnd).toLocaleString()}} ₽\\n`; text += `📊 Разница: ${{parseInt(d.cashDiff) >= 0 ? '+' : ''}}${{parseInt(d.cashDiff).toLocaleString()}} ₽\\n\\n`; text += `🕐 ${{new Date().toLocaleString('ru-RU')}}`; return text; }}; window.shareToVK = function() {{ var text = window.generateFullReportText(); window.open('https://vk.com/share.php?url=&title=Отчёт%20Велодоступ&description=' + text, '_blank', 'width=600,height=400'); }}; window.shareToTelegram = function() {{ var text = window.generateFullReportText(); window.open('https://t.me/share/url?url=&text=' + text, '_blank'); }}; window.copyReport = function() {{ var text = window.generatePlainText(); if (navigator.clipboard) {{ navigator.clipboard.writeText(text).then(() => alert('✅ Отчёт скопирован в буфер обмена!')).catch(() => prompt('Скопируйте вручную:', text)); }} else {{ prompt('📋 Скопируйте отчёт:', text); }} }}; </script> ''' return layout(content) except Exception as e: log_error("Ошибка в reports", e) flash('Произошла ошибка при загрузке отчётов', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() # ===================== УДАЛЕНИЕ ДОХОДА ===================== @app.route('/income/delete/<int:income_id>', methods=['POST']) @admin_required def delete_income(income_id): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("DELETE FROM income WHERE id = ?", (income_id,)) conn.commit() return jsonify({'success': True}) except Exception as e: return jsonify({'success': False, 'error': str(e)}) finally: if conn: conn.close() # ===================== РАСХОДЫ ===================== @app.route('/expense', methods=['GET', 'POST']) @admin_required def expense_page(): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute(''' CREATE TABLE IF NOT EXISTS expense_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, created_at TEXT ) ''') cursor.execute("SELECT COUNT(*) FROM expense_categories") if cursor.fetchone()[0] == 0: default_categories = ['ЗП', 'Аренда места', 'Хоз', 'Велосипед', 'Реклама', 'Коммунальные', 'Транспорт', 'Ремонт', 'Канцелярия'] for cat in default_categories: cursor.execute("INSERT INTO expense_categories (name, created_at) VALUES (?, ?)", (cat, get_now().isoformat())) conn.commit() cursor.execute("SELECT id, name FROM expense_categories ORDER BY name") categories = cursor.fetchall() if request.method == 'POST': if 'add_expense' in request.form: category_id = safe_int(request.form.get('category_id')) amount = safe_float(request.form.get('amount', 0)) comment = request.form.get('comment', '').strip() if category_id <= 0 or amount <= 0: flash('Выберите категорию и укажите сумму', 'error') else: cursor.execute("SELECT name FROM expense_categories WHERE id = ?", (category_id,)) cat = cursor.fetchone() category_name = cat['name'] if cat else 'Прочее' cursor.execute("INSERT INTO expense (date, category, amount, comment) VALUES (?, ?, ?, ?)", (get_now().strftime("%Y-%m-%d"), category_name, amount, comment)) conn.commit() flash('Расход добавлен', 'success') elif 'add_category' in request.form: new_category = request.form.get('new_category', '').strip() if new_category: try: cursor.execute("INSERT INTO expense_categories (name, created_at) VALUES (?, ?)", (new_category, get_now().isoformat())) conn.commit() flash(f'Категория "{new_category}" добавлена!', 'success') except sqlite3.IntegrityError: flash(f'Категория "{new_category}" уже существует!', 'error') else: flash('Введите название категории', 'error') elif 'delete_category' in request.form: category_id = safe_int(request.form.get('delete_category_id')) cursor.execute("SELECT name FROM expense_categories WHERE id = ?", (category_id,)) cat = cursor.fetchone() if cat: cursor.execute("SELECT COUNT(*) FROM expense WHERE category = ?", (cat['name'],)) count = cursor.fetchone()[0] if count > 0: flash(f'Нельзя удалить категорию "{cat["name"]}", есть {count} расход(ов)', 'error') else: cursor.execute("DELETE FROM expense_categories WHERE id = ?", (category_id,)) conn.commit() flash(f'Категория "{cat["name"]}" удалена!', 'success') return redirect(url_for('expense_page')) cursor.execute("SELECT * FROM expense ORDER BY id DESC LIMIT 50") expenses = cursor.fetchall() cursor.execute(""" SELECT category, SUM(amount) as total FROM expense GROUP BY category ORDER BY total DESC """) expense_stats = cursor.fetchall() rows = '' for e in expenses: rows += f''' <tr> <td>{e["date"]}</td> <td><span class="badge badge-warning">{e["category"]}</span></td> <td>{e["amount"]:,.0f} ₽</td> <td>{e["comment"] or ""}</td> </tr> ''' if not rows: rows = '<tr><td colspan="4" style="text-align:center;padding:40px;">Нет расходов</td></tr>' categories_opts = '' for cat in categories: categories_opts += f'<option value="{cat["id"]}">{cat["name"]}</option>' categories_rows = '' for cat in categories: categories_rows += f''' <tr> <td>{cat["id"]}</td> <td>{cat["name"]}</td> <td> <form method="POST" style="display:inline-block;" onsubmit="return confirm('Удалить категорию "{cat["name"]}"?')"> <input type="hidden" name="delete_category" value="1"> <input type="hidden" name="delete_category_id" value="{cat["id"]}"> <button type="submit" class="btn-icon btn-icon-danger" title="Удалить"><i class="fas fa-trash-alt"></i></button> </form> </td> </tr> ''' if not categories_rows: categories_rows = '<tr><td colspan="3" style="text-align:center;padding:20px;">Нет категорий</td></tr>' stats_html = '' total_expense = 0 for stat in expense_stats: total_expense += stat['total'] stats_html += f''' <div style="display: flex; justify-content: space-between; margin-bottom: 8px; padding: 5px 10px; background: var(--bg-primary); border-radius: 8px;"> <span><i class="fas fa-tag"></i> {stat["category"]}</span> <span style="font-weight: bold; color: var(--accent);">{stat["total"]:,.0f} ₽</span> </div> ''' if not stats_html: stats_html = '<p style="text-align:center;padding:20px;">Нет данных</p>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-receipt"></i> Добавить расход</h2></div> <form method="POST" style="padding:20px;"> <input type="hidden" name="add_expense" value="1"> <div class="form-row"> <div class="form-group"> <label>Категория</label> <select name="category_id" class="filter-select" style="width:100%;" required> <option value="">— Выберите категорию —</option> {categories_opts} </select> </div> <div class="form-group"> <label>Сумма (₽)</label> <input type="number" name="amount" step="0.01" min="0" required> </div> <div class="form-group"> <label>Комментарий</label> <input name="comment" placeholder="Описание расхода"> </div> </div> <button class="btn-primary"><i class="fas fa-plus"></i> Добавить расход</button> </form></div> <div style="display: flex; gap: 20px; flex-wrap: wrap; margin-bottom: 20px;"> <div class="card" style="flex: 2;"> <div class="card-header"><h2><i class="fas fa-chart-pie"></i> Статистика расходов</h2></div> <div style="padding: 20px;"> <div style="margin-bottom: 15px; font-size: 1.1rem;"> <strong>Всего расходов: <span style="color: var(--accent);">{total_expense:,.0f} ₽</span></strong> </div> <div style="max-height: 300px; overflow-y: auto;"> {stats_html} </div> </div> </div> <div class="card" style="flex: 1;"> <div class="card-header"><h2><i class="fas fa-tags"></i> Управление категориями</h2></div> <div style="padding: 20px;"> <form method="POST" style="margin-bottom: 20px;"> <input type="hidden" name="add_category" value="1"> <div class="form-row"> <div class="form-group" style="flex: 3;"> <input type="text" name="new_category" placeholder="Новая категория" required> </div> <div class="form-group" style="flex: 1;"> <button type="submit" class="btn-primary" style="width:100%;"><i class="fas fa-plus"></i> Добавить</button> </div> </div> </form> <div style="max-height: 250px; overflow-y: auto;"> <table class="data-table" style="min-width: auto;"> <thead> <tr><th>ID</th><th>Категория</th><th style="width:50px;"></th></tr> </thead> <tbody>{categories_rows}</tbody> </table> </div> </div> </div> </div> <div class="card"><div class="card-header"><h2><i class="fas fa-history"></i> История расходов</h2></div> <div style="overflow-x:auto;"> <table class="data-table"> <thead> <tr><th>Дата</th><th>Категория</th><th>Сумма</th><th>Комментарий</th></tr> </thead> <tbody>{rows}</tbody> </table> </div></div> <style> .badge-warning {{ background: #78350f; color: #fbbf24; padding: 3px 10px; border-radius: 20px; font-size: 0.7rem; }} </style> ''' return layout(content) except Exception as e: log_error("Ошибка в expense_page", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() # ===================== НАСТРОЙКИ ===================== @app.route('/settings', methods=['GET', 'POST']) @admin_required def settings(): conn = None try: conn = get_db() cursor = conn.cursor() try: cursor.execute("ALTER TABLE services ADD COLUMN category TEXT") conn.commit() except: pass if request.method == 'POST': if 'save_settings' in request.form: settings_data = { 'enable_repairs': '1' if 'enable_repairs' in request.form else '0', 'enable_parts': '1' if 'enable_parts' in request.form else '0', 'enable_services': '1' if 'enable_services' in request.form else '0', 'enable_employees': '1' if 'enable_employees' in request.form else '0', 'enable_reports': '1' if 'enable_reports' in request.form else '0', 'enable_salary': '1' if 'enable_salary' in request.form else '0', 'enable_deposits': '1' if 'enable_deposits' in request.form else '0', 'enable_penalties': '1' if 'enable_penalties' in request.form else '0', 'enable_card_discount': '1' if 'enable_card_discount' in request.form else '0', 'auto_close_rental_days': request.form.get('auto_close_rental_days', '30') } for key, value in settings_data.items(): cursor.execute("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", (key, value)) conn.commit() flash('Настройки сохранены!', 'success') elif 'import_clients' in request.form: file = request.files.get('clients_file') if not file or file.filename == '': flash('❌ Не выбран файл', 'error') else: imported = 0 errors = [] try: filename = file.filename.lower() file_content = file.read() if filename.endswith('.csv'): content = file_content.decode('utf-8-sig') lines = content.strip().split('\n') for line_num, line in enumerate(lines[1:], start=2): try: row = line.split(',') if len(row) < 10: continue full_name = row[0].strip() name_parts = full_name.split() last_name = name_parts[0] if len(name_parts) > 0 else '' first_name = name_parts[1] if len(name_parts) > 1 else '' middle_name = name_parts[2] if len(name_parts) > 2 else '' phone = row[9].strip().replace(' ', '').replace('+', '').replace('-', '') if len(row) > 9 else '' if not last_name or not first_name or not phone: errors.append(f'Строка {line_num}: пропущены поля') continue cursor.execute(""" INSERT INTO clients (last_name, first_name, middle_name, phone, created_at) VALUES (?, ?, ?, ?, ?) """, (last_name, first_name, middle_name, phone, get_now().isoformat())) imported += 1 except Exception as ex: errors.append(f'Строка {line_num}: {str(ex)}') else: import xlrd workbook = xlrd.open_workbook(file_contents=file_content) sheet = workbook.sheet_by_index(0) for row in range(1, sheet.nrows): try: full_name = str(sheet.cell_value(row, 0)).strip() if sheet.cell_value(row, 0) else '' name_parts = full_name.split() last_name = name_parts[0] if len(name_parts) > 0 else '' first_name = name_parts[1] if len(name_parts) > 1 else '' middle_name = name_parts[2] if len(name_parts) > 2 else '' phone = str(sheet.cell_value(row, 9)).strip().replace(' ', '').replace('+', '').replace('-', '') if sheet.cell_value(row, 9) else '' if not last_name or not first_name or not phone: continue cursor.execute(""" INSERT INTO clients (last_name, first_name, middle_name, phone, created_at) VALUES (?, ?, ?, ?, ?) """, (last_name, first_name, middle_name, phone, get_now().isoformat())) imported += 1 except: pass conn.commit() flash(f'✅ Импортировано {imported} клиентов!', 'success') if errors: flash(f'⚠️ Ошибки: {"; ".join(errors[:5])}', 'warning') except Exception as ex: flash(f'❌ Ошибка при импорте: {str(ex)}', 'error') return redirect(url_for('settings')) cursor.execute("SELECT key, value FROM settings") rows = cursor.fetchall() settings_dict = {row['key']: row['value'] for row in rows} checked_repairs = 'checked' if settings_dict.get('enable_repairs', '1') == '1' else '' checked_parts = 'checked' if settings_dict.get('enable_parts', '1') == '1' else '' checked_services = 'checked' if settings_dict.get('enable_services', '1') == '1' else '' checked_employees = 'checked' if settings_dict.get('enable_employees', '1') == '1' else '' checked_reports = 'checked' if settings_dict.get('enable_reports', '1') == '1' else '' checked_salary = 'checked' if settings_dict.get('enable_salary', '1') == '1' else '' checked_deposits = 'checked' if settings_dict.get('enable_deposits', '0') == '1' else '' checked_penalties = 'checked' if settings_dict.get('enable_penalties', '1') == '1' else '' checked_card_discount = 'checked' if settings_dict.get('enable_card_discount', '1') == '1' else '' auto_days = settings_dict.get('auto_close_rental_days', '30') content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-sliders-h"></i> Настройки системы</h2></div> <form method="POST" style="padding:20px;"> <input type="hidden" name="save_settings" value="1"> <h3 style="margin:20px 0 10px 0; color:var(--accent);">📦 Модули системы</h3> <div class="form-row"> <div class="form-group"><label><input type="checkbox" name="enable_repairs" {checked_repairs}> <i class="fas fa-tools"></i> Мастерская</label></div> <div class="form-group"><label><input type="checkbox" name="enable_parts" {checked_parts}> <i class="fas fa-microchip"></i> Запчасти</label></div> </div> <div class="form-row"> <div class="form-group"><label><input type="checkbox" name="enable_services" {checked_services}> <i class="fas fa-clipboard-list"></i> Услуги</label></div> <div class="form-group"><label><input type="checkbox" name="enable_employees" {checked_employees}> <i class="fas fa-user-cog"></i> Сотрудники</label></div> </div> <div class="form-row"> <div class="form-group"><label><input type="checkbox" name="enable_reports" {checked_reports}> <i class="fas fa-chart-line"></i> Отчёты</label></div> <div class="form-group"><label><input type="checkbox" name="enable_salary" {checked_salary}> <i class="fas fa-money-bill-wave"></i> Зарплата</label></div> </div> <h3 style="margin:20px 0 10px 0; color:var(--accent);">💰 Финансовые настройки</h3> <div class="form-row"> <div class="form-group"><label><input type="checkbox" name="enable_deposits" {checked_deposits}> <i class="fas fa-archive"></i> Депозиты</label></div> <div class="form-group"><label><input type="checkbox" name="enable_penalties" {checked_penalties}> <i class="fas fa-exclamation-triangle"></i> Штрафы</label></div> </div> <div class="form-row"> <div class="form-group"><label><input type="checkbox" name="enable_card_discount" {checked_card_discount}> <i class="fas fa-credit-card"></i> Клубная карта</label></div> <div class="form-group"><label><i class="fas fa-calendar-alt"></i> Автозакрытие аренд (дней): <input type="number" name="auto_close_rental_days" value="{auto_days}" min="1" max="365" style="width:80px;"></label></div> </div> <div style="margin-top:30px;"> <button type="submit" class="btn-primary"><i class="fas fa-save"></i> Сохранить настройки</button> </div> </form></div> <div class="card"><div class="card-header"><h2><i class="fas fa-upload"></i> 👥 Импорт клиентов из Excel/CSV</h2></div> <div style="padding:20px;"> <p><i class="fas fa-info-circle"></i> Загрузите файл (.xls, .xlsx, .csv) с колонками:</p> <p><small>A-ФИО | J-Телефон</small></p> <form method="POST" enctype="multipart/form-data"> <input type="hidden" name="import_clients" value="1"> <div class="form-row"> <div class="form-group" style="flex:3;"> <input type="file" name="clients_file" accept=".xls,.xlsx,.csv" required style="padding:10px; border:1px solid var(--border); border-radius:10px; background:var(--input-bg); color:var(--text-primary); width:100%;"> </div> <div class="form-group" style="flex:1;"> <button type="submit" class="btn-primary" onclick="return confirm('Импортировать клиентов?')"><i class="fas fa-upload"></i> Импортировать</button> </div> </div> </form> </div></div> ''' return layout(content) except Exception as e: log_error("Ошибка в settings", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() # ===================== АНАЛИТИКА ===================== @app.route('/analytics') @login_required @admin_required def analytics(): conn = None try: conn = get_db() cursor = conn.cursor() point_filter = request.args.get('point', 'all') period = request.args.get('period', 'month') date_from = request.args.get('date_from', '') date_to = request.args.get('date_to', '') section = request.args.get('section', 'general') today = get_now().date() if period == 'today': date_from = today.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'week': week_ago = today - timedelta(days=7) date_from = week_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'month': month_ago = today - timedelta(days=30) date_from = month_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") elif period == 'year': year_ago = today - timedelta(days=365) date_from = year_ago.strftime("%Y-%m-%d") date_to = today.strftime("%Y-%m-%d") cursor.execute("SELECT SUM(amount) FROM income WHERE date BETWEEN ? AND ?", (date_from, date_to)) total_revenue = cursor.fetchone()[0] or 0 cursor.execute("SELECT SUM(amount) FROM expense WHERE date BETWEEN ? AND ?", (date_from, date_to)) total_expense = cursor.fetchone()[0] or 0 net_profit = total_revenue - total_expense cursor.execute("SELECT COUNT(*) FROM rentals WHERE status = 'closed' AND date(created_at) BETWEEN ? AND ?", (date_from, date_to)) rentals_count = cursor.fetchone()[0] or 0 cursor.execute("SELECT COUNT(*) FROM repairs WHERE status = 'issued' AND date(created_at) BETWEEN ? AND ?", (date_from, date_to)) repairs_count = cursor.fetchone()[0] or 0 total_deals = rentals_count + repairs_count cursor.execute("SELECT COUNT(*) FROM clients") total_clients = cursor.fetchone()[0] or 0 cursor.execute("SELECT COUNT(*), SUM(price) FROM rentals WHERE status = 'closed' AND date(created_at) BETWEEN ? AND ?", (date_from, date_to)) rent_data = cursor.fetchone() rent_count = rent_data[0] or 0 rent_sum = rent_data[1] or 0 cursor.execute("SELECT COUNT(*), SUM(price) FROM repairs WHERE status = 'issued' AND date(created_at) BETWEEN ? AND ?", (date_from, date_to)) repair_data = cursor.fetchone() repair_count = repair_data[0] or 0 repair_sum = repair_data[1] or 0 avg_rent_check = rent_sum / rent_count if rent_count > 0 else 0 avg_repair_check = repair_sum / repair_count if repair_count > 0 else 0 cursor.execute(""" SELECT date, SUM(amount) FROM income WHERE date BETWEEN ? AND ? GROUP BY date ORDER BY date """, (date_from, date_to)) chart_data = cursor.fetchall() chart_labels = [row[0] for row in chart_data] chart_values = [row[1] for row in chart_data] cursor.execute(""" SELECT category, SUM(amount) FROM expense WHERE date BETWEEN ? AND ? GROUP BY category ORDER BY SUM(amount) DESC LIMIT 10 """, (date_from, date_to)) expense_by_category = cursor.fetchall() cursor.execute("SELECT id, name FROM points") points = cursor.fetchall() points_opts = '<option value="all">Все пункты</option>' for p in points: selected = 'selected' if point_filter == str(p['id']) else '' points_opts += f'<option value="{p["id"]}" {selected}>{p["name"]}</option>' chart_labels_json = json.dumps(chart_labels) chart_values_json = json.dumps(chart_values) expense_html = '' total_expense_for_percent = total_expense if total_expense > 0 else 1 for cat, amount in expense_by_category: percent = (amount / total_expense_for_percent * 100) expense_html += f''' <div style="margin-bottom:15px;"> <div style="display:flex; justify-content:space-between; margin-bottom:5px;"> <span style="color:var(--text-primary);">{cat}</span> <span style="color:var(--text-secondary);">{amount:,.0f} ₽ ({percent:.1f}%)</span> </div> <div style="background:var(--bg-primary); border-radius:10px; overflow:hidden; height:24px;"> <div style="width:{percent}%; background:linear-gradient(90deg, #dc2626, #ef4444); height:24px; border-radius:10px; display:flex; align-items:center; justify-content:flex-end; padding-right:8px; color:white; font-size:0.7rem;">{percent:.0f}%</div> </div> </div> ''' if not expense_html: expense_html = '<p style="text-align:center; padding:20px;">Нет данных о расходах</p>' content = f''' <div class="card"><div class="card-header"><h2><i class="fas fa-chart-line"></i> Аналитика</h2></div></div> <div class="card"><div class="card-header"><h2><i class="fas fa-filter"></i> Фильтры</h2></div> <div style="padding:15px;"> <form method="GET" id="filterForm" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;"> <div class="form-group" style="margin-bottom:0;"> <label>Пункт проката:</label> <select name="point" style="padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> {points_opts} </select> </div> <div class="form-group" style="margin-bottom:0;"> <label>Период:</label> <select name="period" id="periodSelect" onchange="window.toggleCustomDate()" style="padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> <option value="today" {'selected' if period == 'today' else ''}>Сегодня</option> <option value="week" {'selected' if period == 'week' else ''}>7 дней</option> <option value="month" {'selected' if period == 'month' else ''}>30 дней</option> <option value="year" {'selected' if period == 'year' else ''}>Год</option> <option value="custom" {'selected' if period == 'custom' else ''}>Свои даты</option> </select> </div> <div id="customDates" style="display:{'flex' if period == 'custom' else 'none'}; gap:10px;"> <div class="form-group" style="margin-bottom:0;"> <label>С:</label> <input type="date" name="date_from" value="{request.args.get('date_from', '')}" style="padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> </div> <div class="form-group" style="margin-bottom:0;"> <label>По:</label> <input type="date" name="date_to" value="{request.args.get('date_to', '')}" style="padding:8px 12px; border-radius:10px; border:1px solid var(--border); background:var(--input-bg); color:var(--text-primary);"> </div> </div> <button type="submit" class="btn-primary">Применить</button> <button type="button" class="btn-outline" onclick="window.resetFilters()">Сбросить</button> </form> </div></div> <div class="card"><div class="card-header"><h2><i class="fas fa-chart-pie"></i> Разделы</h2></div> <div style="padding:15px; display:flex; gap:10px; flex-wrap:wrap;"> <a href="?section=general&point={point_filter}&period={period}&date_from={date_from}&date_to={date_to}" class="{'btn-primary' if section == 'general' else 'btn-outline'}" style="padding:8px 16px; border-radius:40px; text-decoration:none;"><i class="fas fa-chart-simple"></i> Общее</a> <a href="?section=rents&point={point_filter}&period={period}&date_from={date_from}&date_to={date_to}" class="{'btn-primary' if section == 'rents' else 'btn-outline'}" style="padding:8px 16px; border-radius:40px; text-decoration:none;"><i class="fas fa-bicycle"></i> Аренды</a> <a href="?section=repair&point={point_filter}&period={period}&date_from={date_from}&date_to={date_to}" class="{'btn-primary' if section == 'repair' else 'btn-outline'}" style="padding:8px 16px; border-radius:40px; text-decoration:none;"><i class="fas fa-tools"></i> Мастерская</a> </div></div> <div class="dashboard-stats"> <div class="dashboard-card"><h3><i class="fas fa-ruble-sign"></i> Выручка</h3><div class="number">{total_revenue:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-receipt"></i> Расход</h3><div class="number">{total_expense:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-chart-line"></i> Чистая прибыль</h3><div class="number" style="color:{'#10b981' if net_profit >= 0 else '#dc2626'};">{net_profit:,.0f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-handshake"></i> Сделки</h3><div class="number">{total_deals}</div></div> <div class="dashboard-card"><h3><i class="fas fa-users"></i> Клиенты</h3><div class="number">{total_clients}</div></div> </div> <div class="dashboard-stats"> <div class="dashboard-card"><h3><i class="fas fa-bicycle"></i> Аренды</h3><div class="number">{rent_count}</div><small>{rent_sum:,.0f} ₽</small></div> <div class="dashboard-card"><h3><i class="fas fa-tools"></i> Ремонты</h3><div class="number">{repair_count}</div><small>{repair_sum:,.0f} ₽</small></div> <div class="dashboard-card"><h3><i class="fas fa-calculator"></i> Ср. чек аренды</h3><div class="number">{avg_rent_check:,.2f} ₽</div></div> <div class="dashboard-card"><h3><i class="fas fa-calculator"></i> Ср. чек ремонта</h3><div class="number">{avg_repair_check:,.2f} ₽</div></div> </div> <div class="card"><div class="card-header"><h2><i class="fas fa-chart-line"></i> График выручки</h2></div> <div style="padding:20px;"> <canvas id="revenueChart" style="width:100%; max-height:400px;"></canvas> </div></div> <div class="card"><div class="card-header"><h2><i class="fas fa-chart-pie"></i> Расходы по категориям</h2></div> <div style="padding:20px;"> <div style="margin-bottom:15px; font-size:1.1rem;">Всего расходов: <strong>{total_expense:,.0f} ₽</strong></div> {expense_html} </div></div> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script> window.toggleCustomDate = function() {{ var period = document.getElementById('periodSelect').value; var customDiv = document.getElementById('customDates'); if (period === 'custom') {{ customDiv.style.display = 'flex'; }} else {{ customDiv.style.display = 'none'; }} }}; window.resetFilters = function() {{ window.location.href = '/analytics'; }}; const ctx = document.getElementById('revenueChart'); if (ctx) {{ new Chart(ctx, {{ type: 'line', data: {{ labels: {chart_labels_json}, datasets: [{{ label: 'Выручка, ₽', data: {chart_values_json}, borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', tension: 0.3, fill: true, pointBackgroundColor: '#3b82f6', pointRadius: 4, pointHoverRadius: 6 }}] }}, options: {{ responsive: true, maintainAspectRatio: true, plugins: {{ tooltip: {{ callbacks: {{ label: function(context) {{ return context.parsed.y.toLocaleString() + ' ₽'; }} }} }} }}, scales: {{ y: {{ ticks: {{ callback: function(value) {{ return value.toLocaleString() + ' ₽'; }} }} }} }} }} }}); }} </script> ''' return layout(content) except Exception as e: log_error("Ошибка в analytics", e) flash('Произошла ошибка', 'error') return redirect(url_for('dashboard')) finally: if conn: conn.close() # ===================== API ДЛЯ ПОИСКА КЛИЕНТОВ ===================== @app.route('/api/client/search') @login_required def api_client_search(): conn = None try: conn = get_db() cursor = conn.cursor() query = request.args.get('q', '').strip() if not query: return jsonify({'found': False, 'clients': []}) digits_only = ''.join(filter(str.isdigit, query)) if digits_only: cursor.execute(""" SELECT id, last_name, first_name, middle_name, phone, has_card FROM clients WHERE phone LIKE ? OR phone LIKE ? ORDER BY CASE WHEN phone = ? THEN 0 ELSE 1 END, id DESC LIMIT 10 """, (f'%{digits_only}%', f'%{query}%', digits_only)) else: cursor.execute(""" SELECT id, last_name, first_name, middle_name, phone, has_card FROM clients WHERE LOWER(last_name) LIKE ? OR LOWER(first_name) LIKE ? OR LOWER(middle_name) LIKE ? ORDER BY id DESC LIMIT 10 """, (f'%{query}%', f'%{query}%', f'%{query}%')) results = cursor.fetchall() if not results and digits_only: cursor.execute(""" SELECT id, last_name, first_name, middle_name, phone, has_card FROM clients WHERE LOWER(last_name) LIKE ? OR LOWER(first_name) LIKE ? ORDER BY id DESC LIMIT 10 """, (f'%{query}%', f'%{query}%')) results = cursor.fetchall() return jsonify({ 'found': len(results) > 0, 'clients': [dict(row) for row in results] }) except Exception as e: log_error("Ошибка при поиске клиентов", e) return jsonify({'found': False, 'clients': [], 'error': str(e)}) finally: if conn: conn.close() # ===================== ФОТО КЛИЕНТОВ ===================== @app.route('/photos/<filename>') @login_required def serve_photo(filename): file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) if os.path.exists(file_path): mimetype, _ = mimetypes.guess_type(file_path) if mimetype is None: if filename.lower().endswith(('.jpg', '.jpeg')): mimetype = 'image/jpeg' elif filename.lower().endswith('.png'): mimetype = 'image/png' elif filename.lower().endswith('.gif'): mimetype = 'image/gif' elif filename.lower().endswith('.webp'): mimetype = 'image/webp' else: mimetype = 'application/octet-stream' return send_file(file_path, mimetype=mimetype) else: return 'Фото не найдено', 404 # ===================== МИГРАЦИЯ: ДОБАВЛЯЕМ КАССУ В СМЕНЫ ===================== def migrate_shifts_add_cash(): conn = None try: conn = get_db() cursor = conn.cursor() cursor.execute("PRAGMA table_info(shifts)") columns = [col[1] for col in cursor.fetchall()] if 'cash_start' not in columns: cursor.execute("ALTER TABLE shifts ADD COLUMN cash_start REAL DEFAULT 0") if 'cash_end' not in columns: cursor.execute("ALTER TABLE shifts ADD COLUMN cash_end REAL DEFAULT 0") if 'cash_expected' not in columns: cursor.execute("ALTER TABLE shifts ADD COLUMN cash_expected REAL DEFAULT 0") conn.commit() print("✅ Колонки кассы добавлены в shifts", flush=True) except Exception as e: log_error("Ошибка миграции shifts (cash)", e) finally: if conn: conn.close() with app.app_context(): migrate_shifts_add_cash() # ===================== ТОЧКА ВХОДА ===================== if __name__ == '__main__': with app.app_context(): init_db() app.run(host='0.0.0.0', port=8080, debug=True)