diff --git a/Gradient-Backend/db.py b/Gradient-Backend/db.py index 9543ca0..c17ea3a 100644 --- a/Gradient-Backend/db.py +++ b/Gradient-Backend/db.py @@ -1,5 +1,6 @@ import duckdb from pathlib import Path +import threading BASE_DIR = Path(__file__).resolve().parent DB_PATH = BASE_DIR / "db" / "database.duckdb" @@ -8,6 +9,8 @@ conn = duckdb.connect(DB_PATH) +db_lock = threading.RLock() + def _ensure_column(table: str, column: str, definition: str) -> None: exists = conn.execute( """ @@ -28,10 +31,13 @@ def init_db(): username TEXT UNIQUE NOT NULL, email TEXT NOT NULL, password TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'manager' CHECK (role IN ('admin', 'manager')) + role TEXT NOT NULL DEFAULT 'manager' CHECK (role IN ('admin', 'manager')), + is_active BOOLEAN NOT NULL DEFAULT TRUE ) """) + _ensure_column("users", "is_active", "BOOLEAN DEFAULT TRUE") + conn.execute(""" CREATE TABLE IF NOT EXISTS processed_emails ( gmail_id TEXT PRIMARY KEY, @@ -95,6 +101,8 @@ def init_db(): ) """) + _ensure_column("lead_status_history", "rejection_reason", "TEXT") + conn.execute(""" CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, diff --git a/Gradient-Backend/fix_admin.py b/Gradient-Backend/fix_admin.py deleted file mode 100644 index 9a57d5c..0000000 --- a/Gradient-Backend/fix_admin.py +++ /dev/null @@ -1,33 +0,0 @@ -import duckdb -from pathlib import Path - -BASE_DIR = Path(__file__).resolve().parent -DB_PATH = BASE_DIR / "db" / "database.duckdb" - -conn = duckdb.connect(DB_PATH) - -# Check if role column exists -try: - conn.execute("SELECT role FROM users LIMIT 1") - print("Column 'role' already exists") -except: - print("Adding 'role' column...") - conn.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'manager'") - print("Column added") - -# Update admin role -conn.execute("UPDATE users SET role = 'admin' WHERE username = 'admin'") -print("Admin role set to 'admin'") - -# Delete duplicate admin -conn.execute("DELETE FROM users WHERE id = 5") -print("Duplicate admin deleted") - -# Show all users -rows = conn.execute("SELECT id, username, email, role FROM users").fetchall() -print("\nAll users:") -for r in rows: - print(f" {r}") - -conn.close() -print("\nDone!") diff --git a/Gradient-Backend/fix_admin.sql b/Gradient-Backend/fix_admin.sql deleted file mode 100644 index bda60ab..0000000 --- a/Gradient-Backend/fix_admin.sql +++ /dev/null @@ -1,11 +0,0 @@ --- Додати колонку role якщо її немає -ALTER TABLE users ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'manager'; - --- Перевірити що admin має правильну роль -UPDATE users SET role = 'admin' WHERE username = 'admin'; - --- Видалити дублікат admin@example.com (рядок 3) -DELETE FROM users WHERE id = 5; - --- Перевірити результат -SELECT id, username, email, role FROM users; diff --git a/Gradient-Backend/main.py b/Gradient-Backend/main.py index 46eb0c9..8d224f3 100644 --- a/Gradient-Backend/main.py +++ b/Gradient-Backend/main.py @@ -3,6 +3,7 @@ from routes.userRoutes import router as user_router from routes.gmailRoutes import router as gmail_router from routes.settingsRoutes import router as settings_router +from routes.managerRoutes import router as manager_router from routes import emailRoutes from routes.leadRoutes import router as lead_router from service.autosyncService import auto_sync_loop @@ -12,7 +13,7 @@ app.add_middleware( CORSMiddleware, - allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000", "http://localhost:3001", "http://127.0.0.1:3001"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -21,6 +22,7 @@ app.include_router(user_router) app.include_router(gmail_router) app.include_router(settings_router) +app.include_router(manager_router) app.include_router(lead_router) app.include_router(emailRoutes.router) diff --git a/Gradient-Backend/migrate_database.py b/Gradient-Backend/migrate_database.py deleted file mode 100644 index 8bdd43f..0000000 --- a/Gradient-Backend/migrate_database.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Script to migrate the existing database to support lead assignment -""" - -import sys -import os - -# Add the current directory to Python path -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) - -from db import conn - -def migrate_database(): - print("Migrating database schema...") - - try: - # Check if role column exists in users table - check_role = conn.execute(""" - SELECT COUNT(*) as count FROM pragma_table_info('users') WHERE name = 'role' - """).fetchone() - - if check_role[0] == 0: - print("Adding 'role' column to users table...") - conn.execute(""" - ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'manager' - """) - - # Add check constraint - conn.execute(""" - ALTER TABLE users ADD CONSTRAINT check_role CHECK (role IN ('admin', 'manager')) - """) - - # Update existing users to be managers by default - conn.execute(""" - UPDATE users SET role = 'manager' WHERE role IS NULL OR role = '' - """) - - print("✓ Added role column to users table") - else: - print("✓ Role column already exists in users table") - - # Check if assigned_to column exists in gmail_messages table - check_assigned_to = conn.execute(""" - SELECT COUNT(*) as count FROM pragma_table_info('gmail_messages') WHERE name = 'assigned_to' - """).fetchone() - - if check_assigned_to[0] == 0: - print("Adding lead assignment columns to gmail_messages table...") - conn.execute(""" - ALTER TABLE gmail_messages ADD COLUMN assigned_to INTEGER - """) - conn.execute(""" - ALTER TABLE gmail_messages ADD COLUMN assigned_at TIMESTAMP - """) - print("✓ Added assignment columns to gmail_messages table") - else: - print("✓ Assignment columns already exist in gmail_messages table") - - conn.commit() - print("\n✅ Database migration completed successfully!") - - # Show current schema - print("\nCurrent users table schema:") - users_schema = conn.execute("PRAGMA table_info(users)").fetchall() - for col in users_schema: - print(f" {col[1]} {col[2]} {'NOT NULL' if col[3] else ''} {'DEFAULT ' + str(col[4]) if col[4] is not None else ''}") - - print("\nCurrent gmail_messages table schema (relevant columns):") - messages_schema = conn.execute("PRAGMA table_info(gmail_messages)").fetchall() - for col in messages_schema: - if col[1] in ['gmail_id', 'assigned_to', 'assigned_at', 'created_at']: - print(f" {col[1]} {col[2]} {'NOT NULL' if col[3] else ''} {'DEFAULT ' + str(col[4]) if col[4] is not None else ''}") - - except Exception as e: - print(f"❌ Migration failed: {e}") - conn.rollback() - raise - -if __name__ == "__main__": - migrate_database() diff --git a/Gradient-Backend/routes/gmailRoutes.py b/Gradient-Backend/routes/gmailRoutes.py index 500f9de..1333b20 100644 --- a/Gradient-Backend/routes/gmailRoutes.py +++ b/Gradient-Backend/routes/gmailRoutes.py @@ -1,9 +1,11 @@ from fastapi import APIRouter, Query, HTTPException, Depends, Security from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel, EmailStr, Field +from datetime import datetime +from db import conn from service.syncService import sync_gmail_to_sheets -from service.sheetService import build_leads_payload, build_leads_payload_from_db +from service.sheetService import build_leads_payload, build_leads_payload_from_db, update_lead_status, update_lead_status_gmail_id from service.aiService import analyze_email, generate_email_replies from service.settingsService import get_reply_prompts from service.leadService import get_current_user_role @@ -31,13 +33,21 @@ def get_leads( range_days: int | None = Query(default=None, ge=1, le=3650), user_info: dict | None = Depends(get_user_from_token) ): - if user_info: - # Use role-based filtering from database - payload = build_leads_payload_from_db(limit, user_info, range_days=range_days) - else: - # Fallback to original sheet-based approach - payload = build_leads_payload(limit) - return payload + print(f"[DEBUG] get_leads called, user_info: {user_info}") + try: + if user_info: + # Use role-based filtering from database + payload = build_leads_payload_from_db(limit, user_info, range_days=range_days) + else: + # Fallback to original sheet-based approach + payload = build_leads_payload(limit) + print(f"[DEBUG] Returning payload with {len(payload.get('leads', []))} leads, stats: {payload.get('stats')}") + return payload + except Exception as e: + import traceback + print(f"[ERROR] get_leads failed: {e}") + print(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) class LeadInsightRequest(BaseModel): @@ -91,6 +101,10 @@ def generate_replies(payload: ReplyGenerationRequest): } +# Unified status system - supporting both old and new status values +VALID_STATUSES = {'NEW', 'ASSIGNED', 'EMAIL_SENT', 'WAITING_REPLY', 'REPLY_READY', 'CLOSED', 'LOST', 'SNOOZED', 'CONFIRMED', 'REJECTED'} +ALLOWED_STATUS_VALUES = {"confirmed", "rejected", "snoozed", "waiting", "new"} + class LeadStatusUpdateRequest(BaseModel): row_number: int | None = Field(gt=0, default=None) gmail_id: str | None = None @@ -98,27 +112,179 @@ class LeadStatusUpdateRequest(BaseModel): rejection_reason: str | None = None +def add_status_history(gmail_id: str, status: str, assignee: str | None = None, rejection_reason: str | None = None): + """Add entry to lead status history""" + import uuid + history_id = str(uuid.uuid4()) + + # Get lead info for the name + lead = conn.execute( + "SELECT full_name, email FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ).fetchone() + lead_name = lead[0] if lead else None + + conn.execute( + """ + INSERT INTO lead_status_history (id, gmail_id, status, assignee, lead_name, rejection_reason, changed_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + [history_id, gmail_id, status, assignee, lead_name, rejection_reason, datetime.now()] + ) + conn.commit() + + @router.post("/lead-status") -def set_lead_status(payload: LeadStatusUpdateRequest): +def set_lead_status(payload: LeadStatusUpdateRequest, user_info: dict = Depends(get_user_from_token)): + """Update lead status and track in history - supports both row_number and gmail_id""" + status = payload.status.upper() + normalized_status = (payload.status or "").strip().lower() + + # Validate status against both old and new status systems + if status not in VALID_STATUSES and normalized_status not in ALLOWED_STATUS_VALUES: + raise HTTPException(status_code=400, detail=f"Invalid status. Valid statuses: {', '.join(VALID_STATUSES)}") + + # Use the uppercase version for storage + final_status = status if status in VALID_STATUSES else normalized_status.upper() + try: if payload.gmail_id: - update_lead_status_gmail_id( - gmail_id=payload.gmail_id, - status=payload.status, - rejection_reason=payload.rejection_reason + # Check if lead exists + lead = conn.execute( + "SELECT gmail_id, assigned_to FROM gmail_messages WHERE gmail_id = ?", + [payload.gmail_id] + ).fetchone() + + if not lead: + raise HTTPException(status_code=404, detail="Lead not found") + + # Update status in database + conn.execute( + "UPDATE gmail_messages SET status = ? WHERE gmail_id = ?", + [final_status, payload.gmail_id] ) + + # Add to history + assignee = user_info.get("username") if user_info else None + add_status_history(payload.gmail_id, final_status, assignee, payload.rejection_reason) + + conn.commit() + + return { + "gmail_id": payload.gmail_id, + "status": final_status, + "updated_by": assignee, + "rejection_reason": payload.rejection_reason + } + elif payload.row_number: + # Fallback to row_number-based update (for backwards compatibility) + update_lead_status(payload.row_number, final_status, payload.rejection_reason) + + assignee = user_info.get("username") if user_info else None + + return { + "row_number": payload.row_number, + "status": final_status, + "updated_by": assignee, + "rejection_reason": payload.rejection_reason + } else: - update_lead_status( - row_number=payload.row_number, - status=payload.status, - rejection_reason=payload.rejection_reason - ) + raise HTTPException(status_code=400, detail="Either gmail_id or row_number must be provided") except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc + +@router.get("/lead-profile") +def get_lead_profile(email: str = Query(...)): + """Get lead profile by email with all emails from this contact""" + # Get all emails from this contact + emails = conn.execute( + """ + SELECT + gmail_id, status, first_name, last_name, full_name, email, subject, + received_at, company, body, phone, website, company_name, company_info, + person_role, person_links, person_location, person_experience, person_summary, + person_insights, company_insights, assigned_to, assigned_at, created_at + FROM gmail_messages + WHERE email = ? + ORDER BY created_at DESC + """, + [email] + ).fetchall() + + if not emails: + raise HTTPException(status_code=404, detail="Lead not found") + + # Format emails + formatted_emails = [] + for mail in emails: + formatted_emails.append({ + "gmail_id": mail[0], + "status": mail[1] or "NEW", + "first_name": mail[2] or "", + "last_name": mail[3] or "", + "full_name": mail[4] or "", + "email": mail[5] or "", + "subject": mail[6] or "", + "received_at": mail[7] or "", + "company": mail[8] or "", + "body": mail[9] or "", + "phone": mail[10] or "", + "website": mail[11] or "", + "company_name": mail[12] or "", + "company_info": mail[13] or "", + "person_role": mail[14] or "", + "person_links": mail[15] or "", + "person_location": mail[16] or "", + "person_experience": mail[17] or "", + "person_summary": mail[18] or "", + "person_insights": mail[19] or [], + "company_insights": mail[20] or [], + "assigned_to": mail[21], + "assigned_at": mail[22], + "created_at": mail[23] + }) + + # Get latest email for profile info + latest = emails[0] + return { - "row_number": payload.row_number, - "gmail_id": payload.gmail_id, - "status": payload.status, - "rejection_reason": payload.rejection_reason + "id": latest[0], + "name": latest[4] or latest[5], + "email": latest[5], + "phone": latest[10] or "", + "company": latest[8] or latest[12] or "", + "role": latest[14] or "", + "status": latest[1] or "NEW", + "pending_review": False, + "is_priority": False, + "emails": formatted_emails } + + +@router.get("/status-history") +def get_status_history(gmail_id: str = Query(...)): + """Get status history for a lead""" + history = conn.execute( + """ + SELECT + id, gmail_id, changed_at, lead_name, status, assignee + FROM lead_status_history + WHERE gmail_id = ? + ORDER BY changed_at DESC + """, + [gmail_id] + ).fetchall() + + formatted_history = [] + for entry in history: + formatted_history.append({ + "id": entry[0], + "gmail_id": entry[1], + "changed_at": entry[2], + "lead_name": entry[3], + "status": entry[4], + "assignee": entry[5] + }) + + return {"history": formatted_history} diff --git a/Gradient-Backend/routes/leadRoutes.py b/Gradient-Backend/routes/leadRoutes.py index e9edd74..74196ae 100644 --- a/Gradient-Backend/routes/leadRoutes.py +++ b/Gradient-Backend/routes/leadRoutes.py @@ -2,7 +2,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from pydantic import BaseModel, Field from typing import Optional -from service.leadService import get_current_user_role, assign_lead_to_user, get_user_leads, get_available_leads, get_all_leads_for_admin, get_assigned_leads_only +from service.leadService import get_current_user_role, assign_lead_to_user, get_user_leads, get_available_leads, get_all_leads_for_admin, get_assigned_leads_only, delete_lead_by_gmail_id from db import conn router = APIRouter(prefix="/leads", tags=["Lead Management"]) @@ -105,6 +105,20 @@ def get_assigned_leads( "message": "Admin view: Assigned leads only" } +@router.delete("/delete") +def delete_lead( + gmail_id: str = Query(..., description="Gmail ID of the lead to delete"), + user_info: dict = Depends(get_user_from_token) +): + """Delete a lead (admin only)""" + if user_info["role"] != "admin": + raise HTTPException( + status_code=403, + detail="Only admin can delete leads" + ) + + result = delete_lead_by_gmail_id(gmail_id, user_info) + return result @router.get("/{email}") def get_lead_profile(email: str): @@ -167,7 +181,7 @@ def get_lead_profile(email: str): name = ( (latest_full_name or "").strip() - or [latest_first_name, latest_last_name].filter(Boolean).join(" ") + or " ".join(filter(bool, [latest_first_name, latest_last_name])) or latest_email or "Unknown" ) diff --git a/Gradient-Backend/routes/managerRoutes.py b/Gradient-Backend/routes/managerRoutes.py new file mode 100644 index 0000000..cbdcd1c --- /dev/null +++ b/Gradient-Backend/routes/managerRoutes.py @@ -0,0 +1,190 @@ +from fastapi import APIRouter, Depends, HTTPException, Query, Security +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, EmailStr, Field + +from db import conn, db_lock +from hashPswd import hash_password +from service.leadService import get_current_user_role + +router = APIRouter(prefix="/admin/managers", tags=["Manager Management"]) +security = HTTPBearer() + + +def get_user_from_token(credentials: HTTPAuthorizationCredentials = Security(security)): + token = credentials.credentials + return get_current_user_role(token) + + +def require_admin(user_info: dict = Depends(get_user_from_token)) -> dict: + if not user_info or user_info.get("role") != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return user_info + + +class ManagerCreatePayload(BaseModel): + username: str = Field(min_length=1) + email: EmailStr + password: str = Field(min_length=6) + + +class ManagerStatusPayload(BaseModel): + is_active: bool + + +class ManagerResetPasswordPayload(BaseModel): + new_password: str = Field(min_length=6) + + +@router.get("") +def list_managers(_: dict = Depends(require_admin)): + with db_lock: + rows = conn.execute( + """ + SELECT id, username, email, role, is_active + FROM users + WHERE role = 'manager' + ORDER BY id ASC + """ + ).fetchall() + + return { + "managers": [ + { + "id": row[0], + "username": row[1], + "email": row[2], + "role": row[3], + "is_active": bool(row[4]), + } + for row in rows + ] + } + + +@router.post("") +def create_manager(payload: ManagerCreatePayload, _: dict = Depends(require_admin)): + with db_lock: + exists = conn.execute( + "SELECT 1 FROM users WHERE username = ? OR email = ?", + [payload.username, str(payload.email)], + ).fetchone() + + if exists: + raise HTTPException(status_code=400, detail="Username or email already exists") + + with db_lock: + next_id = conn.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM users").fetchone()[0] + hashed_pwd = hash_password(payload.password) + + conn.execute( + """ + INSERT INTO users (id, username, email, password, role, is_active) + VALUES (?, ?, ?, ?, 'manager', TRUE) + """, + [next_id, payload.username, str(payload.email), hashed_pwd], + ) + conn.commit() + + return { + "id": next_id, + "username": payload.username, + "email": str(payload.email), + "role": "manager", + "is_active": True, + } + + +@router.patch("/{manager_id}/status") +def set_manager_status(manager_id: int, payload: ManagerStatusPayload, _: dict = Depends(require_admin)): + with db_lock: + row = conn.execute( + "SELECT id, role FROM users WHERE id = ?", + [manager_id], + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Manager not found") + + if row[1] != "manager": + raise HTTPException(status_code=400, detail="Only manager accounts can be updated") + + with db_lock: + conn.execute( + "UPDATE users SET is_active = ? WHERE id = ?", + [bool(payload.is_active), manager_id], + ) + conn.commit() + + return {"id": manager_id, "is_active": bool(payload.is_active)} + + +@router.post("/{manager_id}/reset-password") +def reset_manager_password( + manager_id: int, + payload: ManagerResetPasswordPayload, + _: dict = Depends(require_admin), +): + with db_lock: + row = conn.execute( + "SELECT id, role FROM users WHERE id = ?", + [manager_id], + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Manager not found") + + if row[1] != "manager": + raise HTTPException(status_code=400, detail="Only manager accounts can be updated") + + hashed_pwd = hash_password(payload.new_password) + with db_lock: + conn.execute( + "UPDATE users SET password = ? WHERE id = ?", + [hashed_pwd, manager_id], + ) + conn.commit() + + return {"id": manager_id, "new_password": payload.new_password} + + +@router.delete("/{manager_id}") +def delete_manager( + manager_id: int, + confirm_username: str = Query(default="", description="Username confirmation (GitHub-style)"), + _: dict = Depends(require_admin), +): + with db_lock: + row = conn.execute( + "SELECT id, username, role FROM users WHERE id = ?", + [manager_id], + ).fetchone() + + if not row: + raise HTTPException(status_code=404, detail="Manager not found") + + if row[2] != "manager": + raise HTTPException(status_code=400, detail="Only manager accounts can be deleted") + + expected_username = row[1] or "" + if (confirm_username or "").strip() != expected_username: + raise HTTPException(status_code=400, detail="Username confirmation mismatch") + + with db_lock: + try: + # 1. Unassign from gmail_messages + conn.execute("UPDATE gmail_messages SET assigned_to = NULL WHERE assigned_to = ?", [int(manager_id)]) + + # 2. Soft-delete the user instead of physical deletion to avoid DuckDB's Vector::Reference internal bug + # We change the role and deactivate the account. + # Note: The 'role' check in other routes will now naturally exclude this user. + conn.execute( + "UPDATE users SET role = 'manager_deleted', is_active = FALSE WHERE id = ?", + [int(manager_id)] + ) + + conn.commit() + except Exception as e: + conn.rollback() + raise HTTPException(status_code=500, detail=f"Database error during deletion: {str(e)}") + + return {"deleted": True, "id": manager_id, "mode": "soft_delete"} diff --git a/Gradient-Backend/service/gmailService.py b/Gradient-Backend/service/gmailService.py index e28ad22..81a2745 100644 --- a/Gradient-Backend/service/gmailService.py +++ b/Gradient-Backend/service/gmailService.py @@ -4,7 +4,7 @@ import base64 import json -from db import conn +from db import conn, db_lock from service.aiService import analyze_email from service.leadIntentService import detect_sales_intent @@ -58,18 +58,21 @@ def get_gmail_service(): def is_processed(msg_id: str) -> bool: - result = conn.execute( - "SELECT 1 FROM processed_emails WHERE gmail_id = ?", - [msg_id] - ).fetchone() + with db_lock: + result = conn.execute( + "SELECT 1 FROM processed_emails WHERE gmail_id = ?", + [msg_id] + ).fetchone() return result is not None def mark_as_processed(msg_id: str): - conn.execute( - "INSERT OR IGNORE INTO processed_emails (gmail_id) VALUES (?)", - [msg_id] - ) + with db_lock: + conn.execute( + "INSERT OR IGNORE INTO processed_emails (gmail_id) VALUES (?)", + [msg_id] + ) + conn.commit() def extract_email(from_header: str) -> str: @@ -120,32 +123,37 @@ def _extract_body(payload: dict) -> str: def _store_message(gmail_id: str, values: list[str]) -> None: - existing = conn.execute( - "SELECT synced_at FROM gmail_messages WHERE gmail_id = ?", - [gmail_id] - ).fetchone() + with db_lock: + existing = conn.execute( + "SELECT synced_at FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ).fetchone() columns_sql = ", ".join(_MESSAGE_VALUE_COLUMNS) placeholders = ", ".join(["?"] * len(_MESSAGE_VALUE_COLUMNS)) if existing is None: - conn.execute( - f""" - INSERT INTO gmail_messages (gmail_id, {columns_sql}) - VALUES (?, {placeholders}) - """, - [gmail_id, *values] - ) + with db_lock: + conn.execute( + f""" + INSERT INTO gmail_messages (gmail_id, {columns_sql}) + VALUES (?, {placeholders}) + """, + [gmail_id, *values] + ) + conn.commit() else: assignments = ", ".join(f"{col} = ?" for col in _MESSAGE_VALUE_COLUMNS) - conn.execute( - f""" - UPDATE gmail_messages - SET {assignments} - WHERE gmail_id = ? - """, - [*values, gmail_id] - ) + with db_lock: + conn.execute( + f""" + UPDATE gmail_messages + SET {assignments}, synced_at = NULL + WHERE gmail_id = ? + """, + [*values, gmail_id] + ) + conn.commit() def get_unsynced_message_rows(limit: int | None = None) -> list[tuple[str, list[str]]]: @@ -160,7 +168,8 @@ def get_unsynced_message_rows(limit: int | None = None) -> list[tuple[str, list[ if limit is not None: query += f" LIMIT {int(limit)}" - rows = conn.execute(query).fetchall() + with db_lock: + rows = conn.execute(query).fetchall() result: list[tuple[str, list[str]]] = [] for row in rows: @@ -176,14 +185,16 @@ def mark_messages_synced(gmail_ids: list[str]) -> None: return placeholders = ", ".join(["?"] * len(gmail_ids)) - conn.execute( - f""" - UPDATE gmail_messages - SET synced_at = CURRENT_TIMESTAMP - WHERE gmail_id IN ({placeholders}) - """, - gmail_ids - ) + with db_lock: + conn.execute( + f""" + UPDATE gmail_messages + SET synced_at = CURRENT_TIMESTAMP + WHERE gmail_id IN ({placeholders}) + """, + gmail_ids + ) + conn.commit() def _normalize_cell(value): @@ -257,7 +268,7 @@ def fetch_new_gmail_data(limit: int = 20): intent = detect_sales_intent(subject=subject, body=body) # Special status for leads that look like they want a call/demo. - lead_status = 'call_lead' if intent.get('pending_review') else 'waiting' + lead_status = 'call_lead' if intent.get('pending_review') else 'NEW' # Prioritize name from signature/body if available final_sender_name = parsed.get("full_name") if parsed.get("full_name") else sender_name @@ -277,7 +288,7 @@ def fetch_new_gmail_data(limit: int = 20): company_insights_value = json.dumps(parsed.get("company_insights") or [], ensure_ascii=False) row = [ - lead_status, # status + lead_status, # status - dynamic based on sales intent detection first_name, last_name, final_sender_name, diff --git a/Gradient-Backend/service/leadService.py b/Gradient-Backend/service/leadService.py index efac0b6..b56f435 100644 --- a/Gradient-Backend/service/leadService.py +++ b/Gradient-Backend/service/leadService.py @@ -1,4 +1,4 @@ -from db import conn +from db import conn, db_lock from datetime import datetime from fastapi import HTTPException, status from jose import jwt, JWTError @@ -19,10 +19,11 @@ def get_current_user_role(token: str) -> dict: ) # Get user info from database - user = conn.execute( - "SELECT id, username, role FROM users WHERE username = ?", - [username] - ).fetchone() + with db_lock: + user = conn.execute( + "SELECT id, username, role, is_active FROM users WHERE username = ?", + [username] + ).fetchone() if not user: raise HTTPException( @@ -30,6 +31,12 @@ def get_current_user_role(token: str) -> dict: detail="User not found" ) + if user[3] is not None and not bool(user[3]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User is inactive" + ) + return {"id": user[0], "username": user[1], "role": user[2]} except JWTError: raise HTTPException( @@ -40,10 +47,11 @@ def get_current_user_role(token: str) -> dict: def assign_lead_to_user(gmail_id: str, user_info: dict): """Assign a lead to a user""" # Check if lead exists - lead = conn.execute( - "SELECT gmail_id FROM gmail_messages WHERE gmail_id = ?", - [gmail_id] - ).fetchone() + with db_lock: + lead = conn.execute( + "SELECT gmail_id FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ).fetchone() if not lead: raise HTTPException( @@ -52,10 +60,11 @@ def assign_lead_to_user(gmail_id: str, user_info: dict): ) # Check if lead is already assigned - existing = conn.execute( - "SELECT assigned_to FROM gmail_messages WHERE gmail_id = ? AND assigned_to IS NOT NULL", - [gmail_id] - ).fetchone() + with db_lock: + existing = conn.execute( + "SELECT assigned_to FROM gmail_messages WHERE gmail_id = ? AND assigned_to IS NOT NULL", + [gmail_id] + ).fetchone() if existing: raise HTTPException( @@ -63,18 +72,44 @@ def assign_lead_to_user(gmail_id: str, user_info: dict): detail="Lead is already assigned" ) - # Assign lead to user - conn.execute( - "UPDATE gmail_messages SET assigned_to = ?, assigned_at = ? WHERE gmail_id = ?", - [user_info["id"], datetime.now(), gmail_id] - ) + # Assign lead to user and update status to ASSIGNED + with db_lock: + conn.execute( + "UPDATE gmail_messages SET assigned_to = ?, assigned_at = ?, status = 'ASSIGNED' WHERE gmail_id = ?", + [user_info["id"], datetime.now(), gmail_id] + ) - conn.commit() - return {"message": "Lead assigned successfully", "gmail_id": gmail_id, "assigned_to": user_info["username"]} + # Add status history entry + import uuid + history_id = str(uuid.uuid4()) + with db_lock: + lead_data = conn.execute( + "SELECT full_name FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ).fetchone() + lead_name = lead_data[0] if lead_data else None + + with db_lock: + conn.execute( + """ + INSERT INTO lead_status_history (id, gmail_id, status, assignee, lead_name) + VALUES (?, ?, 'ASSIGNED', ?, ?) + """, + [history_id, gmail_id, user_info["username"], lead_name] + ) + + conn.commit() + return {"message": "Lead assigned successfully", "gmail_id": gmail_id, "assigned_to": user_info["username"], "status": "ASSIGNED"} def get_user_leads(user_info: dict, limit: int = 120): - """Get leads based on user role""" - if user_info and user_info.get("role") == "admin": + """Get leads based on user role - admin sees all, manager sees assigned or available""" + if not user_info: + return [] + + user_role = user_info.get("role") + user_id = user_info.get("id") + + if user_role == "admin": # Admin sees all leads with assignment info query = """ SELECT @@ -88,93 +123,97 @@ def get_user_leads(user_info: dict, limit: int = 120): ORDER BY gm.created_at DESC LIMIT ? """ - leads = conn.execute(query, [limit]).fetchall() - - # Format results - formatted_leads = [] - for lead in leads: - formatted_lead = { - "gmail_id": lead[0], - "status": lead[1], - "first_name": lead[2], - "last_name": lead[3], - "full_name": lead[4], - "email": lead[5], - "subject": lead[6], - "received_at": lead[7], - "company": lead[8], - "body": lead[9], - "phone": lead[10], - "website": lead[11], - "company_name": lead[12], - "company_info": lead[13], - "person_role": lead[14], - "person_links": lead[15], - "person_location": lead[16], - "person_experience": lead[17], - "person_summary": lead[18], - "person_insights": lead[19], - "company_insights": lead[20], - "assigned_to": lead[21], - "assigned_at": lead[22], - "synced_at": lead[23], - "created_at": lead[24], - "assigned_username": lead[25], - "assigned_role": lead[26], - #"assigned_display": f"[{lead[26].upper()}] {lead[25]}" if lead[25] else "Unassigned" - } - formatted_leads.append(formatted_lead) + with db_lock: + leads = conn.execute(query, [limit]).fetchall() - return formatted_leads - - else: - # Manager sees only their assigned leads + elif user_role == "manager": + # Manager sees all leads with assignment info (same as admin) query = """ SELECT - gmail_id, status, first_name, last_name, full_name, email, subject, - received_at, company, body, phone, website, company_name, company_info, - person_role, person_links, person_location, person_experience, person_summary, - person_insights, company_insights, assigned_to, assigned_at, synced_at, created_at - FROM gmail_messages - WHERE assigned_to = ? - ORDER BY created_at DESC + gm.gmail_id, gm.status, gm.first_name, gm.last_name, gm.full_name, gm.email, gm.subject, + gm.received_at, gm.company, gm.body, gm.phone, gm.website, gm.company_name, gm.company_info, + gm.person_role, gm.person_links, gm.person_location, gm.person_experience, gm.person_summary, + gm.person_insights, gm.company_insights, gm.assigned_to, gm.assigned_at, gm.synced_at, gm.created_at, + u.username as assigned_username, u.role as assigned_role + FROM gmail_messages gm + LEFT JOIN users u ON gm.assigned_to = u.id + ORDER BY gm.created_at DESC LIMIT ? """ - leads = conn.execute(query, [user_info["id"], limit]).fetchall() - - # Format results - formatted_leads = [] - for lead in leads: - formatted_lead = { - "gmail_id": lead[0], - "status": lead[1], - "first_name": lead[2], - "last_name": lead[3], - "full_name": lead[4], - "email": lead[5], - "subject": lead[6], - "received_at": lead[7], - "company": lead[8], - "body": lead[9], - "phone": lead[10], - "website": lead[11], - "company_name": lead[12], - "company_info": lead[13], - "person_role": lead[14], - "person_links": lead[15], - "person_location": lead[16], - "person_experience": lead[17], - "person_summary": lead[18], - "person_insights": lead[19], - "company_insights": lead[20], - "assigned_to": lead[21], - "assigned_at": lead[22], - "synced_at": lead[23], - "created_at": lead[24] - } - formatted_leads.append(formatted_lead) - - return formatted_leads + leads = conn.execute(query, [limit]).fetchall() + else: + return [] + + # Format results + formatted_leads = [] + for lead in leads: + formatted_lead = { + "gmail_id": lead[0], + "status": lead[1], + "first_name": lead[2], + "last_name": lead[3], + "full_name": lead[4], + "email": lead[5], + "subject": lead[6], + "received_at": lead[7], + "company": lead[8], + "body": lead[9], + "phone": lead[10], + "website": lead[11], + "company_name": lead[12], + "company_info": lead[13], + "person_role": lead[14], + "person_links": lead[15], + "person_location": lead[16], + "person_experience": lead[17], + "person_summary": lead[18], + "person_insights": lead[19], + "company_insights": lead[20], + "assigned_to": lead[21], + "assigned_at": lead[22], + "synced_at": lead[23], + "created_at": lead[24], + "assigned_username": lead[25], + "assigned_role": lead[26], + } + formatted_leads.append(formatted_lead) + + return formatted_leads + +def delete_lead_by_gmail_id(gmail_id: str, user_info: dict): + """Delete a lead by gmail_id (admin only)""" + # Check if lead exists + lead_data = conn.execute( + "SELECT gmail_id, full_name FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ).fetchone() + + if not lead_data: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Lead with gmail_id {gmail_id} not found" + ) + + lead_name = lead_data[1] if lead_data else None + + # Delete the lead + conn.execute( + "DELETE FROM gmail_messages WHERE gmail_id = ?", + [gmail_id] + ) + + # Log the deletion in history + history_id = str(uuid.uuid4()) + conn.execute( + """ + INSERT INTO lead_status_history (id, gmail_id, status, assignee, lead_name) + VALUES (?, ?, 'DELETED', ?, ?) + """, + [history_id, gmail_id, user_info["username"], lead_name] + ) + + conn.commit() + return {"message": "Lead deleted successfully", "gmail_id": gmail_id, "deleted_by": user_info["username"]} def get_available_leads(user_info: dict, limit: int = 50): """Get unassigned leads that managers can pick""" diff --git a/Gradient-Backend/service/settingsService.py b/Gradient-Backend/service/settingsService.py index 880207c..819a3b7 100644 --- a/Gradient-Backend/service/settingsService.py +++ b/Gradient-Backend/service/settingsService.py @@ -1,6 +1,6 @@ from typing import Dict -from db import conn +from db import conn, db_lock ReplyPrompts = Dict[str, str] @@ -14,12 +14,15 @@ def get_setting(key: str) -> str | None: - row = conn.execute("SELECT value FROM app_settings WHERE key = ?", [key]).fetchone() + with db_lock: + row = conn.execute("SELECT value FROM app_settings WHERE key = ?", [key]).fetchone() return row[0] if row else None def set_setting(key: str, value: str) -> None: - conn.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", [key, value]) + with db_lock: + conn.execute("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)", [key, value]) + conn.commit() def get_reply_prompts() -> ReplyPrompts: diff --git a/Gradient-Backend/service/sheetService.py b/Gradient-Backend/service/sheetService.py index 102ddf3..4cfae4f 100644 --- a/Gradient-Backend/service/sheetService.py +++ b/Gradient-Backend/service/sheetService.py @@ -8,7 +8,7 @@ from googleapiclient.discovery import build from google.oauth2.credentials import Credentials from dotenv import load_dotenv -from db import conn +from db import conn, db_lock load_dotenv() @@ -201,29 +201,36 @@ def update_lead_status_gmail_id(gmail_id: str, status: str, rejection_reason: st """Update lead status in DuckDB by gmail_id""" if not gmail_id: raise ValueError("gmail_id is required") - + normalized_status = (status or "").strip().lower() if normalized_status not in ALLOWED_STATUS_VALUES: raise ValueError("Unsupported status value") - - # Update gmail_messages table - conn.execute(""" - UPDATE gmail_messages - SET status = ? + + conn.execute( + """ + UPDATE gmail_messages + SET status = ? WHERE gmail_id = ? - """, [normalized_status, gmail_id]) - - # Add to lead_status_history + """, + [normalized_status, gmail_id], + ) + history_id = f"{gmail_id}_{datetime.now().isoformat()}" - lead_name = conn.execute("SELECT full_name FROM gmail_messages WHERE gmail_id = ?", [gmail_id]).fetchone() + lead_name = conn.execute( + "SELECT full_name FROM gmail_messages WHERE gmail_id = ?", + [gmail_id], + ).fetchone() lead_name = lead_name[0] if lead_name else "Unknown" - - conn.execute(""" - INSERT INTO lead_status_history + + conn.execute( + """ + INSERT INTO lead_status_history (id, gmail_id, lead_name, status, rejection_reason, changed_at) VALUES (?, ?, ?, ?, ?, ?) - """, [history_id, gmail_id, lead_name, normalized_status, rejection_reason, datetime.now()]) - + """, + [history_id, gmail_id, lead_name, normalized_status, rejection_reason, datetime.now()], + ) + conn.commit() @@ -274,10 +281,6 @@ def build_leads_payload(limit: int | None = 120) -> dict[str, Any]: return build_leads_payload_from_db(limit, dummy_admin) except Exception as e: print(f"Fallback to Sheets API due to DB error: {e}") - # Original fallback logic - leads = fetch_sheet_rows(limit) - # ... rest of the original logic if needed, but build_leads_payload_from_db is better - # For now, let's just make it return from DB as it's our primary storage. return build_leads_payload_from_db(limit, {"role": "admin", "id": -1}) @@ -287,13 +290,13 @@ def build_leads_payload_from_db( range_days: int | None = None, ) -> dict[str, Any]: """Build leads payload from database with role-based filtering""" - + # Build query based on user role if user_info and user_info.get("role") == "admin": # Admin sees all leads with assignment info query = """ - SELECT - gmail_id, status, first_name, last_name, full_name, gm.email, subject, + SELECT + gmail_id, status, first_name, last_name, full_name, gm.email, subject, received_at, company, body, phone, website, company_name, company_info, person_role, person_links, person_location, person_experience, person_summary, person_insights, company_insights, assigned_to, assigned_at, synced_at, created_at, @@ -303,8 +306,9 @@ def build_leads_payload_from_db( ORDER BY created_at DESC LIMIT ? """ - leads_data = conn.execute(query, [limit]).fetchall() - + with db_lock: + leads_data = conn.execute(query, [limit]).fetchall() + leads = [] for lead in leads_data: lead_dict = { @@ -337,7 +341,7 @@ def build_leads_payload_from_db( "assigned_role": lead[26], "assigned_display": f"[{lead[26].upper()}] {lead[25]}" if lead[25] else None } - + # Process JSON fields if lead_dict["person_links"]: try: @@ -346,7 +350,7 @@ def build_leads_payload_from_db( lead_dict["person_links"] = [] else: lead_dict["person_links"] = [] - + for field in ["person_insights", "company_insights"]: if lead_dict[field]: try: @@ -355,24 +359,25 @@ def build_leads_payload_from_db( lead_dict[field] = [] else: lead_dict[field] = [] - + leads.append(lead_dict) - + elif user_info and user_info.get("role") == "manager": - # Manager sees only their assigned leads + # Manager sees all leads with assignment info (same as admin) query = """ - SELECT - gmail_id, status, first_name, last_name, full_name, email, subject, + SELECT + gmail_id, status, first_name, last_name, full_name, gm.email, subject, received_at, company, body, phone, website, company_name, company_info, person_role, person_links, person_location, person_experience, person_summary, - person_insights, company_insights, assigned_to, assigned_at, synced_at, created_at - FROM gmail_messages - WHERE assigned_to = ? + person_insights, company_insights, assigned_to, assigned_at, synced_at, created_at, + u.username as assigned_username, u.role as assigned_role + FROM gmail_messages gm + LEFT JOIN users u ON gm.assigned_to = u.id ORDER BY created_at DESC LIMIT ? """ - leads_data = conn.execute(query, [user_info["id"], limit]).fetchall() - + leads_data = conn.execute(query, [limit]).fetchall() + leads = [] for lead in leads_data: lead_dict = { @@ -400,9 +405,12 @@ def build_leads_payload_from_db( "assigned_to": lead[21], "assigned_at": lead[22], "synced_at": lead[23], - "created_at": lead[24] + "created_at": lead[24], + "assigned_username": lead[25], + "assigned_role": lead[26], + "assigned_display": f"[{lead[26].upper()}] {lead[25]}" if lead[25] else None } - + # Process JSON fields if lead_dict["person_links"]: try: @@ -411,7 +419,7 @@ def build_leads_payload_from_db( lead_dict["person_links"] = [] else: lead_dict["person_links"] = [] - + for field in ["person_insights", "company_insights"]: if lead_dict[field]: try: @@ -420,13 +428,13 @@ def build_leads_payload_from_db( lead_dict[field] = [] else: lead_dict[field] = [] - + leads.append(lead_dict) - + else: # No user info, return empty leads leads = [] - + now = datetime.utcnow() # Optional global range filter (used by Analytics global filter panel). @@ -447,48 +455,49 @@ def build_leads_payload_from_db( if not gmail_id: lead["rejection_reason"] = None continue - row = conn.execute( - """ - SELECT rejection_reason - FROM lead_status_history - WHERE gmail_id = ? - ORDER BY changed_at DESC - LIMIT 1 - """, - [gmail_id], - ).fetchone() + with db_lock: + row = conn.execute( + """ + SELECT rejection_reason + FROM lead_status_history + WHERE gmail_id = ? + ORDER BY changed_at DESC + LIMIT 1 + """, + [gmail_id], + ).fetchone() lead["rejection_reason"] = row[0] if row else None # Calculate stats active_cutoff = now - timedelta(days=range_days if range_days is not None else 30) - + total = len(leads) qualified_total = 0 waiting_total = 0 active_total = 0 - + month_totals: dict[tuple[int, int], dict[str, int]] = defaultdict(lambda: {"total": 0, "qualified": 0}) week_totals = [0, 0, 0, 0] week_qualified = [0, 0, 0, 0] - + for lead in leads: lead_dt = _parse_datetime(lead.get("received_at")) qualified = _is_qualified(lead) if qualified: qualified_total += 1 - + if (lead.get("status") or "waiting").lower() == "waiting" and not qualified: waiting_total += 1 - + if lead_dt: if lead_dt >= active_cutoff: active_total += 1 - + key = (lead_dt.year, lead_dt.month) month_totals[key]["total"] += 1 if qualified: month_totals[key]["qualified"] += 1 - + diff_days = (now - lead_dt).days if diff_days < 0: diff_days = 0 @@ -498,7 +507,7 @@ def build_leads_payload_from_db( week_totals[slot] += 1 if qualified: week_qualified[slot] += 1 - + month_buckets = _generate_month_buckets() line_chart = [] for bucket in month_buckets: @@ -509,23 +518,23 @@ def build_leads_payload_from_db( "pv": bucket_totals["total"], "uv": bucket_totals["qualified"], }) - + quarter_chart = line_chart[-3:] if line_chart else [] - + month_chart = [ {"name": label, "pv": week_totals[idx], "uv": week_qualified[idx]} for idx, label in enumerate(WEEK_LABELS) ] - + percentage = 0 if total: percentage = int(round((qualified_total / total) * 100)) - + pie_chart = [ {"value": percentage}, {"value": max(0, 100 - percentage)}, ] if total else [{"value": 0}, {"value": 100}] - + stats = { "active": active_total, "completed": total, @@ -533,7 +542,7 @@ def build_leads_payload_from_db( "qualified": qualified_total, "waiting": waiting_total, } - + pending_groups: list[dict[str, Any]] = [] pending_buckets: dict[str, list[dict[str, Any]]] = {"3": [], "5": [], "10": []} diff --git a/Gradient-Backend/service/userService.py b/Gradient-Backend/service/userService.py index f13420c..74af706 100644 --- a/Gradient-Backend/service/userService.py +++ b/Gradient-Backend/service/userService.py @@ -1,4 +1,4 @@ -from db import conn +from db import conn, db_lock from hashPswd import hash_password, verify_password from datetime import datetime, timedelta from jose import jwt, JWTError @@ -17,10 +17,11 @@ ACCESS_TOKEN_EXPIRE_HOURS = 2 def register_user(user): - exists = conn.execute( - "SELECT 1 FROM users WHERE username = ? OR email = ?", - [user.username, user.email] - ).fetchone() + with db_lock: + exists = conn.execute( + "SELECT 1 FROM users WHERE username = ? OR email = ?", + [user.username, user.email] + ).fetchone() if exists: raise HTTPException( @@ -30,14 +31,17 @@ def register_user(user): hashed_pwd = hash_password(user.password) - next_id = conn.execute( - "SELECT COALESCE(MAX(id), 0) + 1 FROM users" - ).fetchone()[0] + with db_lock: + next_id = conn.execute( + "SELECT COALESCE(MAX(id), 0) + 1 FROM users" + ).fetchone()[0] - conn.execute( - "INSERT INTO users (id, username, email, password) VALUES (?, ?, ?, ?)", - [next_id, user.username, user.email, hashed_pwd] - ) + with db_lock: + conn.execute( + "INSERT INTO users (id, username, email, password) VALUES (?, ?, ?, ?)", + [next_id, user.username, user.email, hashed_pwd] + ) + conn.commit() return {"msg": "User registered successfully"} @@ -53,10 +57,11 @@ def create_access_token(data: dict, expires_delta: timedelta = None): def login_user(user): username = user.username or user.email - row = conn.execute( - "SELECT username, password FROM users WHERE username = ? OR email = ?", - [username, user.email or username] - ).fetchone() + with db_lock: + row = conn.execute( + "SELECT username, password, role, is_active FROM users WHERE username = ? OR email = ?", + [username, user.email or username] + ).fetchone() if not row: raise HTTPException( @@ -64,7 +69,13 @@ def login_user(user): detail="Invalid username or password" ) - stored_username, hashed_password = row + stored_username, hashed_password, user_role, is_active = row + + if is_active is not None and not bool(is_active): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is deactivated" + ) if not verify_password(user.password, hashed_password): raise HTTPException( @@ -72,5 +83,5 @@ def login_user(user): detail="Invalid username or password" ) - access_token = create_access_token({"sub": stored_username}) - return {"access_token": access_token, "token_type": "bearer"} + access_token = create_access_token({"sub": stored_username, "role": user_role or "manager"}) + return {"access_token": access_token, "token_type": "bearer", "role": user_role or "manager"} diff --git a/Gradient-Backend/simple_migrate.py b/Gradient-Backend/simple_migrate.py deleted file mode 100644 index eca6b10..0000000 --- a/Gradient-Backend/simple_migrate.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Simple database migration script -Run this to add the required columns for lead assignment -""" - -import duckdb -from pathlib import Path - -# Database path -BASE_DIR = Path(__file__).resolve().parent -DB_PATH = BASE_DIR / "db" / "database.duckdb" - -def migrate(): - print("Starting database migration...") - - try: - # Connect to database - conn = duckdb.connect(DB_PATH) - print(f"Connected to database: {DB_PATH}") - - # Check current gmail_messages schema - print("\nCurrent gmail_messages schema:") - schema = conn.execute("PRAGMA table_info(gmail_messages)").fetchall() - for col in schema: - print(f" {col[1]} {col[2]}") - - # Add assigned_to column if it doesn't exist - try: - conn.execute("ALTER TABLE gmail_messages ADD COLUMN assigned_to INTEGER") - print("✓ Added assigned_to column") - except Exception as e: - if "duplicate column name" in str(e).lower(): - print("✓ assigned_to column already exists") - else: - print(f"Error adding assigned_to: {e}") - - # Add assigned_at column if it doesn't exist - try: - conn.execute("ALTER TABLE gmail_messages ADD COLUMN assigned_at TIMESTAMP") - print("✓ Added assigned_at column") - except Exception as e: - if "duplicate column name" in str(e).lower(): - print("✓ assigned_at column already exists") - else: - print(f"Error adding assigned_at: {e}") - - # Check users table - print("\nCurrent users schema:") - users_schema = conn.execute("PRAGMA table_info(users)").fetchall() - for col in users_schema: - print(f" {col[1]} {col[2]}") - - # Add role column if it doesn't exist - try: - conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'manager'") - print("✓ Added role column to users") - - # Update existing users to have manager role - conn.execute("UPDATE users SET role = 'manager' WHERE role IS NULL OR role = ''") - print("✓ Updated existing users to manager role") - - except Exception as e: - if "duplicate column name" in str(e).lower(): - print("✓ role column already exists in users") - else: - print(f"Error adding role: {e}") - - conn.commit() - print("\n✅ Migration completed successfully!") - - # Test the new columns - print("\nTesting new columns...") - test = conn.execute("SELECT COUNT(*) FROM gmail_messages WHERE assigned_to IS NULL").fetchone() - print(f"Found {test[0]} unassigned leads") - - users = conn.execute("SELECT username, role FROM users").fetchall() - print("Users:") - for username, role in users: - print(f" {username}: {role}") - - except Exception as e: - print(f"❌ Migration failed: {e}") - raise - finally: - conn.close() - -if __name__ == "__main__": - migrate() diff --git a/gradient-frontend/src/App.js b/gradient-frontend/src/App.js index 41ec4eb..8caeba7 100644 --- a/gradient-frontend/src/App.js +++ b/gradient-frontend/src/App.js @@ -7,6 +7,7 @@ import AnalyticsManager from './pages/AnalyticsManager'; import Automation from './pages/Automation'; import Profile from './pages/Profile'; import Settings from './pages/Settings'; +import ManagerManagement from './pages/ManagerManagement'; import LeadProfile from './pages/LeadProfile'; import LeadsHistory from './pages/LeadsHistory'; import Login from './pages/Login'; @@ -230,6 +231,7 @@ function InnerApp() { } /> } /> } /> + } /> diff --git a/gradient-frontend/src/api/client.js b/gradient-frontend/src/api/client.js index 4e37bb9..650abec 100644 --- a/gradient-frontend/src/api/client.js +++ b/gradient-frontend/src/api/client.js @@ -1,49 +1,95 @@ const DEFAULT_API_URL = 'http://127.0.0.1:8000'; + + const API_URL = (typeof process !== 'undefined' && process.env.REACT_APP_API_URL) || DEFAULT_API_URL; + + let authToken = null; + + const getSessionStorage = () => { + if (typeof window === 'undefined') return null; + try { + return window.sessionStorage; + } catch (error) { + console.warn('Session storage unavailable:', error); + return null; + } + }; + + export const loadAuthToken = () => { + const storage = getSessionStorage(); + authToken = storage?.getItem('authToken') || null; + return authToken; + }; + + export const setAuthToken = token => { + authToken = token; + const storage = getSessionStorage(); + if (!storage) return; + + if (token) { + storage.setItem('authToken', token); + } else { + storage.removeItem('authToken'); + } + }; + + export const clearAuthToken = () => setAuthToken(null); + + const parseJsonSafely = async response => { + const text = await response.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch (error) { + throw new Error(text || response.statusText); + } + }; + + const request = async (path, options = {}) => { const headers = new Headers(options.headers || {}); const isFormData = @@ -66,34 +112,64 @@ const request = async (path, options = {}) => { headers, }); + + if (!response.ok) { + const errorBody = await parseJsonSafely(response).catch(() => null); + const detail = errorBody?.detail || errorBody?.message; + throw new Error(detail || response.statusText || 'Request failed'); + } + + if (response.status === 204) { + return null; + } + + return parseJsonSafely(response); + }; + + export const loginRequest = credentials => + request('/auth/login', { + method: 'POST', + body: JSON.stringify(credentials), + }); + + export const registerRequest = payload => + request('/auth/register', { + method: 'POST', + body: JSON.stringify(payload), + }); + + export const postGmailSync = () => + request('/gmail/sync', { + method: 'POST', + }); export const getGmailLeads = (rangeDays = null) => { @@ -102,51 +178,109 @@ export const getGmailLeads = (rangeDays = null) => { }; export const postLeadInsights = (payload) => + request('/gmail/lead-insights', { + method: 'POST', + body: JSON.stringify(payload), + }); + + +export const getLeadProfile = (email) => request(`/gmail/lead-profile?email=${encodeURIComponent(email)}`); + +export const getStatusHistory = (gmail_id) => request(`/gmail/status-history?gmail_id=${encodeURIComponent(gmail_id)}`); + export const postLeadStatus = (payload) => request('/gmail/lead-status', { method: 'POST', body: JSON.stringify(payload), }); +export const assignLead = (gmail_id) => + request('/leads/assign', { + method: 'POST', + body: JSON.stringify({ gmail_id }), + }); + +export const deleteLead = (gmail_id) => + request(`/leads/delete?gmail_id=${encodeURIComponent(gmail_id)}`, { + method: 'DELETE', + }); + + + export const postGenerateReplies = (payload) => + request('/gmail/generate-replies', { + method: 'POST', + body: JSON.stringify(payload), + }); + + export const getReplyPrompts = () => request('/settings/reply-prompts'); + + export const updateReplyPrompts = (payload) => request('/settings/reply-prompts', { method: 'PUT', body: JSON.stringify(payload), }); -export const getLeadProfile = (leadId) => - request(`/leads/${leadId}`); +export const getManagers = () => request('/admin/managers'); + +export const createManager = (payload) => + request('/admin/managers', { + method: 'POST', + body: JSON.stringify(payload), + }); + +export const setManagerStatus = (managerId, payload) => + request(`/admin/managers/${encodeURIComponent(managerId)}/status`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); + +export const resetManagerPassword = (managerId, payload) => + request(`/admin/managers/${encodeURIComponent(managerId)}/reset-password`, { + method: 'POST', + body: JSON.stringify(payload), + }); + +export const deleteManager = (managerId, confirmUsername) => + request( + `/admin/managers/${encodeURIComponent(managerId)}?confirm_username=${encodeURIComponent( + confirmUsername || '' + )}`, + { + method: 'DELETE', + } + ); export const sendEmailWithAttachments = (payload) => { const formData = new FormData(); - + // Add text fields Object.keys(payload).forEach(key => { if (key !== 'attachments') { formData.append(key, payload[key]); } }); - + // Add files if (payload.attachments) { payload.attachments.forEach(file => { formData.append('attachments', file); }); } - + return request('/email/send', { method: 'POST', body: formData, diff --git a/gradient-frontend/src/components/Header.js b/gradient-frontend/src/components/Header.js index 012d012..ff72def 100644 --- a/gradient-frontend/src/components/Header.js +++ b/gradient-frontend/src/components/Header.js @@ -1,298 +1,614 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; + import { Link, NavLink, useNavigate } from 'react-router-dom'; + import styled, { keyframes } from 'styled-components'; + import userAvatar from '../assets/user.jpg'; + import ThemeToggle from './ThemeToggle'; + import { useAuth } from '../context/AuthContext'; + + const HeaderContainer = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background: ${({ theme }) => theme.colors.headerBackground}; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border}; + box-shadow: 0 2px 12px ${({ theme }) => theme.colors.shadow}; + position: relative; + transition: background 0.3s ease, border-color 0.3s ease; + `; + + const Nav = styled.nav` + display: flex; + align-items: center; + position: absolute; + left: 50%; + transform: translateX(-50%); + + a { + color: ${({ theme }) => theme.colors.textSecondary}; + text-decoration: none; + margin: 0 1.25rem; + font-size: 1.2rem; + letter-spacing: 0.2px; + padding-bottom: 0.6rem; + transition: color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; + opacity: 0.9; + + &:hover { + color: ${({ theme }) => theme.colors.text}; + opacity: 1; + } + + &.active { + color: ${({ theme }) => theme.colors.text}; + border-bottom: 3px solid ${({ theme }) => theme.colors.primary}; + } + } + `; + + const UserMenu = styled.div` + display: flex; + align-items: center; + position: relative; + `; + + const UserAvatar = styled.div` + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #f1f3f6; + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.border}; + `; + + const AvatarImage = styled.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + `; + + const dropdownAppear = keyframes` + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + `; + + const UserButton = styled.button` + display: flex; + align-items: center; + background: none; + border: none; + color: ${({ theme }) => theme.colors.textSecondary}; + cursor: pointer; + padding: 0; + `; + + const UserDropdown = styled.div` + ${({ closing }) => closing && 'pointer-events: none;'} + position: absolute; + top: 52px; + right: 0; + width: 320px; + background: ${({ theme }) => theme.colors.headerBackground}; + border-radius: 16px; + box-shadow: 0 10px 30px ${({ theme }) => theme.colors.shadow}; + border: 1px solid ${({ theme }) => theme.colors.border}; + padding: 1.25rem 1.2rem 1rem; + z-index: 10; + animation: ${dropdownAppear} 0.22s ease-out forwards; + ${({ closing }) => closing && 'animation-direction: reverse; pointer-events: none;'} + `; + + const UserTop = styled.div` + display: flex; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.border}; + `; + + const UserTopInfo = styled.div` + margin-left: 0.75rem; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + } + + span { + display: block; + font-size: 0.8rem; + opacity: 0.8; + } + `; + + const MenuList = styled.ul` + list-style: none; + margin: 0; + padding: 0; + `; + + const MenuItem = styled.li` + a, + button { + width: 100%; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.7rem; + border-radius: 999px; + background: transparent; + border: none; + text-align: left; + color: ${({ theme }) => theme.colors.textSecondary}; + text-decoration: none; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.cardBackground}; + color: ${({ theme }) => theme.colors.text}; + } + } + + &.logout button { + color: #ff4d4f; + + &:hover { + background: rgba(255, 77, 79, 0.12); + color: #ff4d4f; + } + } + `; + + const IconCircle = styled.span` + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.colors.cardBackground}; + font-size: 0.95rem; + `; + + const StatusIndicator = styled.span` + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid ${({ theme }) => theme.colors.headerBackground}; + background-color: #21ff00; /* online */ + `; + + const Header = () => { + const [open, setOpen] = useState(false); + const [closing, setClosing] = useState(false); + const menuRef = useRef(null); + const navigate = useNavigate(); + const { logout, user } = useAuth(); + + const toggleMenu = () => { + setOpen((prev) => !prev); + }; + + const closeMenu = useCallback(() => { + if (closing) return; + setClosing(true); + setTimeout(() => { + setOpen(false); + setClosing(false); + }, 220); // match animation duration + }, [closing]); + useEffect(() => { + if (!open) return; + + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + closeMenu(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [open, closeMenu]); + + const handleLogout = () => { + logout(); + closeMenu(); + navigate('/login'); + }; + + return ( + + + + + + + + + + + {(open || closing) && ( + + + + + + + +

{user?.email || 'User'}

- Admin + + {user?.role === 'admin' ? 'Адміністратор' : 'Менеджер'} +
+
+ + + + 👤 + Профіль + + + + + 🕒 + Історія Лідів + + - - - ⚙️ - Налаштування - - + + {user?.role === 'admin' && ( + + + + + + ⚙️ + + Налаштування + + + + + + )} + + {user?.role === 'admin' && ( + + + + + + 🧑‍💼 + + Керування менеджерами + + + + + + )} + + + + +
+ )} +
+
+ ); + }; + + export default Header; + diff --git a/gradient-frontend/src/context/AuthContext.js b/gradient-frontend/src/context/AuthContext.js index 1f1a392..0717ea0 100644 --- a/gradient-frontend/src/context/AuthContext.js +++ b/gradient-frontend/src/context/AuthContext.js @@ -1,317 +1,636 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + import { + clearAuthToken, + loadAuthToken, + loginRequest, + setAuthToken, + getGmailLeads, + } from '../api/client'; + + const AuthContext = createContext(); + + const LAST_SEEN_LEAD_KEY = 'gradient:lastSeenLeadTime'; + const LEAD_SNAPSHOT_KEY = 'gradient:displayedLeadSnapshot'; + + const readLastSeenLeadTime = () => { + if (typeof window === 'undefined') return null; + try { + return window.localStorage.getItem(LAST_SEEN_LEAD_KEY); + } catch (storageError) { + console.error('Не вдалося зчитати час перегляду листів', storageError); + return null; + } + }; + + const writeLastSeenLeadTime = (isoString) => { + if (typeof window === 'undefined' || !isoString) return; + try { + window.localStorage.setItem(LAST_SEEN_LEAD_KEY, isoString); + } catch (storageError) { + console.error('Не вдалося зберегти час перегляду листів', storageError); + } + }; + + const clearLastSeenLeadTime = () => { + if (typeof window === 'undefined') return; + try { + window.localStorage.removeItem(LAST_SEEN_LEAD_KEY); + } catch (storageError) { + console.error('Не вдалося очистити час перегляду листів', storageError); + } + }; + + const readLeadSnapshot = () => { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(LEAD_SNAPSHOT_KEY); + if (!raw) return null; + return JSON.parse(raw); + } catch (storageError) { + console.error('Не вдалося зчитати збережені ліди', storageError); + return null; + } + }; + + const writeLeadSnapshot = (payload) => { + if (typeof window === 'undefined') return; + try { + if (payload) { + window.localStorage.setItem(LEAD_SNAPSHOT_KEY, JSON.stringify(payload)); + } else { + window.localStorage.removeItem(LEAD_SNAPSHOT_KEY); + } + } catch (storageError) { + console.error('Не вдалося зберегти ліди', storageError); + } + }; + + const parseDateOrNull = (value) => { + if (!value) return null; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; + }; + + const formatRelativeTime = (value) => { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + const diffMs = Date.now() - date.getTime(); + const minutes = Math.floor(diffMs / (1000 * 60)); + if (minutes < 1) return 'щойно'; + if (minutes < 60) return `${minutes} хв тому`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} год тому`; + const days = Math.floor(hours / 24); + if (days === 1) return 'вчора'; + if (days < 7) return `${days} дн. тому`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks} тиж. тому`; + const months = Math.floor(days / 30); + if (months < 12) return `${months} міс. тому`; + const years = Math.floor(months / 12); + return `${years} р. тому`; + }; + + const formatEmailCountMessage = (count) => { + if (count === 0) { + return 'Нових листів немає.'; + } + if (count === 1) { + return 'Вам надійшов 1 новий лист.'; + } + return `Вам надійшло ${count} нових листів.`; + }; + + export const AuthProvider = ({ children }) => { + const [token, setToken] = useState(null); + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [notifications, setNotifications] = useState([]); + const notificationTimersRef = useRef({}); + const [leadSnapshot, setLeadSnapshot] = useState(() => readLeadSnapshot()); + const loginSnapshotRef = useRef(null); + + const removeNotification = useCallback((id) => { + setNotifications((prev) => prev.filter((item) => item.id !== id)); + const timeoutId = notificationTimersRef.current[id]; + if (timeoutId) { + clearTimeout(timeoutId); + delete notificationTimersRef.current[id]; + } + }, []); + + const pushNotification = useCallback( + (notification) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const entry = { + id, + createdAt: new Date().toISOString(), + duration: 9000, + variant: 'info', + ...notification, + }; + setNotifications((prev) => [...prev, entry]); + + if (entry.duration !== 0) { + const timeoutId = setTimeout(() => { + removeNotification(id); + }, entry.duration || 9000); + notificationTimersRef.current[id] = timeoutId; + } + }, + [removeNotification] + ); + + useEffect(() => () => { + Object.values(notificationTimersRef.current).forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + notificationTimersRef.current = {}; + }, []); + + useEffect(() => { + const storedToken = loadAuthToken(); + if (storedToken) { + setToken(storedToken); + } + }, []); + + const logout = useCallback(() => { + clearAuthToken(); + setToken(null); + setUser(null); + setError(null); + setNotifications([]); + Object.values(notificationTimersRef.current).forEach((timeoutId) => { + clearTimeout(timeoutId); + }); + notificationTimersRef.current = {}; + setLeadSnapshot(null); + writeLeadSnapshot(null); + clearLastSeenLeadTime(); + }, []); + + const login = useCallback(async ({ email, password }) => { + setLoading(true); + setError(null); + try { + const payload = { + username: email, + email, + password, + }; + + const response = await loginRequest(payload); + const receivedToken = response?.access_token; + const userRole = response?.role || 'manager'; + + + if (!receivedToken) { + throw new Error('Не вдалося отримати токен доступу.'); + } + + setAuthToken(receivedToken); + setToken(receivedToken); - setUser({ email }); + + setUser({ email, role: userRole }); + + try { + const leadsPayload = await getGmailLeads(); + const leads = leadsPayload?.leads ?? []; + const waitingLeads = leads.filter((lead) => ((lead.status || 'waiting').toLowerCase()) === 'waiting'); + const sortedLeads = [...leads] + .map((lead) => ({ + ...lead, + _receivedAt: parseDateOrNull(lead.received_at), + })) + .filter((lead) => lead._receivedAt) + .sort((a, b) => b._receivedAt.getTime() - a._receivedAt.getTime()); + const newestLead = sortedLeads[0] ?? null; + loginSnapshotRef.current = leadsPayload; + const lastSeenIso = readLastSeenLeadTime(); + const lastSeenDate = parseDateOrNull(lastSeenIso); + const newLeadCount = sortedLeads.reduce((acc, lead) => { + if (!lastSeenDate) { + return acc + 1; + } + return lead._receivedAt > lastSeenDate ? acc + 1 : acc; + }, 0); + + if (newestLead) { + const relative = formatRelativeTime(newestLead.received_at); + pushNotification({ + variant: newLeadCount ? 'success' : 'info', + title: newLeadCount ? 'Вам надійшли нові листи' : 'Нові листи відсутні', + message: newLeadCount + ? `${formatEmailCountMessage(newLeadCount)}${relative ? ` Останній отримано ${relative}.` : ''}` + : relative + ? `Останній лист отримано ${relative}.` + : 'Вхідні актуальні, ви нічого не пропустили.', + duration: 0, + }); + } else { + pushNotification({ + variant: 'info', + title: 'Вхідні порожні', + message: 'Наразі у вас немає листів для обробки.', + duration: 0, + }); + } + } catch (notifyError) { + console.error('Не вдалося завантажити інформацію про листи після входу', notifyError); + } + + return { success: true }; + } catch (err) { + const message = err?.message || 'Помилка авторизації.'; + setError(message); + return { success: false, error: message }; + } finally { + setLoading(false); + } + }, []); + + const updateLeadSnapshot = useCallback( + (payload, { isManualRefresh = false } = {}) => { + const snapshot = payload ?? loginSnapshotRef.current ?? null; + setLeadSnapshot(snapshot); + writeLeadSnapshot(snapshot); + + const leadsArray = snapshot?.leads ?? []; + const newestDisplayed = leadsArray + .map((lead) => parseDateOrNull(lead.received_at)) + .filter(Boolean) + .sort((a, b) => b.getTime() - a.getTime())[0]; + + if (newestDisplayed) { + writeLastSeenLeadTime(newestDisplayed.toISOString()); + } else if (snapshot) { + writeLastSeenLeadTime(new Date().toISOString()); + } + + if (isManualRefresh) { + loginSnapshotRef.current = snapshot; + } + }, + [] + ); + + const value = useMemo( + () => ({ + token, + user, + loading, + error, + isAuthenticated: Boolean(token), + login, + logout, + clearError: () => setError(null), + notifications, + pushNotification, + removeNotification, + leadSnapshot, + updateLeadSnapshot, + }), + [ + token, + user, + loading, + error, + login, + logout, + notifications, + pushNotification, + removeNotification, + leadSnapshot, + updateLeadSnapshot, + ] + ); + + return {children}; + }; + + export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth мусить використовуватися всередині AuthProvider'); + } + return context; + }; + diff --git a/gradient-frontend/src/pages/Automation.js b/gradient-frontend/src/pages/Automation.js index cb968e6..35e4dac 100644 --- a/gradient-frontend/src/pages/Automation.js +++ b/gradient-frontend/src/pages/Automation.js @@ -6,347 +6,689 @@ import { useAuth } from '../context/AuthContext'; import { useModalManager } from '../context/ModalManagerContext'; const PageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 2.5rem; + height: 100%; + padding-bottom: 2rem; + `; + + const PageHeader = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; + `; + + const TitleBlock = styled.div` + display: flex; + flex-direction: column; + gap: 0.6rem; + + h1 { + margin: 0; + font-size: 2.6rem; + letter-spacing: -0.02em; + } + + p { + margin: 0; + color: ${({ theme }) => theme.colors.subtleText}; + font-size: 1rem; + max-width: 46ch; + } + `; + + const HeaderActions = styled.div` + display: flex; + align-items: flex-end; + margin-left: auto; + `; + + const RefreshButton = styled.button` + background: linear-gradient(135deg, #5e7dfd, #9c6dff); + border: none; + color: #fff; + padding: 0.75rem 1.5rem; + font-size: 0.95rem; + border-radius: 999px; + cursor: pointer; + box-shadow: 0 12px 30px rgba(111, 125, 255, 0.35); + transition: transform 0.18s ease, box-shadow 0.18s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 16px 34px rgba(111, 125, 255, 0.42); + } + + &:disabled { + opacity: 0.5; + cursor: default; + transform: none; + box-shadow: none; + } + `; + + const SummaryGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1.5rem; + `; + + const SummaryCard = styled.div` + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 18px; + padding: 1.4rem 1.6rem; + border: 1px solid rgba(255, 255, 255, 0.06); + display: flex; + flex-direction: column; + gap: 0.35rem; + position: relative; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + inset: auto auto -40% auto; + width: 120%; + height: 120%; + background: radial-gradient(ellipse at bottom right, rgba(90, 105, 255, 0.15), transparent 65%); + pointer-events: none; + } + + span { + font-size: 0.9rem; + color: ${({ theme }) => theme.colors.subtleText}; + } + + strong { + font-size: 2.2rem; + letter-spacing: -0.01em; + } + + small { + color: ${({ theme }) => theme.colors.subtleText}; + font-size: 0.9rem; + } + `; + + const ControlsRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + `; + + const Filters = styled.div` + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + `; + + const SearchInput = styled.input` + background: ${({ theme }) => theme.colors.cardBackground}; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + padding: 0.65rem 1.1rem; + min-width: 220px; + color: ${({ theme }) => theme.colors.text}; + transition: border 0.18s ease, box-shadow 0.18s ease; + + &::placeholder { + color: ${({ theme }) => theme.colors.subtleText}; + } + + &:focus { + outline: none; + border: 1px solid rgba(104, 123, 255, 0.6); + box-shadow: 0 0 0 6px rgba(104, 123, 255, 0.18); + } + `; + + const Select = styled.select` + background: ${({ theme }) => theme.colors.cardBackground}; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + padding: 0.65rem 1.1rem; + color: ${({ theme }) => theme.colors.text}; + min-width: 170px; + + &:focus { + outline: none; + border: 1px solid rgba(104, 123, 255, 0.55); + } + `; + + const RangeSelector = styled.div` + position: relative; + `; + + const RangeButton = styled.button` + display: flex; + align-items: center; + gap: 0.45rem; + background: ${({ theme }) => theme.colors.cardBackground}; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + padding: 0.65rem 1.1rem; + color: ${({ theme }) => theme.colors.text}; + cursor: pointer; + font-size: 0.95rem; + transition: border 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + + &:hover { + border: 1px solid rgba(104, 123, 255, 0.5); + box-shadow: 0 0 0 4px rgba(104, 123, 255, 0.18); + } + + span.toggle { + font-size: 0.75rem; + opacity: 0.7; + transform: translateY(-1px); + } + `; + + const RangeDropdown = styled.div` + position: absolute; + top: calc(100% + 0.55rem); + left: 0; + background: ${({ theme }) => theme.colors.cardBackground}; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + box-shadow: 0 12px 28px ${({ theme }) => theme.colors.shadow}; + padding: 0.45rem; + min-width: 200px; + z-index: 8; + `; + + const RangeOption = styled.button` + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.22rem; + border: none; + border-radius: 12px; + padding: 0.6rem 0.75rem; + background: ${({ $active }) => ($active ? 'rgba(104, 123, 255, 0.22)' : 'transparent')}; + color: ${({ theme }) => theme.colors.text}; + cursor: pointer; + font-size: 0.9rem; + transition: background 0.18s ease; + + &:hover { + background: rgba(104, 123, 255, 0.3); + } + + span { + font-size: 0.78rem; + color: ${({ theme }) => theme.colors.subtleText}; + } + `; + + const LeadsPanel = styled.div` + flex: 1; + display: flex; + flex-direction: column; + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 22px; + border: 1px solid rgba(255, 255, 255, 0.06); + overflow: hidden; + min-height: 360px; + `; + + const PanelHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1.3rem 1.6rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + + h2 { + margin: 0; + font-size: 1.3rem; + letter-spacing: 0.01em; + } + + span { + color: ${({ theme }) => theme.colors.subtleText}; + font-size: 0.95rem; + } + `; + + const TableWrapper = styled.div` + overflow: auto; + max-height: 520px; + `; + + const LeadsTable = styled.table` + width: 100%; + border-collapse: collapse; + min-width: 740px; + + thead { + background: rgba(255, 255, 255, 0.03); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.12em; + } + + th { + text-align: left; + padding: 0.85rem 1.4rem; + color: ${({ theme }) => theme.colors.subtleText}; + } + + tbody tr { + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + transition: background 0.15s ease, transform 0.15s ease; + cursor: pointer; + background: var(--row-bg, transparent); + } + + tbody tr:hover { + background: rgba(104, 123, 255, 0.08); + transform: translateY(-1px); + } + + td { + padding: 1.1rem 1.4rem; + vertical-align: top; + } + `; + + const LeadName = styled.div` + font-weight: 600; + font-size: 1rem; + `; + + const LeadEmail = styled.div` + font-size: 0.9rem; + color: ${({ theme }) => theme.colors.subtleText}; + margin-top: 0.25rem; + `; + + const LeadSubject = styled.div` + font-size: 0.95rem; + color: ${({ theme }) => theme.colors.text}; + `; + + const LeadMeta = styled.div` + margin-top: 0.4rem; + font-size: 0.85rem; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const BADGE_VARIANTS = { + confirmed: { + color: '#15803d', + background: '#ffffff', + border: '1px solid rgba(21, 128, 61, 0.28)', + }, + rejected: { + color: '#be123c', + background: '#ffffff', + border: '1px solid rgba(190, 18, 60, 0.3)', + }, + snoozed: { + color: '#b45309', + background: '#ffffff', + border: '1px solid rgba(180, 83, 9, 0.28)', + }, + qualified: { + color: '#2563eb', + background: '#ffffff', + border: '1px solid rgba(37, 99, 235, 0.26)', + }, + new: { + color: '#1f2937', + background: '#ffffff', + border: '1px solid rgba(31, 41, 55, 0.16)', + }, + waiting: { color: '#475569', background: '#ffffff', @@ -363,848 +705,1687 @@ const BADGE_VARIANTS = { const StatusBadge = styled.button` border-radius: 999px; + padding: 0.35rem 0.85rem; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; cursor: pointer; font-family: inherit; appearance: none; ${({ $variant }) => { + const preset = BADGE_VARIANTS[$variant] ?? BADGE_VARIANTS.new; + return ` + color: ${preset.color}; + background: ${preset.background}; + border: ${preset.border}; + `; + }} + `; + + const EmptyState = styled.div` + padding: 3rem; + text-align: center; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const StatusBanner = styled.div` + background: rgba(104, 123, 255, 0.1); + border: 1px solid rgba(104, 123, 255, 0.3); + border-radius: 18px; + padding: 1rem 1.3rem; + color: ${({ theme }) => theme.colors.text}; + font-size: 0.95rem; + `; + + const ErrorBanner = styled(StatusBanner)` + background: rgba(255, 112, 162, 0.12); + border-color: rgba(255, 112, 162, 0.4); + `; + + const ModalOverlay = styled.div` + position: fixed; + inset: 0; + background: rgba(9, 10, 22, 0.72); + backdrop-filter: ${({ $shifted }) => ($shifted ? 'none' : 'blur(6px)')}; + display: flex; + align-items: center; + justify-content: ${({ $shifted }) => ($shifted ? 'flex-start' : 'center')}; + padding: ${({ $shifted }) => ($shifted ? '2rem clamp(10vw, 18vw, 26vw) 2rem 2rem' : '2rem')}; + z-index: 40; + transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + ${({ $shifted }) => $shifted && ` + transform: translateX(-2%); + opacity: 1; + `} + `; + + const ModalContent = styled.div` + width: ${({ $shifted }) => ($shifted ? 'min(1120px, 62vw)' : 'min(1460px, 96vw)')}; + height: min(94vh, 940px); + max-height: min(94vh, 940px); + overflow: hidden; + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35); + padding: 0; + position: relative; + display: flex; + flex-direction: column; + transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94); + backdrop-filter: ${({ $shifted }) => $shifted ? 'none' : 'blur(0)'}; + ${({ $shifted }) => $shifted && ` + transform: scale(0.97); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.28); + `} + + @media (max-width: 1560px) { + width: ${({ $shifted }) => ($shifted ? 'min(1020px, 66vw)' : 'min(1320px, 94vw)')}; + } + + @media (max-width: 1320px) { + width: ${({ $shifted }) => ($shifted ? 'min(920px, 70vw)' : 'min(1120px, 92vw)')}; + } + + @media (max-width: 1180px) { + width: min(960px, 92vw); + } + + @media (max-width: 1040px) { + width: min(880px, 94vw); + } + + @media (max-width: 900px) { + width: min(780px, 96vw); + } + `; + + const ModalMain = styled.div` + position: relative; + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + `; + + const ModalCloseButton = styled.button` + position: absolute; + top: 1.2rem; + right: 1.2rem; + width: 36px; + height: 36px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.08); + color: white; + font-size: 1.4rem; + font-weight: 300; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + z-index: 10; + + &:hover { + background: rgba(255, 255, 255, 0.16); + border-color: rgba(255, 255, 255, 0.24); + transform: scale(1.05); + } + + &:active { + transform: scale(0.95); + } + `; + + const ModalHeader = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 2rem 2.2rem 1.4rem; + position: sticky; + top: 0; + z-index: 1; + background: ${({ theme }) => theme.colors.cardBackground}; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + `; + + const ModalTitle = styled.h3` + margin: 0; + font-size: 1.4rem; + letter-spacing: -0.01em; + `; + + const ModalMeta = styled.div` + display: grid; + gap: 0.6rem; + font-size: 0.94rem; + `; + + const MetaLine = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + align-items: baseline; + `; + + const MetaLabel = styled.span` + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.subtleText}; + `; -const MetaValue = styled.span` + + +const MetaValue = styled.span` + font-weight: 600; + color: ${({ theme }) => theme.colors.text}; + `; + + const MetaHint = styled.span` + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const ModalScroller = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 0.2rem; + padding: 0 2.2rem 2.4rem; + margin-right: -0.2rem; + `; + + const ModalBody = styled.div` + margin-top: 1.6rem; + background: rgba(255, 255, 255, 0.04); + border-radius: 18px; + padding: 1.6rem 1.8rem 1.8rem; + color: ${({ theme }) => theme.colors.text}; + font-size: 0.98rem; + line-height: 1.6; + overflow: hidden; + `; + + const ModalSections = styled.div` + display: grid; + grid-template-columns: 1fr; + gap: 1.6rem; + min-height: 280px; + `; + + const InsightPanel = styled.div` + display: flex; + flex-direction: column; + gap: 1.2rem; + `; + + const InsightSection = styled.div` + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 18px; + padding: 1.15rem 1.3rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.8rem; + `; + + const SectionTitle = styled.h4` + margin: 0; + font-size: 1.05rem; + letter-spacing: -0.01em; + `; + + const SectionHint = styled.span` + font-size: 0.82rem; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const InfoGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0.8rem 1.1rem; + `; + + const InfoRow = styled.div` + display: flex; + flex-direction: column; + gap: 0.25rem; + `; + + const InfoLabel = styled.span` + font-size: 0.75rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const InfoValue = styled.span` + font-size: 0.95rem; + font-weight: 500; + `; + + const SummaryBlock = styled.div` + margin-top: 1.2rem; + padding: 1rem 1.15rem; + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + flex-direction: column; + gap: 0.5rem; + `; + + const SummaryLabel = styled.span` + font-size: 0.78rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const SummaryText = styled.p` + margin: 0; + color: ${({ theme }) => theme.colors.text}; + font-size: 0.94rem; + line-height: 1.55; + white-space: pre-wrap; + `; + + const SummaryHint = styled.span` + font-size: 0.82rem; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const TagRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + `; + + const TagChip = styled.span` + border-radius: 999px; + padding: 0.35rem 0.8rem; + background: rgba(104, 123, 255, 0.18); + color: ${({ theme }) => theme.colors.text}; + font-size: 0.78rem; + letter-spacing: 0.02em; + `; + + const BadgeColumn = styled.div` + display: flex; + flex-direction: column; + gap: 0.35rem; + align-items: flex-start; + `; + + const DecisionNote = styled.span` + font-size: 0.82rem; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const SearchResults = styled.div` + display: flex; + flex-direction: column; + gap: 0.9rem; + margin-top: 1rem; + `; + + const SearchResultCard = styled.div` + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 0.9rem 1rem 1rem; + display: flex; + flex-direction: column; + gap: 0.4rem; + + strong { + font-size: 0.95rem; + letter-spacing: -0.005em; + } + + span { + font-size: 0.85rem; + color: ${({ theme }) => theme.colors.subtleText}; + } + + a { + font-size: 0.82rem; + color: ${({ theme }) => theme.colors.accent || '#6f7dff'}; + word-break: break-word; + } + `; + + const ReaderOverlay = styled.div` + position: fixed; + inset: 0; + display: flex; + justify-content: flex-end; + align-items: stretch; + padding: 2rem 2.2rem 2rem 0; + pointer-events: none; + z-index: 70; + background: rgba(9, 10, 22, 0.2); + `; + + const ReaderWindow = styled.aside` + pointer-events: auto; + width: clamp(530px, 41vw, 710px); + height: 100%; + max-height: calc(100vh - 4rem); + background: ${({ theme }) => theme.colors.cardBackground}; + border-top-left-radius: 24px; + border-bottom-left-radius: 24px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-right: none; + box-shadow: -26px 0 60px rgba(6, 8, 22, 0.42); + padding: 2.1rem 2.35rem 2.3rem; + display: flex; + flex-direction: column; + gap: 1.4rem; + margin-left: -2rem; + min-height: 0; + overflow: hidden; + + @media (max-width: 1400px) { + width: clamp(470px, 44vw, 620px); + margin-left: -1.7rem; + } + + @media (max-width: 1200px) { + width: clamp(420px, 48vw, 580px); + margin-left: -1.2rem; + } + + @media (max-width: 1080px) { + width: min(420px, 92vw); + margin-left: 0; + } + + @media (max-width: 940px) { + width: min(380px, 94vw); + } + + @media (max-width: 820px) { + width: min(360px, 96vw); + } + `; + + const ReaderHeader = styled.div` + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + `; + + const ReaderTitle = styled.h3` + margin: 0; + font-size: 1.3rem; + letter-spacing: -0.01em; + line-height: 1.3; + `; + + const ReaderCloseButton = styled.button` + width: 40px; + height: 40px; + border-radius: 50%; + border: 1px solid + ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.18)' : 'rgba(255, 255, 255, 0.16)')}; + background: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.06)' : 'rgba(255, 255, 255, 0.08)')}; + color: ${({ theme }) => (theme.mode === 'light' ? '#111827' : theme.colors.text)}; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.45rem; + line-height: 1; + font-weight: 300; + cursor: pointer; + transition: background 0.18s ease, border 0.18s ease, color 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease; + + &:hover { + background: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.12)' : 'rgba(255, 255, 255, 0.14)')}; + border-color: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.28)' : 'rgba(255, 255, 255, 0.28)')}; + transform: translateY(-1px); + box-shadow: ${({ theme }) => (theme.mode === 'light' ? '0 6px 12px rgba(148, 163, 184, 0.25)' : '0 8px 18px rgba(6, 8, 22, 0.45)')}; + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.primary || '#6f7dff'}; + outline-offset: 2px; + } + `; + + const ReaderMeta = styled.div` + display: grid; + gap: 0.45rem; + font-size: 0.92rem; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const ReaderMetaRow = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: baseline; + `; + + const ReaderMetaLabel = styled.span` + font-size: 0.76rem; + letter-spacing: 0.08em; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.subtleText}; + `; + + const ReaderMetaValue = styled.span` + color: ${({ theme }) => theme.colors.text}; + font-weight: 500; + `; + + const ReaderBody = styled.div` + flex: 1; + overflow-y: auto; + padding-right: 0.8rem; + margin-right: -0.2rem; + font-size: 1rem; + line-height: 1.7; + white-space: pre-wrap; + color: ${({ theme }) => theme.colors.text}; + `; + + const ModalAlert = styled(ErrorBanner)` + margin-top: 1.4rem; + `; + + const LinkButton = styled.button` + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.16); + color: ${({ theme }) => theme.colors.text}; + border-radius: 12px; + padding: 0.55rem 0.9rem; + cursor: pointer; + transition: background 0.18s ease, border 0.18s ease; + + &:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(104, 123, 255, 0.5); + } + `; + + const ModalFooter = styled.div` + margin-top: 1.8rem; + padding-bottom: 0.4rem; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem; + `; + + const FooterSecondaryActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + `; + + const FooterPrimaryActions = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + `; + + const ReaderToggleButton = styled.button` + background: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.05)')}; + border: 1px solid + ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.2)' : 'rgba(255, 255, 255, 0.16)')}; + border-radius: 12px; + padding: 0.55rem 1rem; + color: ${({ theme }) => (theme.mode === 'light' ? '#111827' : theme.colors.text)}; + font-size: 0.9rem; + cursor: pointer; + transition: background 0.18s ease, border 0.18s ease, color 0.18s ease; + + &:hover { + background: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.12)' : 'rgba(104, 123, 255, 0.16)')}; + border-color: ${({ theme }) => (theme.mode === 'light' ? 'rgba(15, 23, 42, 0.32)' : 'rgba(104, 123, 255, 0.5)')}; + color: ${({ theme }) => (theme.mode === 'light' ? '#0f172a' : theme.colors.text)}; + } + `; + + const ActionButton = styled.button` + border-radius: 999px; + padding: 0.65rem 1.5rem; + font-size: 0.95rem; + font-weight: 600; + border: none; + cursor: pointer; + transition: opacity 0.18s ease; + + ${({ $variant }) => { + switch ($variant) { + case 'confirm': + return ` + background: #20e3a2; + color: #041620; + `; + case 'reject': + return ` + background: #fa3c7a; + color: #fff; + `; + case 'generate': + return ` + background: linear-gradient(135deg, #5e7dfd, #9c6dff); + color: #fff; + `; + case 'snooze': + default: + return ` + background: #ffb347; + color: #3d1a00; + `; + } + }} + + &:hover { + opacity: 0.88; + } + + &:active { + opacity: 0.78; + } + + &:disabled { + opacity: 0.55; + cursor: not-allowed; + } + `; + + const ReplyComposerOverlay = styled.div` + position: fixed; + inset: 0; + background: rgba(6, 7, 20, 0.78); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + padding: 3rem 2.5rem; + z-index: 60; + overflow-y: auto; + + @media (max-width: 768px) { + padding: 1.5rem; + } + `; + + const ReplyComposerContent = styled.div` + width: min(880px, calc(100% - 3rem)); + min-height: clamp(520px, 78vh, 780px); + max-height: min(90vh, 820px); + background: ${({ theme }) => theme.colors.cardBackground}; + border-radius: 26px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 38px 90px rgba(0, 0, 0, 0.6); + padding: clamp(2rem, 3vw, 2.6rem); + display: flex; + flex-direction: column; + gap: 1.5rem; + overflow: hidden; + + @media (max-width: 768px) { + width: calc(100% - 1.5rem); + min-height: auto; + max-height: 92vh; + } + `; + + const ReplyComposerHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + `; + + const ReplyComposerTitle = styled.h3` + margin: 0; + font-size: 1.25rem; + `; + + const ReplyComposerClose = styled.button` + width: 38px; + height: 38px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(14, 18, 32, 0.68); + color: ${({ theme }) => theme.colors.text}; + font-size: 1.15rem; + font-weight: 500; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: transform 0.2s ease, box-shadow 0.2s ease, opacity 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); + opacity: 0.92; + } + + &:active { + transform: scale(0.96); + } + `; + + const ReplyComposerTextarea = styled.textarea` + flex: 1; + min-height: 320px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(11, 16, 33, 0.7); + color: ${({ theme }) => theme.colors.text}; + padding: 1.3rem 1.5rem; + font-family: 'Manrope', 'Segoe UI', sans-serif; + font-size: 1.07rem; + line-height: 1.7; + resize: vertical; + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06), 0 20px 38px rgba(0, 0, 0, 0.45); + + &:focus { + outline: none; + border-color: rgba(104, 125, 255, 0.85); + box-shadow: inset 0 0 0 1px rgba(104, 125, 255, 0.74), 0 28px 48px rgba(104, 125, 255, 0.24); + } + `; + + const ReplyComposerActions = styled.div` + display: flex; + justify-content: space-between; + gap: 0.75rem; + `; + + const ReplyComposerLeftActions = styled.div` + display: flex; + align-items: center; + gap: 0.6rem; + `; + + const AttachmentButton = styled.button` + width: 44px; + height: 44px; + border-radius: 14px; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.08); + color: ${({ theme }) => theme.colors.text}; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease; + + &:hover { + transform: translateY(-1px); + background: rgba(255, 255, 255, 0.12); + border-color: rgba(104, 125, 255, 0.55); + } + + &:active { + transform: scale(0.98); + } + `; + + const AttachmentList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + `; + + const AttachmentChip = styled.span` + display: inline-flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0.6rem; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.14); + color: ${({ theme }) => theme.colors.subtleText}; + font-size: 0.82rem; + `; + + const AttachmentRemove = styled.button` + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid rgba(255, 255, 255, 0.16); + background: transparent; + color: ${({ theme }) => theme.colors.subtleText}; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + + &:hover { + color: ${({ theme }) => theme.colors.text}; + border-color: rgba(255, 255, 255, 0.28); + } + `; + + const HiddenFileInput = styled.input` + display: none; + `; + + const ReplyComposerButton = styled.button` + border-radius: 999px; + padding: 0.7rem 1.6rem; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.2px; + cursor: pointer; + transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease; + background: ${({ $primary }) => + $primary ? 'linear-gradient(135deg, #5f7bff 0%, #9a62ff 100%)' : 'rgba(255, 255, 255, 0.08)'}; + color: ${({ $primary, theme }) => ($primary ? '#fff' : theme.colors.text)}; + border: ${({ $primary }) => + $primary ? 'none' : '1px solid rgba(255, 255, 255, 0.16)'}; + box-shadow: ${({ $primary }) => + $primary ? '0 16px 32px rgba(95, 123, 255, 0.28)' : 'inset 0 1px 0 rgba(255, 255, 255, 0.06)'}; + + &:hover { + transform: translateY(-1px); + box-shadow: ${({ $primary }) => + $primary ? '0 20px 36px rgba(95, 123, 255, 0.35)' : 'inset 0 1px 0 rgba(255, 255, 255, 0.1)'}; + background: ${({ $primary }) => + $primary ? 'linear-gradient(135deg, #6b84ff 0%, #a36bff 100%)' : 'rgba(255, 255, 255, 0.12)'}; + } + + &:active { + transform: translateY(0); + } + `; + + const ReplyVariantsRow = styled.div` + display: flex; + gap: 0.6rem; + flex-wrap: wrap; + `; + + const ReplyVariantButton = styled.button` + border-radius: 14px; + padding: 0.45rem 0.9rem; + border: 1px solid ${({ theme, $active }) => ($active ? theme.colors.primary : 'rgba(255, 255, 255, 0.12)')}; + background: ${({ theme, $active }) => ($active ? 'rgba(75, 163, 255, 0.12)' : 'transparent')}; + color: ${({ theme }) => theme.colors.text}; + font-size: 0.9rem; + cursor: pointer; + + &:hover { + border-color: ${({ theme }) => theme.colors.primary}; + } + `; + + const ReplyStatusMessage = styled.p` + margin: 0; + color: ${({ $error }) => ($error ? '#ff4d4f' : '#4ba3ff')}; + font-size: 0.88rem; + `; + + const DECISION_LABELS = { + confirmed: 'Підтверджено', + rejected: 'Відхилено', + snoozed: 'Відкладено', + }; + + const DECISION_ROW_TONES = { + confirmed: 'rgba(31, 226, 155, 0.12)', + rejected: 'rgba(250, 60, 122, 0.12)', + snoozed: 'rgba(255, 179, 71, 0.16)', + }; + + const WAITING_ROW_TONE = 'rgba(190, 201, 226, 0.14)'; + + const BADGE_LABELS = { + confirmed: DECISION_LABELS.confirmed, + rejected: DECISION_LABELS.rejected, + snoozed: DECISION_LABELS.snoozed, + qualified: 'Кваліфікований', + new: 'Новий', + waiting: 'Очікує', call_lead: '📞 Дзвінок з лідом', }; @@ -1219,165 +2400,326 @@ const STATUS_TOOLTIPS = { call_lead: '🎯 ВАЖЛИВО: Потрібно зателефонувати ліду!', }; + + const getLeadKey = (lead) => { + if (!lead) return 'unknown'; + if (lead.gmail_id) return `${lead.gmail_id}`; + if (lead.id) return `${lead.id}`; + const email = (lead.email || '').trim().toLowerCase(); + if (email) return `email:${email}`; + const fallback = lead.full_name || 'lead'; + return `${fallback}-${lead.received_at || ''}`; + }; + + const getLeadStatus = (lead) => (lead?.status || 'waiting').toLowerCase(); + + const isEmptyLeadRow = (lead) => { + if (!lead) return true; + const email = (lead.email || '').trim(); + const subject = (lead.subject || '').trim(); + const body = (lead.body || '').trim(); + return !email && !subject && !body; + }; + + const getLeadCompletenessScore = (lead) => { + if (!lead) return 0; + let score = 0; + if ((lead.email || '').trim()) score += 3; + if ((lead.subject || '').trim()) score += 2; + if ((lead.body || '').trim()) score += 1; + if ((lead.full_name || '').trim()) score += 2; + if ((lead.company || lead.company_name || '').trim()) score += 1; + if ((lead.phone || '').trim()) score += 1; + if ((lead.website || '').trim()) score += 1; + return score; + }; + + const normalizeLeadInsights = (lead) => { + if (!lead) return null; + + const personLinksRaw = lead.person_links; + let personLinks = []; + if (Array.isArray(personLinksRaw)) { + personLinks = personLinksRaw.filter(Boolean); + } else if (typeof personLinksRaw === 'string' && personLinksRaw.trim()) { + personLinks = personLinksRaw + .split(';') + .map((item) => item.trim()) + .filter(Boolean); + } + + const personInsights = Array.isArray(lead.person_insights) ? lead.person_insights : []; + const companyInsights = Array.isArray(lead.company_insights) ? lead.company_insights : []; + const personSummary = lead.person_summary || ''; + const firstName = lead.first_name || ''; + const lastName = lead.last_name || ''; + + return { + ...lead, + person_links: personLinks, + person_insights: personInsights, + company_insights: companyInsights, + person_summary: personSummary, + first_name: firstName, + last_name: lastName, + }; + }; + + const RANGE_OPTIONS = [7, 14, 30]; + + const getRangeLabel = (days) => { + switch (days) { + case 7: + return '7 днів'; + case 14: + return '14 днів'; + case 30: + return '30 днів'; + default: + return `${days} днів`; + } + }; + + const useLeadData = (initialPayload, onAfterFetch) => { + const [data, setData] = useState(() => initialPayload ?? null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchData = useCallback(async (options = {}) => { + setLoading(true); + setError(null); + + try { + const response = await getGmailLeads(); + if (!response) { + throw new Error('Порожня відповідь від сервера'); + } + setData(response); + onAfterFetch?.(response, options); + return response; + } catch (err) { + const message = err instanceof Error ? err.message : 'Сталася помилка'; + setError(message); + return null; + } finally { + setLoading(false); + } + }, [onAfterFetch]); + + const refresh = useCallback((options) => fetchData(options), [fetchData]); + + return { + data, + loading, + error, + refresh, + }; + }; + + const isQualifiedLead = (lead) => { + if (!lead) return false; + return Boolean(lead.phone || lead.website || lead.company || lead.company_name); + }; + + const parseDate = (value) => { + if (!value) return null; + const normalized = value.endsWith('Z') ? value : `${value}`; + const date = new Date(normalized); + return Number.isNaN(date.getTime()) ? null : date; + }; + + const formatDate = (value, locale = 'uk-UA') => { + const date = parseDate(value); + if (!date) return '—'; + return new Intl.DateTimeFormat(locale, { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }).format(date); + }; + + const formatRelative = (value) => { + const date = parseDate(value); + if (!date) return '—'; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + if (diffDays <= 0) { + const diffHours = Math.max(1, Math.floor(diffMs / (1000 * 60 * 60))); + return `${diffHours} год тому`; + } + if (diffDays === 1) return 'Вчора'; + if (diffDays < 7) return `${diffDays} дн. тому`; + const diffWeeks = Math.floor(diffDays / 7); + if (diffWeeks < 5) return `${diffWeeks} тиж. тому`; + const diffMonths = Math.floor(diffDays / 30); + if (diffMonths < 12) return `${diffMonths} міс. тому`; + const diffYears = Math.floor(diffMonths / 12); + return `${diffYears} р. тому`; + }; + + const Automation = () => { const theme = useTheme(); const navigate = useNavigate(); @@ -1385,90 +2727,175 @@ const Automation = () => { const { activeModals, openModal, closeModal: closeGlobalModal } = useModalManager(); const { leadSnapshot, updateLeadSnapshot, pushNotification } = useAuth(); const { data, loading, error, refresh } = useLeadData(leadSnapshot, updateLeadSnapshot); + const [searchTerm, setSearchTerm] = useState(''); + const [stageFilter, setStageFilter] = useState('all'); + const [rangeFilter, setRangeFilter] = useState(30); + const [rangeMenuOpen, setRangeMenuOpen] = useState(false); + const rangeRef = useRef(null); + const [selectedLead, setSelectedLead] = useState(null); + const [decisions, setDecisions] = useState({}); + const [insightsError, setInsightsError] = useState(null); + const [statusError, setStatusError] = useState(null); + const [showReader, setShowReader] = useState(false); + const [showReplyComposer, setShowReplyComposer] = useState(false); + const [replyOptions, setReplyOptions] = useState({ quick: '', follow_up: '', recap: '' }); + const [replyOptionsByStyle, setReplyOptionsByStyle] = useState({ official: null, semi_official: null }); + const [selectedReplyKey, setSelectedReplyKey] = useState(''); + const [replyDraft, setReplyDraft] = useState(''); + const [replyLoading, setReplyLoading] = useState(false); + const [replyError, setReplyError] = useState(null); + const [replyStyle, setReplyStyle] = useState('semi_official'); + const [replyAttachments, setReplyAttachments] = useState([]); + const fileInputRef = useRef(null); + + const leads = useMemo(() => data?.leads ?? [], [data]); + + const orderedLeads = useMemo(() => { + const cleaned = (leads || []).filter((lead) => !isEmptyLeadRow(lead)); + return cleaned + .map((lead) => ({ + ...lead, + _receivedAt: parseDate(lead.received_at), + _score: getLeadCompletenessScore(lead), + })) + .sort((a, b) => { + if (a._score !== b._score) return b._score - a._score; + const aTime = a._receivedAt ? a._receivedAt.getTime() : 0; + const bTime = b._receivedAt ? b._receivedAt.getTime() : 0; + return bTime - aTime; + }) + .map(({ _receivedAt, _score, ...rest }) => rest); + }, [leads]); + + const dedupedLeads = useMemo(() => { + const byEmail = new Map(); + const counts = new Map(); + + const keyFor = (lead) => { + const email = (lead?.email || '').trim().toLowerCase(); + if (email) return `email:${email}`; + const gid = (lead?.gmail_id || '').trim(); + return gid ? `gmail:${gid}` : `row:${lead?.sheet_row || lead?.sheetRow || ''}`; + }; + + orderedLeads.forEach((lead) => { + const key = keyFor(lead); + counts.set(key, (counts.get(key) || 0) + 1); + + const current = byEmail.get(key); + if (!current) { + byEmail.set(key, lead); + return; + } + + const currentDate = parseDate(current.received_at); + const nextDate = parseDate(lead.received_at); + const currentTime = currentDate ? currentDate.getTime() : 0; + const nextTime = nextDate ? nextDate.getTime() : 0; + + if (nextTime > currentTime) { + byEmail.set(key, lead); + return; + } + if (nextTime === currentTime) { + const currentScore = getLeadCompletenessScore(current); + const nextScore = getLeadCompletenessScore(lead); + if (nextScore > currentScore) byEmail.set(key, lead); + } + }); + + return Array.from(byEmail.entries()) + .map(([key, lead]) => ({ ...lead, _messagesFromEmail: counts.get(key) || 1 })) + .sort((a, b) => { + const aTime = (parseDate(a.received_at)?.getTime() || 0); + const bTime = (parseDate(b.received_at)?.getTime() || 0); + return bTime - aTime; + }); + }, [orderedLeads]); const handleRowClick = useCallback(async (lead) => { @@ -1513,19 +2940,33 @@ const Automation = () => { }, [location.state, dedupedLeads, handleRowClick]); const filteredLeads = useMemo(() => { + const text = searchTerm.trim().toLowerCase(); + const now = new Date(); + const rangeLimitMs = rangeFilter ? rangeFilter * 24 * 60 * 60 * 1000 : null; + + return dedupedLeads.filter((lead) => { + const leadDate = parseDate(lead.received_at); + if (rangeLimitMs && leadDate) { + if (now.getTime() - leadDate.getTime() > rangeLimitMs) { + return false; + } + } + + const qualified = isQualifiedLead(lead); + const decisionStatus = decisions[getLeadKey(lead)]?.status; const status = (decisionStatus ?? getLeadStatus(lead) ?? '').toLowerCase(); const badgeVariantForFilter = @@ -1543,121 +2984,238 @@ const Automation = () => { if (!text) return true; + + const haystack = [ + lead.full_name, + lead.first_name, + lead.last_name, + lead.email, + lead.subject, + lead.company, + lead.company_name, + lead.body, + lead.person_summary, + lead.company_info, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + + return haystack.includes(text); + }); + }, [dedupedLeads, stageFilter, searchTerm, rangeFilter, decisions]); + + const summary = data?.stats ?? {}; + + const { + totalLeads, + waitingCount, + confirmedCount, + rejectedCount, + processedCount, + processedPercentage, + qualifiedCount, + } = useMemo(() => { + let waiting = 0; + let confirmed = 0; + let rejected = 0; + let qualified = 0; + + dedupedLeads.forEach((lead) => { + const decisionStatus = decisions[getLeadKey(lead)]?.status; + const status = (decisionStatus ?? getLeadStatus(lead) ?? '').toLowerCase(); + + if (status === 'waiting') { + waiting += 1; + } else if (status === 'confirmed') { + confirmed += 1; + } else if (status === 'rejected') { + rejected += 1; + } + + if (isQualifiedLead(lead)) { + qualified += 1; + } + }); + + const total = dedupedLeads.length; + const processed = confirmed + rejected; + const processedPct = total ? Math.round((processed / total) * 100) : 0; + + return { + totalLeads: total, + waitingCount: waiting, + confirmedCount: confirmed, + rejectedCount: rejected, + processedCount: processed, + processedPercentage: processedPct, + qualifiedCount: qualified, + }; + }, [dedupedLeads, decisions]); + + const qualifiedShare = totalLeads ? Math.round((qualifiedCount / totalLeads) * 100) : 0; + const waitingShare = totalLeads ? Math.round((waitingCount / totalLeads) * 100) : 0; + + const selectedInsights = useMemo(() => normalizeLeadInsights(selectedLead), [selectedLead]); + const selectedPerson = selectedInsights?.person_insights?.[0]; + const selectedCompanyInsights = selectedInsights?.company_insights ?? []; + const selectedCompanySummary = useMemo(() => { + if (!selectedInsights && !selectedLead) return ''; + return ( + selectedInsights?.company_summary || + selectedInsights?.company_info || + selectedLead?.company_info || + '' + ); + }, [selectedInsights, selectedLead]); + + const closeModal = useCallback(() => { + setSelectedLead(null); + setShowReader(false); + setShowReplyComposer(false); + setInsightsError(null); + setReplyOptions({ quick: '', follow_up: '', recap: '' }); + setReplyOptionsByStyle({ official: null, semi_official: null }); + setSelectedReplyKey(''); + setReplyDraft(''); + setReplyError(null); + setReplyStyle('semi_official'); + setReplyAttachments([]); + }, []); + + const toggleRangeMenu = useCallback(() => { + setRangeMenuOpen((prev) => !prev); + }, []); + + const toggleReader = useCallback(() => { + setShowReader((prev) => !prev); + }, []); + + const closeReplyComposer = useCallback(() => { + setShowReplyComposer(false); + setReplyDraft(''); + setSelectedReplyKey(''); + setReplyOptions({ quick: '', follow_up: '', recap: '' }); + setReplyOptionsByStyle({ official: null, semi_official: null }); + setReplyError(null); + setReplyStyle('semi_official'); + setReplyAttachments([]); + }, []); const closeLocalModal = useCallback(() => { @@ -1672,112 +3230,213 @@ const Automation = () => { }, []); const handlePickAttachments = useCallback(() => { + fileInputRef.current?.click?.(); + }, []); + + const handleFilesSelected = useCallback((event) => { + const files = Array.from(event.target.files || []); + if (!files.length) return; + setReplyAttachments((prev) => { + const next = [...prev]; + files.forEach((f) => { + const id = `${f.name}-${f.size}-${f.lastModified}`; + if (!next.some((x) => x.id === id)) next.push({ id, file: f }); + }); + return next; + }); + event.target.value = ''; + }, []); + + const removeAttachment = useCallback((id) => { + setReplyAttachments((prev) => prev.filter((x) => x.id !== id)); + }, []); + + const handleSelectRange = (value) => { + setRangeFilter(value); + setRangeMenuOpen(false); + }; const handleRowKeyDown = (event, lead) => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleRowClick(lead); } }; + + const handleSelectReplyOption = (key) => { + setSelectedReplyKey(key); + setReplyDraft(replyOptions[key] || ''); + }; + + const handleGenerateReplies = useCallback(async (nextStyle, options = {}) => { + if (!selectedLead) return; + setReplyLoading(true); + setReplyError(null); + try { + const styleToUse = nextStyle || replyStyle || 'semi_official'; + if (nextStyle) { + setReplyStyle(nextStyle); + } + const cached = replyOptionsByStyle?.[styleToUse]; + if (cached && !options?.force) { + setReplyOptions(cached); + const priorityOrder = ['quick', 'follow_up', 'recap']; + const populatedKeys = priorityOrder.filter((key) => (cached[key] || '').trim()); + const primaryKey = populatedKeys[0] || ''; + const preserveSelectedKey = Boolean(options?.preserveSelectedKey); + const preferredKey = + preserveSelectedKey && selectedReplyKey && (cached[selectedReplyKey] || '').trim() ? selectedReplyKey : primaryKey; + setSelectedReplyKey(preferredKey); + setReplyDraft(preferredKey ? cached[preferredKey] : ''); + return; + } + const response = await postGenerateReplies({ + sender: selectedLead.email || 'unknown@example.com', + subject: selectedLead.subject || '', + body: selectedLead.body || '', + lead: selectedLead, + style: styleToUse, + }); + + const replies = response?.replies || {}; + const normalizedReplies = { + quick: typeof replies.quick === 'string' ? replies.quick : '', + follow_up: typeof replies.follow_up === 'string' ? replies.follow_up : '', + recap: typeof replies.recap === 'string' ? replies.recap : '', + }; + + setReplyOptions(normalizedReplies); + setReplyOptionsByStyle((prev) => ({ ...(prev || {}), [styleToUse]: normalizedReplies })); + + const priorityOrder = ['quick', 'follow_up', 'recap']; + const populatedKeys = priorityOrder.filter((key) => (normalizedReplies[key] || '').trim()); + const primaryKey = populatedKeys[0] || ''; + + const preserveSelectedKey = Boolean(options?.preserveSelectedKey); + const preferredKey = + preserveSelectedKey && selectedReplyKey && (normalizedReplies[selectedReplyKey] || '').trim() + ? selectedReplyKey + : primaryKey; + + setSelectedReplyKey(preferredKey); + setReplyDraft(preferredKey ? normalizedReplies[preferredKey] : ''); + setShowReplyComposer(true); + if (!populatedKeys.length) { + setReplyError('Модель не повернула відповідей. Спробуйте пізніше.'); + } else if (populatedKeys.length === 1) { + setReplyError('Згенеровано лише один варіант. Перевірте шаблони у налаштуваннях.'); + } + } catch (error) { + setReplyError(error?.message || 'Не вдалося згенерувати відповідь.'); + setShowReplyComposer(true); + } finally { + setReplyLoading(false); + } + }, [selectedLead, replyStyle, selectedReplyKey, replyOptionsByStyle]); + + const refreshAndSync = useCallback(async () => { + await refresh({ isManualRefresh: true }); + }, [refresh]); const handleDecisionWithReason = useCallback( @@ -1838,103 +3497,32 @@ const Automation = () => { ); const handleConfirmClick = useCallback(async () => { + if (!selectedLead || replyLoading) return; + // Open immediately, then fill content as it arrives. + setShowReplyComposer(true); + setReplyError(null); + setReplyDraft(''); + setReplyOptions({ quick: '', follow_up: '', recap: '' }); + setSelectedReplyKey('quick'); + // Force fetch to ensure latest Settings (top/bottom blocks + prompts) are applied. - void handleGenerateReplies(replyStyle, { preserveSelectedKey: true, force: true }); - // Pre-warm the other style in background for instant toggle. - const otherStyle = replyStyle === 'official' ? 'semi_official' : 'official'; - void handleGenerateReplies(otherStyle, { preserveSelectedKey: true }); - }, [selectedLead, replyLoading, handleGenerateReplies, replyStyle]); - const handleDecision = useCallback( - async (status) => { - if (!selectedLead) return; + void handleGenerateReplies(replyStyle, { preserveSelectedKey: true, force: true }); - if (status === 'rejected') { - openModal({ - id: 'reject-form', - type: 'reject_modal', - props: { - title: 'Відхилення ліда', - content: ( -
-

- Лід: {selectedLead.full_name || selectedLead.email} -

-

- Будь ласка, вкажіть причину відхилення -

-