diff --git a/backend/app.py b/backend/app.py index 5186df6..0ffff38 100644 --- a/backend/app.py +++ b/backend/app.py @@ -17,8 +17,14 @@ app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER # Register blueprints (routes) from .routes.cases import cases_blueprint from .routes.credentials import credentials_blueprint +from .routes.auth import auth_blueprint +from .routes.dashboard import dashboard_blueprint +from .routes.reports import reports_blueprint app.register_blueprint(cases_blueprint, url_prefix='/api') app.register_blueprint(credentials_blueprint, url_prefix='/api') +app.register_blueprint(auth_blueprint, url_prefix='/api') +app.register_blueprint(dashboard_blueprint, url_prefix='/api') +app.register_blueprint(reports_blueprint, url_prefix='/api') if __name__ == '__main__': # Use host 0.0.0.0 for Docker compatibility, port 5000 diff --git a/backend/middleware.py b/backend/middleware.py new file mode 100644 index 0000000..d101711 --- /dev/null +++ b/backend/middleware.py @@ -0,0 +1,32 @@ +import os +import jwt +from functools import wraps +from flask import request, jsonify + +# Should ideally be set in .env +SECRET_KEY = os.getenv('JWT_SECRET', 'super-secret-jwt-key-automarket') + +def token_required(f): + @wraps(f) + def decorated(*args, **kwargs): + token = None + auth_header = request.headers.get('Authorization') + + if auth_header and auth_header.startswith('Bearer '): + token = auth_header.split(" ")[1] + + # Fallback to query param for file downloads + if not token: + token = request.args.get('token') + + if not token: + return jsonify({'message': 'Token is missing!'}), 401 + + try: + data = jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + current_user_id = data['user_id'] + except Exception as e: + return jsonify({'message': 'Token is invalid or expired!'}), 401 + + return f(current_user_id, *args, **kwargs) + return decorated diff --git a/backend/requirements.txt b/backend/requirements.txt index 83657de..e49e202 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,7 @@ python-dotenv==1.0.1 psycopg2-binary==2.9.9 Werkzeug==3.0.3 requests==2.31.0 +PyJWT==2.8.0 +bcrypt==4.1.3 +reportlab==4.1.0 +pandas==2.2.1 diff --git a/backend/routes/auth.py b/backend/routes/auth.py new file mode 100644 index 0000000..4eeac66 --- /dev/null +++ b/backend/routes/auth.py @@ -0,0 +1,88 @@ +# backend/routes/auth.py +from flask import Blueprint, request, jsonify +from datetime import datetime, timedelta +import bcrypt +import jwt + +from ..db import get_db_connection, release_db_connection +from ..middleware import SECRET_KEY, token_required + +auth_blueprint = Blueprint('auth', __name__) + +@auth_blueprint.route('/auth/register', methods=['POST']) +def register(): + data = request.json + username = data.get('username') + password = data.get('password') + + if not username or not password: + return jsonify({"error": "Username and password are required"}), 400 + + conn = get_db_connection() + cur = conn.cursor() + + # Check if exists + cur.execute("SELECT id FROM users WHERE username = %s", (username,)) + if cur.fetchone(): + release_db_connection(conn) + return jsonify({"error": "Username already exists"}), 400 + + hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + + cur.execute( + "INSERT INTO users (username, password_hash) VALUES (%s, %s) RETURNING id", + (username, hashed_password) + ) + user_id = cur.fetchone()[0] + conn.commit() + release_db_connection(conn) + + return jsonify({"message": "User registered successfully", "user_id": user_id}), 201 + +@auth_blueprint.route('/auth/login', methods=['POST']) +def login(): + data = request.json + username = data.get('username') + password = data.get('password') + + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT id, password_hash FROM users WHERE username = %s", (username,)) + user = cur.fetchone() + release_db_connection(conn) + + if not user or not bcrypt.checkpw(password.encode('utf-8'), user[1].encode('utf-8')): + return jsonify({"error": "Invalid credentials"}), 401 + + token = jwt.encode({ + 'user_id': user[0], + 'exp': datetime.utcnow() + timedelta(days=7) + }, SECRET_KEY, algorithm="HS256") + + return jsonify({"token": token}) + +@auth_blueprint.route('/auth/me', methods=['GET']) +@token_required +def get_me(current_user_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute("SELECT id, username, plan FROM users WHERE id = %s", (current_user_id,)) + user = cur.fetchone() + release_db_connection(conn) + + if not user: + return jsonify({"error": "User not found"}), 404 + + return jsonify({"id": user[0], "username": user[1], "plan": user[2]}) + +@auth_blueprint.route('/auth/upgrade', methods=['POST']) +@token_required +def upgrade_plan(current_user_id): + """Simulates payment success and upgrades to PRO.""" + conn = get_db_connection() + cur = conn.cursor() + cur.execute("UPDATE users SET plan = 'pro' WHERE id = %s", (current_user_id,)) + conn.commit() + release_db_connection(conn) + + return jsonify({"message": "Successfully upgraded to PRO"}), 200 diff --git a/backend/routes/cases.py b/backend/routes/cases.py index 84359f9..8e5b25b 100644 --- a/backend/routes/cases.py +++ b/backend/routes/cases.py @@ -5,6 +5,7 @@ from werkzeug.utils import secure_filename from datetime import datetime from ..db import get_db_connection, release_db_connection +from ..middleware import token_required cases_blueprint = Blueprint('cases', __name__) @@ -24,7 +25,8 @@ def serialize_case(row): # ---------- CASE CRUD ---------- @cases_blueprint.route('/cases', methods=['GET']) -def list_cases(): +@token_required +def list_cases(current_user_id): # optional filters: ?article=...&status=...&platform=... article = request.args.get('article') status = request.args.get('status') @@ -32,8 +34,8 @@ def list_cases(): conn = get_db_connection() cur = conn.cursor() query = "SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases" - params = [] - conditions = [] + params = [current_user_id] + conditions = ["user_id = %s"] if article: conditions.append("violator_article = %s") params.append(article) @@ -52,7 +54,8 @@ def list_cases(): return jsonify([serialize_case(r) for r in rows]) @cases_blueprint.route('/cases', methods=['POST']) -def create_case(): +@token_required +def create_case(current_user_id): data = request.json required = ["platform", "violator_url", "violator_article"] for f in required: @@ -61,9 +64,10 @@ def create_case(): conn = get_db_connection() cur = conn.cursor() cur.execute( - """INSERT INTO cases (platform, violator_url, violator_article, my_article, comment, status) - VALUES (%s, %s, %s, %s, %s, %s) RETURNING id""", + """INSERT INTO cases (user_id, platform, violator_url, violator_article, my_article, comment, status) + VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id""", ( + current_user_id, data.get('platform'), data.get('violator_url'), data.get('violator_article'), @@ -78,10 +82,11 @@ def create_case(): return jsonify({"id": case_id}), 201 @cases_blueprint.route('/cases/', methods=['GET']) -def get_case(case_id): +@token_required +def get_case(current_user_id, case_id): conn = get_db_connection() cur = conn.cursor() - cur.execute("SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases WHERE id = %s", (case_id,)) + cur.execute("SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at, updated_at FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id)) row = cur.fetchone() release_db_connection(conn) if not row: @@ -89,7 +94,8 @@ def get_case(case_id): return jsonify(serialize_case(row)) @cases_blueprint.route('/cases/', methods=['PUT']) -def update_case(case_id): +@token_required +def update_case(current_user_id, case_id): data = request.json fields = [] params = [] @@ -100,27 +106,29 @@ def update_case(case_id): params.append(data[f]) if not fields: return jsonify({"error": "No updatable fields provided"}), 400 - params.append(case_id) + params.extend([case_id, current_user_id]) conn = get_db_connection() cur = conn.cursor() - cur.execute(f"UPDATE cases SET {', '.join(fields)}, updated_at = %s WHERE id = %s", - (*params[:-1], datetime.utcnow(), case_id)) + cur.execute(f"UPDATE cases SET {', '.join(fields)}, updated_at = %s WHERE id = %s AND user_id = %s", + (*params[:-2], datetime.utcnow(), case_id, current_user_id)) conn.commit() release_db_connection(conn) return jsonify({"message": "Case updated"}) @cases_blueprint.route('/cases/', methods=['DELETE']) -def delete_case(case_id): +@token_required +def delete_case(current_user_id, case_id): conn = get_db_connection() cur = conn.cursor() - cur.execute("DELETE FROM cases WHERE id = %s", (case_id,)) + cur.execute("DELETE FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id)) conn.commit() release_db_connection(conn) return jsonify({"message": "Case deleted"}) # ---------- FILE UPLOAD ---------- @cases_blueprint.route('/cases//files', methods=['POST']) -def upload_files(case_id): +@token_required +def upload_files(current_user_id, case_id): if 'files' not in request.files: return jsonify({"error": "No files part in request"}), 400 files = request.files.getlist('files') @@ -128,6 +136,13 @@ def upload_files(case_id): upload_folder = current_app.config['UPLOAD_FOLDER'] conn = get_db_connection() cur = conn.cursor() + + # Check ownership + cur.execute("SELECT id FROM cases WHERE id = %s AND user_id = %s", (case_id, current_user_id)) + if not cur.fetchone(): + release_db_connection(conn) + return jsonify({"error": "Case not found"}), 404 + for f in files: if f.filename == '': continue @@ -149,10 +164,11 @@ def upload_files(case_id): # Get files for a case @cases_blueprint.route('/cases//files', methods=['GET']) -def list_files(case_id): +@token_required +def list_files(current_user_id, case_id): conn = get_db_connection() cur = conn.cursor() - cur.execute("SELECT id, file_path, uploaded_at FROM files WHERE case_id = %s", (case_id,)) + cur.execute("SELECT f.id, f.file_path, f.uploaded_at FROM files f JOIN cases c ON f.case_id = c.id WHERE f.case_id = %s AND c.user_id = %s", (case_id, current_user_id)) rows = cur.fetchall() release_db_connection(conn) files = [{"id": r[0], "path": r[1], "uploaded_at": r[2].isoformat()} for r in rows] diff --git a/backend/routes/credentials.py b/backend/routes/credentials.py index 06b4cfe..4e56d92 100644 --- a/backend/routes/credentials.py +++ b/backend/routes/credentials.py @@ -4,15 +4,17 @@ from datetime import datetime import json from ..db import get_db_connection, release_db_connection +from ..middleware import token_required credentials_blueprint = Blueprint('credentials', __name__) @credentials_blueprint.route('/credentials', methods=['GET']) -def list_credentials(): +@token_required +def list_credentials(current_user_id): """Return configured platforms (without exposing secrets).""" conn = get_db_connection() cur = conn.cursor() - cur.execute("SELECT platform FROM credentials") + cur.execute("SELECT platform FROM credentials WHERE user_id = %s", (current_user_id,)) rows = cur.fetchall() release_db_connection(conn) @@ -20,7 +22,8 @@ def list_credentials(): return jsonify({"configured_platforms": platforms}) @credentials_blueprint.route('/credentials/', methods=['POST']) -def save_credentials(platform): +@token_required +def save_credentials(current_user_id, platform): """Save or update credentials for a specific platform.""" data = request.json api_key = data.get('api_key') @@ -31,16 +34,29 @@ def save_credentials(platform): conn = get_db_connection() cur = conn.cursor() + # --- PLAN LIMIT CHECK --- + cur.execute("SELECT plan FROM users WHERE id = %s", (current_user_id,)) + user_plan = cur.fetchone()[0] + + if user_plan == 'free': + # Count existing configured platforms for this user + cur.execute("SELECT COUNT(*) FROM credentials WHERE user_id = %s AND platform != %s", (current_user_id, platform)) + count = cur.fetchone()[0] + if count >= 1: + release_db_connection(conn) + return jsonify({"error": "Лимит бесплатного тарифа исчерпан (максимум 1 интеграция). Перейдите на PRO."}), 403 + # ------------------------ + cur.execute( - """INSERT INTO credentials (platform, api_key, client_id, client_secret, other_data, updated_at) - VALUES (%s, %s, %s, %s, %s, %s) - ON CONFLICT (platform) DO UPDATE SET + """INSERT INTO credentials (user_id, platform, api_key, client_id, client_secret, other_data, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s) + ON CONFLICT (user_id, platform) DO UPDATE SET api_key = EXCLUDED.api_key, client_id = EXCLUDED.client_id, client_secret = EXCLUDED.client_secret, other_data = EXCLUDED.other_data, updated_at = EXCLUDED.updated_at""", - (platform, api_key, client_id, client_secret, json.dumps(other_data), datetime.utcnow()) + (current_user_id, platform, api_key, client_id, client_secret, json.dumps(other_data), datetime.utcnow()) ) conn.commit() release_db_connection(conn) @@ -48,11 +64,12 @@ def save_credentials(platform): return jsonify({"message": f"Credentials for {platform} saved successfully."}), 200 @credentials_blueprint.route('/credentials/', methods=['DELETE']) -def delete_credentials(platform): +@token_required +def delete_credentials(current_user_id, platform): """Delete credentials for a specific platform.""" conn = get_db_connection() cur = conn.cursor() - cur.execute("DELETE FROM credentials WHERE platform = %s", (platform,)) + cur.execute("DELETE FROM credentials WHERE user_id = %s AND platform = %s", (current_user_id, platform)) conn.commit() release_db_connection(conn) diff --git a/backend/routes/dashboard.py b/backend/routes/dashboard.py new file mode 100644 index 0000000..f2b9409 --- /dev/null +++ b/backend/routes/dashboard.py @@ -0,0 +1,51 @@ +from flask import Blueprint, jsonify +from ..db import get_db_connection, release_db_connection +from ..middleware import token_required + +dashboard_blueprint = Blueprint('dashboard', __name__) + +@dashboard_blueprint.route('/dashboard/stats', methods=['GET']) +@token_required +def get_stats(current_user_id): + conn = get_db_connection() + cur = conn.cursor() + + # 1. Total cases + cur.execute("SELECT COUNT(*) FROM cases WHERE user_id = %s", (current_user_id,)) + total_cases = cur.fetchone()[0] + + # 2. Cases by status + cur.execute("SELECT status, COUNT(*) FROM cases WHERE user_id = %s GROUP BY status", (current_user_id,)) + status_rows = cur.fetchall() + cases_by_status = {row[0]: row[1] for row in status_rows} + + # 3. Total active integrations + cur.execute("SELECT COUNT(*) FROM credentials WHERE user_id = %s", (current_user_id,)) + total_integrations = cur.fetchone()[0] + + # 4. List of connected platforms + cur.execute("SELECT platform FROM credentials WHERE user_id = %s", (current_user_id,)) + platforms = [row[0] for row in cur.fetchall()] + + # 5. Recent activity (last 5 cases) + cur.execute("SELECT id, platform, violator_article, status, created_at FROM cases WHERE user_id = %s ORDER BY created_at DESC LIMIT 5", (current_user_id,)) + recent_rows = cur.fetchall() + recent_activity = [ + { + "id": r[0], + "platform": r[1], + "article": r[2], + "status": r[3], + "date": r[4].isoformat() if r[4] else None + } for r in recent_rows + ] + + release_db_connection(conn) + + return jsonify({ + "total_cases": total_cases, + "cases_by_status": cases_by_status, + "total_integrations": total_integrations, + "platforms": platforms, + "recent_activity": recent_activity + }) diff --git a/backend/routes/reports.py b/backend/routes/reports.py new file mode 100644 index 0000000..d5bc6b5 --- /dev/null +++ b/backend/routes/reports.py @@ -0,0 +1,96 @@ +import io +import pandas as pd +from flask import Blueprint, send_file, jsonify, make_response +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle + +from ..db import get_db_connection, release_db_connection +from ..middleware import token_required + +reports_blueprint = Blueprint('reports', __name__) + +@reports_blueprint.route('/reports/cases/csv', methods=['GET']) +@token_required +def export_cases_csv(current_user_id): + conn = get_db_connection() + query = """ + SELECT id, platform, violator_url, violator_article, my_article, comment, status, created_at + FROM cases + WHERE user_id = %s + """ + df = pd.read_sql(query, conn, params=(current_user_id,)) + release_db_connection(conn) + + output = io.BytesIO() + df.to_csv(output, index=False, encoding='utf-8-sig') + output.seek(0) + + return send_file( + output, + mimetype='text/csv', + as_attachment=True, + download_name=f'cases_export_{current_user_id}.csv' + ) + +@reports_blueprint.route('/reports/case//pdf', methods=['GET']) +@token_required +def export_case_pdf(current_user_id, case_id): + conn = get_db_connection() + cur = conn.cursor() + cur.execute(""" + SELECT platform, violator_url, violator_article, my_article, comment, status, created_at + FROM cases + WHERE id = %s AND user_id = %s + """, (case_id, current_user_id)) + row = cur.fetchone() + release_db_connection(conn) + + if not row: + return jsonify({"error": "Case not found"}), 404 + + platform, v_url, v_art, my_art, comment, status, created_at = row + + buffer = io.BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=letter) + styles = getSampleStyleSheet() + elements = [] + + # Title + elements.append(Paragraph(f"Официальная претензия #{case_id}", styles['Title'])) + elements.append(Spacer(1, 12)) + + # Info table + data = [ + ["Маркетплейс", platform.upper()], + ["Дата создания", created_at.strftime('%Y-%m-%d %H:%M') if created_at else "-"], + ["Артикул нарушителя", v_art], + ["Ссылка на товар", Paragraph(v_url, styles['Normal'])], + ["Ваш артикул", my_art or "-"], + ["Текущий статус", status.upper()] + ] + + t = Table(data, colWidths=[150, 350]) + t.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (0, -1), colors.lightgrey), + ('GRID', (0, 0), (-1, -1), 1, colors.black), + ('PADDING', (0, 0), (-1, -1), 6), + ])) + elements.append(t) + elements.append(Spacer(1, 24)) + + # Description + elements.append(Paragraph("Описание нарушения:", styles['Heading2'])) + elements.append(Paragraph(comment or "Комментарий отсутствует", styles['Normal'])) + + doc.build(elements) + buffer.seek(0) + + return send_file( + buffer, + mimetype='application/pdf', + as_attachment=True, + download_name=f'complaint_{case_id}.pdf' + ) diff --git a/db/init.sql b/db/init.sql index a2d2958..f74ed51 100644 --- a/db/init.sql +++ b/db/init.sql @@ -1,17 +1,28 @@ -- db/init.sql --- Создаёт таблицы для хранения ключей маркетплейсов, кейсов и файлов +-- Создаёт таблицы для хранения пользователей, ключей маркетплейсов, кейсов и файлов + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + plan VARCHAR(20) DEFAULT 'free', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); CREATE TABLE IF NOT EXISTS credentials ( - platform VARCHAR(50) PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, + platform VARCHAR(50), api_key TEXT, client_id TEXT, client_secret TEXT, other_data JSONB, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, platform) ); CREATE TABLE IF NOT EXISTS cases ( id SERIAL PRIMARY KEY, + user_id INT REFERENCES users(id) ON DELETE CASCADE, platform VARCHAR(50) NOT NULL DEFAULT 'ozon', violator_url TEXT NOT NULL, violator_article VARCHAR(50) NOT NULL, diff --git a/frontend/css/styles.css b/frontend/css/styles.css index b4f7e9f..6f5309c 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -241,3 +241,31 @@ label textarea { background: rgba(0,0,0,0.2); color: #fff; } + +.auth-box { + max-width: 400px; + margin: 4rem auto; + padding: 2rem; + background: var(--color-surface); + border-radius: var(--radius); + border: 1px solid hsla(0,0%,100%,0.1); + box-shadow: var(--shadow); +} + +.auth-box input { + width: 100%; + padding: 0.75rem; + margin-top: 0.5rem; + border: 1px solid hsla(0,0%,100%,0.1); + border-radius: var(--radius); + background: rgba(0,0,0,0.2); + color: #fff; +} + +.auth-box a { + color: var(--color-primary); + text-decoration: none; +} +.auth-box a:hover { + text-decoration: underline; +} diff --git a/frontend/index.html b/frontend/index.html index 41bc018..408349f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -9,17 +9,92 @@
-

