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 (
+
+
+
+
(isActive ? 'active' : '')}>Аналітика
(isActive ? 'active' : '')}>Робоча зона
+
+
+
+
+
+
+
+
{(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}
-
-
- Будь ласка, вкажіть причину відхилення
-
-
-
- closeGlobalModal('reject-form')}
- style={{
- padding: '0.5rem 1rem',
- border: `1px solid ${theme.colors.border}`,
- borderRadius: '6px',
- background: 'transparent',
- color: theme.colors.text,
- cursor: 'pointer',
- }}
- type="button"
- >
- Скасувати
-
- {
- const reason = document.getElementById('rejection-reason').value.trim();
- if (!reason) {
- alert('Будь ласка, вкажіть причину відхилення');
- return;
- }
- closeGlobalModal('reject-form');
- handleDecisionWithReason(status, reason);
- }}
- style={{
- padding: '0.5rem 1rem',
- border: 'none',
- borderRadius: '6px',
- background: '#be123c',
- color: 'white',
- cursor: 'pointer',
- }}
- type="button"
- >
- Відхилити
-
-
-
- ),
- },
- });
- return;
- }
+ // Pre-warm the other style in background for instant toggle.
- handleDecisionWithReason(status, null);
- },
- [selectedLead, openModal, closeGlobalModal, theme, handleDecisionWithReason]
- );
+ const otherStyle = replyStyle === 'official' ? 'semi_official' : 'official';
+ void handleGenerateReplies(otherStyle, { preserveSelectedKey: true });
+
+ }, [selectedLead, replyLoading, handleGenerateReplies, replyStyle]);
const handleSendReply = useCallback(async () => {
if (!selectedLead || !replyDraft.trim()) {
setReplyError('Будь ласка, напишіть текст відповіді');
@@ -1950,7 +3538,7 @@ const Automation = () => {
};
await sendEmailWithAttachments(payload);
-
+
pushNotification({
variant: 'success',
title: 'Лист надіслано',
@@ -1961,73 +3549,129 @@ const Automation = () => {
setReplyDraft('');
setReplyAttachments([]);
setShowReplyComposer(false);
-
+
// Оновимо статус ліда
await handleDecisionWithReason('confirmed', null);
-
+
} catch (error) {
setReplyError(error?.message || 'Не вдалося надіслати лист');
}
}, [selectedLead, replyDraft, replyAttachments, pushNotification, handleDecisionWithReason]);
return (
+
+
+
Робоча зона
+
Центральна панель для керування вхідними лідами. Відслідковуйте активність, підтвердження відповіді GPT й людини
+
та структуруйте фокус команди за хвилини.
+
+
+
+
+
Оновити дані
+
+
+
+
+
{loading && Завантаження даних по лідах… }
+
{error && Не вдалося отримати інформацію: {error} }
+
+
+
+
Активні за {getRangeLabel(rangeFilter)}
+
{summary.active ?? 0}
+
Лідів, які відповіли останнім часом
+
+
+
Всього лідів
+
{totalLeads}
+
Синхронізовано з Gmail та Sheets
+
+
+
Очікують дії
+
{waitingCount}
+
{waitingShare}% від загальної кількості
+
+
+
Опрацьовано
+
{processedCount}
+
+
{processedPercentage}% від усіх • Прийнято: {confirmedCount} • Відхилено: {rejectedCount}
+
+
+
+
Кваліфіковані
+
{qualifiedCount}
+
{qualifiedShare}% мають контактні дані чи компанію
+
+
+
+
+
+
setSearchTerm(event.target.value)}
+
/>
+
setStageFilter(event.target.value)}>
Усі статуси
Нові
@@ -2038,64 +3682,123 @@ const Automation = () => {
Відхилено
Відкладено
+
+
+
Період: {getRangeLabel(rangeFilter)}
+
{rangeMenuOpen ? '▴' : '▾'}
+
+
{rangeMenuOpen && (
+
+
{RANGE_OPTIONS.map((days) => (
+
handleSelectRange(days)}
+
$active={rangeFilter === days}
+
>
+
{`Останні ${getRangeLabel(days)}`}
+
+
{days === 7 ? 'Фокус на тиждень' : days === 14 ? 'Двотижневий перегляд' : 'Місячна активність'}
+
+
+
))}
+
+
)}
+
+
+
+
Показано {filteredLeads.length} / {dedupedLeads.length}
+
+
+
+
+
+
Список лідів
+
Автоматично оновлюється після синхронізації Gmail
+
+
+
+
{filteredLeads.length === 0 ? (
+
+
Немає лідів, що відповідають вибраним фільтрам. Змініть фільтри або запустіть синхронізацію.
+
+
) : (
+
+
+
+
Лід
+
Компанія
+
Повідомлення
+
Оновлено
+
Статус
+
+
+
+
{filteredLeads.map((lead, index) => {
+
const qualified = isQualifiedLead(lead);
+
const key = getLeadKey(lead);
+
const decision = decisions[key];
+
const leadStatus = getLeadStatus(lead);
+
const decisionStatus = decision?.status;
+
const resolvedStatus = decisionStatus ?? leadStatus;
const badgeVariant =
resolvedStatus === 'call_lead'
@@ -2108,15 +3811,25 @@ const Automation = () => {
? 'qualified'
: 'new';
const badgeLabel = BADGE_LABELS[badgeVariant] ?? BADGE_LABELS.new;
+
const rowStyle = DECISION_ROW_TONES[resolvedStatus]
+
? { '--row-bg': DECISION_ROW_TONES[resolvedStatus] }
+
: resolvedStatus === 'waiting'
+
? { '--row-bg': WAITING_ROW_TONE }
+
: undefined;
+
const displayName = lead.full_name || [lead.first_name, lead.last_name].filter(Boolean).join(' ') || 'Невідомий контакт';
+
const companySummary = lead.company_info;
+
+
return (
+
{
role="button"
tabIndex={0}
>
+
+
{displayName}
+
{lead.email || 'email не вказано'}
+
{lead._messagesFromEmail > 1 && Останній лист • ще {lead._messagesFromEmail - 1} з цього email }
+
{lead.person_summary && {lead.person_summary} }
+
+
+
{lead.company || lead.company_name || '—'}
+
{lead.website || 'Сайт не вказано'}
+
{companySummary && {companySummary} }
+
+
+
{lead.subject || 'Без теми'}
+
+
{(lead.body || '').slice(0, 120)}
+
{(lead.body || '').length > 120 ? '…' : ''}
+
+
+
+
{formatDate(lead.received_at)}
+
{formatRelative(lead.received_at)}
+
+
+
- {
{badgeLabel}
{decision && (
+
+
{`Рішення: ${DECISION_LABELS[decision.status]} • ${formatRelative(decision.decidedAt)}`}
+
+
)}
+
+
+
+
);
+
})}
+
+
+
)}
+
-
- {activeModals
- .filter((modal) => modal?.type === 'reject_modal')
- .map((modal) => (
- closeGlobalModal(modal.id)}
- $shifted={false}
- >
- event.stopPropagation()}
- $shifted={false}
- style={{ height: 'min(86vh, 560px)' }}
- >
-
- closeGlobalModal(modal.id)} aria-label="Закрити">
- ×
-
-
- {modal?.props?.title || 'Відхилення'}
-
-
- {modal?.props?.content}
-
-
-
-
- ))}
+
{selectedLead && (
-
+
event.stopPropagation()}
$shifted={showReader}
+ role="dialog"
+ aria-modal="true"
+ $expanded={showReader}
>
- ×
+ ×
+
{selectedLead.subject || 'Без теми'}
+
+
+
Від
+
{selectedLead.full_name || 'Невідомий контакт'}
+
{selectedLead.email && ({selectedLead.email}) }
+
+
{(selectedLead.company || selectedLead.company_name) && (
+
+
Компанія
+
{selectedLead.company || selectedLead.company_name}
+
{selectedLead.website && {selectedLead.website} }
+
+
)}
+
+
Отримано
+
{formatDate(selectedLead.received_at)}
+
{formatRelative(selectedLead.received_at)}
+
+
+
Телефон
+
{selectedLead.phone || 'Телефон не вказано'}
+
+
+
+
+
+
+
+
{insightsError ? (
+
Не вдалося завантажити інсайти: {insightsError}
+
) : (
+
<>
+
+
Профіль контакту
+
Автоматична довідка за ім'ям та листом
+
+
+
Ім'я
+
+
{selectedInsights?.full_name || [selectedInsights?.first_name, selectedInsights?.last_name]
+
.filter(Boolean)
+
.join(' ') || selectedPerson?.title || '—'}
+
+
+
{(selectedInsights?.first_name || selectedInsights?.last_name) && (
+
+
Ім'я / Прізвище
+
+
{[selectedInsights?.first_name, selectedInsights?.last_name].filter(Boolean).join(' ') || '—'}
+
+
+
)}
+
+
Роль
+
{selectedInsights?.person_role || selectedPerson?.snippet || 'Потребує уточнення'}
+
+
+
Локація
+
{selectedInsights?.person_location || '—'}
+
+
+
Досвід
+
{selectedInsights?.person_experience || '—'}
+
+
+
Email
+
{selectedInsights?.email || selectedLead.email || '—'}
+
+
+
Телефон
+
{selectedInsights?.phone_number || selectedLead.phone || '—'}
+
+
+
+
Коротко
+
{selectedInsights?.person_summary ? (
+
{selectedInsights.person_summary}
+
) : (
+
Немає зведеної інформації
+
)}
+
+
{!!selectedInsights?.person_links?.length && (
+
+
{selectedInsights.person_links.slice(0, 3).map((link) => (
+
+
{link}
+
+
))}
+
+
)}
+
{!!selectedInsights?.person_insights?.length && (
+
+
{selectedInsights.person_insights.slice(0, 3).map((item, index) => (
+
+
{item.title || `Згадка ${index + 1}`}
+
{item.snippet && {item.snippet} }
+
{item.url && (
+
+
{item.url}
+
+
)}
+
+
))}
+
+
)}
+
+
+
+
Інформація про компанію
+
Підтягуємо з відкритих джерел та GPT
+
+
+
Компанія
+
+
{selectedInsights?.company || selectedLead.company || selectedLead.company_name || '—'}
+
+
+
+
Сайт
+
{selectedInsights?.website || selectedLead.website || '—'}
+
+
+
Підсумок
+
{selectedInsights?.company_summary || 'Дані не знайдено.'}
+
+
+
{(selectedInsights?.company_summary || selectedCompanySummary) && (
+
+
Коротко про компанію
+
{selectedInsights?.company_summary ? (
+
{selectedInsights.company_summary}
+
) : null}
+
{selectedCompanySummary && (
+
{selectedCompanySummary}
+
)}
+
+
)}
+
{!!selectedCompanyInsights.length && (
+
+
{selectedCompanyInsights.slice(0, 3).map((entry, index) => (
+
+
{entry.title || `Результат ${index + 1}`}
+
{entry.snippet && {entry.snippet} }
+
{entry.url && (
+
+
{entry.url}
+
+
)}
+
+
))}
+
+
)}
+
+
>
+
)}
+
+
+
+
+
+
{showReader ? 'Сховати лист' : 'Показати лист'}
+
+
{selectedLead?.email && (
+
navigate(`/lead/${encodeURIComponent(selectedLead.email)}`)}
+
>
+
Профіль ліда
+
+
)}
+
+
+
handleDecision('snoozed')}>
+
Відкласти
+
+
handleDecision('rejected')}>
+
Відхилити
+
+
{replyLoading ? 'Генеруємо...' : 'Підтвердити'}
+
+
+
{statusError && {statusError} }
+
+
+
+
+
+
)}
+
{showReader && (
+
+
+
+
+
Вміст листа
+
+
+
Від
+
+
{selectedLead.full_name || 'Невідомий контакт'}
+
+
{selectedLead.email && ({selectedLead.email}) }
+
+
{selectedLead.subject && (
+
+
Тема
+
{selectedLead.subject}
+
+
)}
+
+
Отримано
+
{formatDate(selectedLead.received_at)}
+
{formatRelative(selectedLead.received_at)}
+
+
+
+
+
×
+
+
+
{selectedLead.body || 'Повідомлення порожнє.'}
+
+
+
)}
+
+
{showReplyComposer && (
+
+
event.stopPropagation()}>
+
+
Чернетка відповіді
+
×
+
+
+
{[
+
{ key: 'official', label: 'Офіційний' },
+
{ key: 'semi_official', label: 'Напів-офіційний' },
+
].map((item) => (
+
{
+
setReplyStyle(item.key);
+
await handleGenerateReplies(item.key, { preserveSelectedKey: true });
+
}}
+
disabled={replyLoading}
+
>
+
{item.label}
+
+
))}
+
+
+
{['quick', 'follow_up', 'recap'].map((key) => (
+
handleSelectReplyOption(key)}
+
disabled={!(replyOptions[key] || '').trim()}
+
>
+
{key === 'quick' ? 'Quick' : key === 'follow_up' ? 'Follow-up' : 'Recap & Proposal'}
+
+
))}
+
+
+
{selectedReplyKey === 'quick'
+
? 'Дуже короткий варіант для швидкої відповіді.'
+
: selectedReplyKey === 'follow_up'
+
? 'Варіант м’якого фолоу-апу після знайомства.'
+
: 'Варіант з рекепом болей та пропозицією рішення.'}
+
+
{replyError && {replyError} }
+
{!replyError && !replyDraft && (
+
{replyLoading ? 'Генеруємо відповідь…' : 'Відповідь порожня — відредагуйте вручну перед надсиланням.'}
+
)}
+
setReplyDraft(event.target.value)}
+
/>
+
+
+
+
+
📎
+
+
{replyAttachments.length ? (
+
+
{replyAttachments.map((item) => (
+
+
{item.file?.name || 'file'}
+
removeAttachment(item.id)} aria-label="Видалити">
+
×
+
+
+
))}
+
+
) : null}
+
+
+
+
+
Скасувати
+
{replyLoading ? 'Надсилаємо...' : 'Відповісти та підтвердити'}
+
+
+
+
+
)}
+
+
+
);
+
};
+
+
export default Automation;
+
diff --git a/gradient-frontend/src/pages/ManagerManagement.js b/gradient-frontend/src/pages/ManagerManagement.js
new file mode 100644
index 0000000..e939c89
--- /dev/null
+++ b/gradient-frontend/src/pages/ManagerManagement.js
@@ -0,0 +1,412 @@
+import React, { useEffect, useMemo, useState } from 'react';
+import styled from 'styled-components';
+import { FiEye, FiEyeOff } from 'react-icons/fi';
+import { useAuth } from '../context/AuthContext';
+import {
+ createManager,
+ deleteManager,
+ getManagers,
+ setManagerStatus,
+} from '../api/client';
+
+const PageWrapper = styled.section`
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ padding: 3rem 2.5rem 4.5rem;
+
+ @media (max-width: 720px) {
+ padding: 2.4rem 1.25rem 3.2rem;
+ }
+`;
+
+const PanelGrid = styled.section`
+ width: min(1150px, 100%);
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+ gap: 2.4rem;
+`;
+
+const Panel = styled.div`
+ background: ${({ theme }) => theme.colors.cardBackground};
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ border-radius: 28px;
+ box-shadow: 0 20px 44px ${({ theme }) => theme.colors.shadow};
+ padding: clamp(2.2rem, 3vw, 3.1rem);
+ display: flex;
+ flex-direction: column;
+ gap: 1.7rem;
+`;
+
+const PanelTitle = styled.h2`
+ margin: 0;
+ font-size: 1.55rem;
+ font-weight: 600;
+ text-align: center;
+ color: ${({ theme }) => theme.colors.text};
+`;
+
+const FieldGroup = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.55rem;
+`;
+
+const FieldLabel = styled.label`
+ font-size: 0.95rem;
+ font-weight: 500;
+ color: ${({ theme }) => theme.colors.textSecondary};
+`;
+
+const InputRow = styled.div`
+ position: relative;
+ display: flex;
+ align-items: center;
+`;
+
+const FieldInput = styled.input`
+ width: 100%;
+ padding: 0.95rem 1.1rem;
+ border-radius: 18px;
+ border: 1px solid ${({ theme }) => theme.colors.border};
+ background: ${({ theme }) => (theme.mode === 'light' ? '#f8fbff' : 'rgba(12, 17, 34, 0.88)')};
+ color: ${({ theme }) => theme.colors.text};
+ font-size: 1.02rem;
+
+ &:focus {
+ outline: none;
+ border-color: rgba(104, 125, 255, 0.85);
+ box-shadow: 0 0 0 2px rgba(104, 125, 255, 0.2);
+ }
+`;
+
+const ToggleVisibility = styled.button`
+ position: absolute;
+ right: 14px;
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ color: ${({ theme }) => theme.colors.textSecondary};
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.2rem;
+`;
+
+const PrimaryButton = styled.button`
+ align-self: center;
+ padding: 0.95rem 2.2rem;
+ border-radius: 999px;
+ border: none;
+ cursor: pointer;
+ font-size: 1rem;
+ font-weight: 500;
+ background: ${({ theme }) => `linear-gradient(135deg, ${theme.colors.primary} 0%, #7b6bff 100%)`};
+ color: #fff;
+ box-shadow: 0 14px 26px rgba(75, 163, 255, 0.3);
+ transition: opacity 0.18s ease;
+
+ &:hover {
+ opacity: 0.92;
+ }
+
+ &:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ }
+`;
+
+const List = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+`;
+
+const ListItem = styled.div`
+ display: grid;
+ grid-template-columns: 48px 1fr auto;
+ gap: 1rem;
+ align-items: center;
+`;
+
+const AvatarCircle = styled.div`
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: ${({ theme }) => (theme.mode === 'light' ? '#eef2ff' : 'rgba(255,255,255,0.08)')};
+ color: ${({ theme }) => theme.colors.text};
+ font-weight: 600;
+`;
+
+const EmployeeInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+
+ h4 {
+ margin: 0;
+ font-size: 1.02rem;
+ font-weight: 600;
+ color: ${({ theme }) => theme.colors.text};
+ }
+
+ span {
+ font-size: 0.9rem;
+ color: ${({ theme }) => theme.colors.textSecondary};
+ }
+`;
+
+const Actions = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.9rem;
+ align-items: center;
+ justify-content: flex-end;
+
+ button {
+ border: none;
+ background: transparent;
+ cursor: pointer;
+ padding: 0;
+ font-size: 0.86rem;
+ color: ${({ theme }) => theme.colors.textSecondary};
+
+ &:hover {
+ color: ${({ theme }) => theme.colors.text};
+ }
+ }
+
+ button.danger {
+ color: #ff4d4f;
+
+ &:hover {
+ opacity: 0.9;
+ }
+ }
+`;
+
+const Banner = styled.p`
+ margin: 0;
+ font-size: 0.92rem;
+ color: ${({ $error, theme }) => ($error ? '#ff4d4f' : theme.colors.textSecondary)};
+`;
+
+const initialForm = { username: '', email: '', password: '' };
+
+const ManagerManagement = () => {
+ const { user } = useAuth();
+ const [form, setForm] = useState(initialForm);
+ const [showPassword, setShowPassword] = useState(false);
+ const [loading, setLoading] = useState(false);
+ const [managersLoading, setManagersLoading] = useState(false);
+ const [managers, setManagers] = useState([]);
+ const [message, setMessage] = useState(null);
+
+ const isAdmin = user?.role === 'admin';
+
+ const avatarText = useMemo(() => {
+ const name = (value) => (value || '').trim();
+ return (manager) => {
+ const username = name(manager?.username);
+ if (username) {
+ return username.split(' ').slice(0, 2).map((part) => part[0]).join('').toUpperCase();
+ }
+ const email = name(manager?.email);
+ return email ? email[0].toUpperCase() : '?';
+ };
+ }, []);
+
+ const loadManagers = async () => {
+ setManagersLoading(true);
+ setMessage(null);
+ try {
+ const data = await getManagers();
+ setManagers(data?.managers || []);
+ } catch (error) {
+ setMessage('Не вдалося завантажити менеджерів.');
+ } finally {
+ setManagersLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!isAdmin) return;
+ loadManagers();
+ }, [isAdmin]);
+
+ const handleChange = (field) => (event) => {
+ setForm((prev) => ({ ...prev, [field]: event.target.value }));
+ setMessage(null);
+ };
+
+ const handleRegister = async () => {
+ setLoading(true);
+ setMessage(null);
+ try {
+ await createManager(form);
+ setForm(initialForm);
+ setMessage('Менеджера зареєстровано.');
+ await loadManagers();
+ } catch (error) {
+ setMessage(error?.message || 'Не вдалося зареєструвати менеджера.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleToggleActive = async (manager) => {
+ setMessage(null);
+ try {
+ await setManagerStatus(manager.id, { is_active: !manager.is_active });
+ await loadManagers();
+ } catch (error) {
+ setMessage(error?.message || 'Не вдалося змінити статус менеджера.');
+ }
+ };
+
+ const handleDelete = async (manager) => {
+ const managerUsername = manager?.username || '';
+ const managerLabel = managerUsername || manager?.email || '';
+ const ok = window.confirm(`Видалити менеджера ${managerLabel}?`);
+ if (!ok) return;
+
+ const confirmation = window.prompt(
+ `Щоб підтвердити видалення, введіть username: ${managerUsername}`
+ );
+ if (confirmation === null) return;
+
+ if ((confirmation || '').trim() !== managerUsername) {
+ setMessage('Username введено невірно. Видалення скасовано.');
+ return;
+ }
+
+ setMessage(null);
+ try {
+ await deleteManager(manager.id, managerUsername);
+ setMessage('Менеджера видалено.');
+ await loadManagers();
+ } catch (error) {
+ setMessage(error?.message || 'Не вдалося видалити менеджера.');
+ }
+ };
+
+ if (!isAdmin) {
+ return (
+
+
+ Керування менеджерами
+ Доступ дозволено лише адміністратору.
+
+
+ );
+ }
+
+ return (
+
+
+
+ Реєстрація нового працівника
+
+
+ Username
+
+
+
+
+ Email
+
+
+
+
+
+
+ Password
+
+
+ setShowPassword((prev) => !prev)}
+ >
+ {showPassword ? : }
+
+
+
+
+
+ Зареєструвати
+
+
+ {message && {message} }
+
+
+
+ Працівники
+
+ {message && {message} }
+
+ {managersLoading ? (
+ Завантаження...
+ ) : managers.length ? (
+
+ {managers.map((manager) => (
+
+ {avatarText(manager)}
+
+ {manager.username || manager.email}
+ {manager.email}
+
+
+ handleToggleActive(manager)}>
+ {manager.is_active ? 'Деактивувати' : 'Активувати'}
+
+ handleDelete(manager)}>
+ Видалити
+
+
+
+ ))}
+
+ ) : (
+ Менеджерів ще немає.
+ )}
+
+
+
+
+
+ );
+};
+
+export default ManagerManagement;