AutoMarket

-

Управление жалобами и интеграциями для 10 маркетплейсов

+
+
+

AutoMarket

+

Управление жалобами и интеграциями для 10 маркетплейсов

+
+ +
-
- - -
+ +
+
+

Вход в систему

+
+ + + +
+

+ Нет аккаунта? Зарегистрироваться +

+
+
-
+ @@ -119,11 +195,15 @@ - + + diff --git a/frontend/js/api.js b/frontend/js/api.js index 1fda392..f1ea031 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -1,39 +1,101 @@ // frontend/js/api.js -// Base URL for backend API (adjust if using different host/port) const API_BASE = "http://localhost:5000/api"; +function getHeaders() { + const token = localStorage.getItem('token'); + const headers = { "Content-Type": "application/json" }; + if (token) headers["Authorization"] = `Bearer ${token}`; + return headers; +} + +async function handleResponse(res) { + if (res.status === 401) { + localStorage.removeItem('token'); + window.location.reload(); + } + const data = await res.json(); + if (!res.ok) throw new Error(data.error || data.message || 'API Error'); + return data; +} + +export async function login(username, password) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + return await handleResponse(res); +} + +export async function register(username, password) { + const res = await fetch(`${API_BASE}/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + return await handleResponse(res); +} + +export async function getMe() { + const res = await fetch(`${API_BASE}/auth/me`, { headers: getHeaders() }); + return await handleResponse(res); +} + +export async function upgradePlan() { + const res = await fetch(`${API_BASE}/auth/upgrade`, { + method: "POST", + headers: getHeaders() + }); + return await handleResponse(res); +} + +export async function getDashboardStats() { + const res = await fetch(`${API_BASE}/dashboard/stats`, { headers: getHeaders() }); + return await handleResponse(res); +} + +export function getCaseReportPdfUrl(caseId) { + const token = localStorage.getItem('token'); + return `${API_BASE}/reports/case/${caseId}/pdf?token=${token}`; +} + +export function getExportCsvUrl() { + const token = localStorage.getItem('token'); + return `${API_BASE}/reports/cases/csv?token=${token}`; +} + export async function fetchCases(params = {}) { const query = new URLSearchParams(params).toString(); - const res = await fetch(`${API_BASE}/cases${query ? `?${query}` : ''}`); - return await res.json(); + const res = await fetch(`${API_BASE}/cases${query ? `?${query}` : ''}`, { headers: getHeaders() }); + return await handleResponse(res); } export async function createCase(data) { const res = await fetch(`${API_BASE}/cases`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getHeaders(), body: JSON.stringify(data), }); - return await res.json(); + return await handleResponse(res); } export async function getCase(id) { - const res = await fetch(`${API_BASE}/cases/${id}`); - return await res.json(); + const res = await fetch(`${API_BASE}/cases/${id}`, { headers: getHeaders() }); + return await handleResponse(res); } export async function updateCase(id, data) { const res = await fetch(`${API_BASE}/cases/${id}`, { method: "PUT", - headers: { "Content-Type": "application/json" }, + headers: getHeaders(), body: JSON.stringify(data), }); - return await res.json(); + return await handleResponse(res); } export async function deleteCase(id) { - const res = await fetch(`${API_BASE}/cases/${id}`, { method: "DELETE" }); - return await res.json(); + const res = await fetch(`${API_BASE}/cases/${id}`, { method: "DELETE", headers: getHeaders() }); + return await handleResponse(res); } export async function uploadFiles(caseId, fileList) { @@ -41,29 +103,32 @@ export async function uploadFiles(caseId, fileList) { for (const f of fileList) { form.append("files", f); } + const headers = getHeaders(); + delete headers["Content-Type"]; // let browser set boundary const res = await fetch(`${API_BASE}/cases/${caseId}/files`, { method: "POST", + headers: headers, body: form, }); - return await res.json(); + return await handleResponse(res); } export async function listFiles(caseId) { - const res = await fetch(`${API_BASE}/cases/${caseId}/files`); - return await res.json(); + const res = await fetch(`${API_BASE}/cases/${caseId}/files`, { headers: getHeaders() }); + return await handleResponse(res); } export async function getConfiguredPlatforms() { - const res = await fetch(`${API_BASE}/credentials`); - const data = await res.json(); + const res = await fetch(`${API_BASE}/credentials`, { headers: getHeaders() }); + const data = await handleResponse(res); return data.configured_platforms || []; } export async function saveCredentials(platform, data) { const res = await fetch(`${API_BASE}/credentials/${platform}`, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: getHeaders(), body: JSON.stringify(data), }); - return await res.json(); + return await handleResponse(res); } diff --git a/frontend/js/app.js b/frontend/js/app.js index 89259a8..891619e 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -14,15 +14,156 @@ const PLATFORMS = [ { id: 'flowwow', name: 'Flowwow' } ]; -document.addEventListener("DOMContentLoaded", () => { +let currentUser = null; + +document.addEventListener("DOMContentLoaded", async () => { + initAuth(); + initReports(); + + // Check if logged in + const token = localStorage.getItem('token'); + if (token) { + try { + currentUser = await api.getMe(); + const planBadge = currentUser.plan === 'pro' ? '[PRO]' : '[FREE]'; + document.getElementById('username-display').innerHTML = `Привет, ${currentUser.username} ${planBadge}`; + showApp(); + } catch (e) { + showAuth(); + } + } else { + showAuth(); + } +}); + +let isLoginMode = true; +function initAuth() { + const authForm = document.getElementById('auth-form'); + const toggleBtn = document.getElementById('auth-toggle'); + const title = document.getElementById('auth-title'); + const logoutBtn = document.getElementById('logout-btn'); + + toggleBtn.addEventListener('click', (e) => { + e.preventDefault(); + isLoginMode = !isLoginMode; + title.textContent = isLoginMode ? 'Вход в систему' : 'Регистрация'; + toggleBtn.textContent = isLoginMode ? 'Нет аккаунта? Зарегистрироваться' : 'Уже есть аккаунт? Войти'; + }); + + authForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('auth-username').value; + const password = document.getElementById('auth-password').value; + try { + if (isLoginMode) { + const res = await api.login(username, password); + localStorage.setItem('token', res.token); + } else { + await api.register(username, password); + alert('Регистрация успешна! Теперь вы можете войти.'); + isLoginMode = true; + title.textContent = 'Вход в систему'; + toggleBtn.textContent = 'Нет аккаунта? Зарегистрироваться'; + return; + } + window.location.reload(); + } catch (err) { + alert(err.message); + } + }); + + logoutBtn.addEventListener('click', () => { + localStorage.removeItem('token'); + window.location.reload(); + }); +} + +function showAuth() { + document.getElementById('auth-section').classList.remove('hidden'); + document.getElementById('main-app').classList.add('hidden'); + document.getElementById('user-info').classList.add('hidden'); +} + +function showApp() { + document.getElementById('auth-section').classList.add('hidden'); + document.getElementById('main-app').classList.remove('hidden'); + document.getElementById('user-info').classList.remove('hidden'); initTabs(); initCases(); initIntegrations(); -}); + initBilling(); + initDashboard(); +} + +function initDashboard() { + loadDashboard(); +} + +async function loadDashboard() { + try { + const stats = await api.getDashboardStats(); + const grid = document.getElementById('stats-grid'); + grid.innerHTML = ` +
+

Всего кейсов

+

${stats.total_cases}

+
+
+

Активных интеграций

+

${stats.total_integrations}

+

${stats.platforms.join(', ').toUpperCase()}

+
+
+

В работе (Prepared/Sent)

+

+ ${(stats.cases_by_status.prepared || 0) + (stats.cases_by_status.sent || 0)} +

+
+ `; + + const tbody = document.querySelector('#recent-activity-table tbody'); + tbody.innerHTML = ''; + stats.recent_activity.forEach(a => { + const tr = document.createElement('tr'); + const pName = PLATFORMS.find(p => p.id === a.platform)?.name || a.platform; + tr.innerHTML = ` + + + + + + `; + tbody.appendChild(tr); + }); + } catch (e) { + console.error(e); + } +} + +function initBilling() { + document.getElementById('current-plan-display').textContent = currentUser.plan.toUpperCase(); + + document.getElementById('upgrade-btn').addEventListener('click', async () => { + if(currentUser.plan === 'pro') { + alert('У вас уже тариф PRO!'); + return; + } + + if(confirm('Перейти на PRO тариф (симуляция оплаты)?')) { + try { + await api.upgradePlan(); + alert('Оплата прошла успешно! Вы теперь PRO.'); + window.location.reload(); + } catch (err) { + alert(err.message); + } + } + }); +} function initTabs() { const btns = document.querySelectorAll('.tab-btn'); - const sections = ['cases-section', 'integrations-section']; + const sections = ['dashboard-section', 'cases-section', 'integrations-section', 'billing-section']; btns.forEach(btn => { btn.addEventListener('click', () => { @@ -31,12 +172,14 @@ function initTabs() { const target = btn.dataset.target; sections.forEach(s => { - document.getElementById(s).classList.add('hidden'); + const el = document.getElementById(s); + if (el) el.classList.add('hidden'); }); document.getElementById(target).classList.remove('hidden'); document.getElementById('case-form-section').classList.add('hidden'); document.getElementById('case-detail-section').classList.add('hidden'); + if(target === 'dashboard-section') loadDashboard(); if(target === 'cases-section') loadCases(); if(target === 'integrations-section') loadIntegrations(); }); @@ -71,9 +214,13 @@ async function loadIntegrations() { e.preventDefault(); const formData = new FormData(e.target); const data = Object.fromEntries(formData); - await api.saveCredentials(p.id, data); - alert(`Настройки для ${p.name} сохранены!`); - loadIntegrations(); + try { + await api.saveCredentials(p.id, data); + alert(`Настройки для ${p.name} сохранены!`); + loadIntegrations(); + } catch(err) { + alert(err.message); + } }); grid.appendChild(card); @@ -130,9 +277,22 @@ async function initCases() { } }); + document.getElementById('export-csv-btn').addEventListener('click', () => { + window.location.href = api.getExportCsvUrl(); + }); + loadCases(); } +let currentViewCaseId = null; +async function initReports() { + document.getElementById('download-pdf-btn').addEventListener('click', () => { + if (currentViewCaseId) { + window.location.href = api.getCaseReportPdfUrl(currentViewCaseId); + } + }); +} + async function loadCases() { const article = document.getElementById('search-input').value; const status = document.getElementById('status-filter').value; @@ -171,6 +331,7 @@ async function loadCases() { } window.viewCase = async function(id) { + currentViewCaseId = id; document.getElementById('cases-section').classList.add('hidden'); const detailSection = document.getElementById('case-detail-section'); detailSection.classList.remove('hidden');
${a.id}${pName}${a.article}${a.status}${new Date(a.date).toLocaleDateString()}