From 4c97b29e2104238a7277857c28598fc1930e650f Mon Sep 17 00:00:00 2001 From: VladyslavFiniahin Date: Fri, 27 Mar 2026 14:21:14 +0200 Subject: [PATCH 1/5] some fixes --- Gradient-Backend/main.py | 2 +- Gradient-Backend/routes/gmailRoutes.py | 22 +++++++++++++++------- Gradient-Backend/service/gmailService.py | 6 +++++- Gradient-Backend/service/sheetService.py | 16 ++++++++++------ gradient-frontend/src/api/client.js | 11 ++++++++++- 5 files changed, 41 insertions(+), 16 deletions(-) diff --git a/Gradient-Backend/main.py b/Gradient-Backend/main.py index d0d84d0..b86ec4a 100644 --- a/Gradient-Backend/main.py +++ b/Gradient-Backend/main.py @@ -11,7 +11,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=["*"], diff --git a/Gradient-Backend/routes/gmailRoutes.py b/Gradient-Backend/routes/gmailRoutes.py index 56ace8f..3de52f3 100644 --- a/Gradient-Backend/routes/gmailRoutes.py +++ b/Gradient-Backend/routes/gmailRoutes.py @@ -30,13 +30,21 @@ def get_leads( limit: int | None = Query(default=120, ge=1, le=500), 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) - 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) + 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): diff --git a/Gradient-Backend/service/gmailService.py b/Gradient-Backend/service/gmailService.py index a742b3f..e0195df 100644 --- a/Gradient-Backend/service/gmailService.py +++ b/Gradient-Backend/service/gmailService.py @@ -64,6 +64,7 @@ def mark_as_processed(msg_id: str): "INSERT OR IGNORE INTO processed_emails (gmail_id) VALUES (?)", [msg_id] ) + conn.commit() def extract_email(from_header: str) -> str: @@ -130,16 +131,18 @@ def _store_message(gmail_id: str, values: list[str]) -> None: """, [gmail_id, *values] ) + conn.commit() else: assignments = ", ".join(f"{col} = ?" for col in _MESSAGE_VALUE_COLUMNS) conn.execute( f""" UPDATE gmail_messages - SET {assignments} + 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]]]: @@ -178,6 +181,7 @@ def mark_messages_synced(gmail_ids: list[str]) -> None: """, gmail_ids ) + conn.commit() def _normalize_cell(value): diff --git a/Gradient-Backend/service/sheetService.py b/Gradient-Backend/service/sheetService.py index 074edc7..687e4e8 100644 --- a/Gradient-Backend/service/sheetService.py +++ b/Gradient-Backend/service/sheetService.py @@ -38,12 +38,16 @@ def append_to_sheet(rows: list[list[str]]): if not rows: return + spreadsheet_id = os.getenv("SPREADSHEET_ID") + if not spreadsheet_id: + raise ValueError("SPREADSHEET_ID not set in environment") + service = _get_sheet_service() body = {"values": rows} service.spreadsheets().values().append( - spreadsheetId=os.getenv("SPREADSHEET_ID"), + spreadsheetId=spreadsheet_id, range="A:T", valueInputOption="RAW", insertDataOption="INSERT_ROWS", @@ -327,14 +331,14 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None # Admin sees all leads with assignment info 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, + 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 created_at DESC + ORDER BY gm.created_at DESC LIMIT ? """ leads_data = conn.execute(query, [limit]).fetchall() diff --git a/gradient-frontend/src/api/client.js b/gradient-frontend/src/api/client.js index 9e6b7c6..eab28ee 100644 --- a/gradient-frontend/src/api/client.js +++ b/gradient-frontend/src/api/client.js @@ -91,7 +91,16 @@ export const postGmailSync = () => method: 'POST', }); -export const getGmailLeads = () => request('/gmail/leads'); +export const getGmailLeads = () => { + console.log('[DEBUG] Fetching gmail leads...'); + return request('/gmail/leads').then(response => { + console.log('[DEBUG] getGmailLeads response:', response); + return response; + }).catch(error => { + console.error('[DEBUG] getGmailLeads error:', error); + throw error; + }); +}; export const postLeadInsights = (payload) => request('/gmail/lead-insights', { From 4a5189a9d0f880fcfdc2926299f74199e02d6706 Mon Sep 17 00:00:00 2001 From: VladyslavFiniahin Date: Wed, 8 Apr 2026 09:17:52 +0300 Subject: [PATCH 2/5] fix --- Gradient-Backend/routes/gmailRoutes.py | 158 +- Gradient-Backend/routes/leadRoutes.py | 17 +- Gradient-Backend/service/gmailService.py | 2 +- Gradient-Backend/service/leadService.py | 225 +- Gradient-Backend/service/sheetService.py | 70 +- Gradient-Backend/service/userService.py | 8 +- gradient-frontend/src/api/client.js | 140 ++ gradient-frontend/src/components/Header.js | 300 ++- gradient-frontend/src/context/AuthContext.js | 317 +++ gradient-frontend/src/pages/Automation.js | 2317 ++++++++++++++++++ 10 files changed, 3436 insertions(+), 118 deletions(-) diff --git a/Gradient-Backend/routes/gmailRoutes.py b/Gradient-Backend/routes/gmailRoutes.py index 3de52f3..167af50 100644 --- a/Gradient-Backend/routes/gmailRoutes.py +++ b/Gradient-Backend/routes/gmailRoutes.py @@ -1,6 +1,8 @@ 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 @@ -98,16 +100,158 @@ def generate_replies(payload: ReplyGenerationRequest): } +# NEW STATUS SYSTEM - using gmail_id instead of row_number +VALID_STATUSES = {'NEW', 'ASSIGNED', 'EMAIL_SENT', 'WAITING_REPLY', 'REPLY_READY', 'CLOSED', 'LOST', 'SNOOZED', 'CONFIRMED', 'REJECTED'} + class LeadStatusUpdateRequest(BaseModel): - row_number: int = Field(gt=0) + gmail_id: str status: str +def add_status_history(gmail_id: str, status: str, assignee: 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) + VALUES (?, ?, ?, ?, ?) + """, + [history_id, gmail_id, status, assignee, lead_name] + ) + conn.commit() + + @router.post("/lead-status") -def set_lead_status(payload: LeadStatusUpdateRequest): - try: - update_lead_status(payload.row_number, payload.status) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc +def set_lead_status(payload: LeadStatusUpdateRequest, user_info: dict = Depends(get_user_from_token)): + """Update lead status and track in history""" + status = payload.status.upper() + if status not in VALID_STATUSES: + raise HTTPException(status_code=400, detail=f"Invalid status. Valid statuses: {', '.join(VALID_STATUSES)}") + + # 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 = ?", + [status, payload.gmail_id] + ) + + # Add to history + assignee = user_info.get("username") if user_info else None + add_status_history(payload.gmail_id, status, assignee) + + conn.commit() + + return {"gmail_id": payload.gmail_id, "status": status, "updated_by": assignee} + + +@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 { + "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, # Can be updated based on your logic + "is_priority": False, # Can be updated based on your logic + "emails": formatted_emails + } + - return {"row_number": payload.row_number, "status": payload.status} +@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 3bacee6..b091b63 100644 --- a/Gradient-Backend/routes/leadRoutes.py +++ b/Gradient-Backend/routes/leadRoutes.py @@ -3,7 +3,7 @@ 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 router = APIRouter(prefix="/leads", tags=["Lead Management"]) security = HTTPBearer() @@ -104,3 +104,18 @@ def get_assigned_leads( "total_count": len(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 diff --git a/Gradient-Backend/service/gmailService.py b/Gradient-Backend/service/gmailService.py index e0195df..0a2b823 100644 --- a/Gradient-Backend/service/gmailService.py +++ b/Gradient-Backend/service/gmailService.py @@ -271,7 +271,7 @@ def fetch_new_gmail_data(limit: int = 20): company_insights_value = json.dumps(parsed.get("company_insights") or [], ensure_ascii=False) row = [ - "waiting", # status + "NEW", # status - new lead status first_name, last_name, final_sender_name, diff --git a/Gradient-Backend/service/leadService.py b/Gradient-Backend/service/leadService.py index efac0b6..dd575f9 100644 --- a/Gradient-Backend/service/leadService.py +++ b/Gradient-Backend/service/leadService.py @@ -63,18 +63,41 @@ def assign_lead_to_user(gmail_id: str, user_info: dict): detail="Lead is already assigned" ) - # Assign lead to user + # Assign lead to user and update status to ASSIGNED conn.execute( - "UPDATE gmail_messages SET assigned_to = ?, assigned_at = ? WHERE gmail_id = ?", + "UPDATE gmail_messages SET assigned_to = ?, assigned_at = ?, status = 'ASSIGNED' WHERE gmail_id = ?", [user_info["id"], datetime.now(), gmail_id] ) + # Add status history entry + import uuid + history_id = str(uuid.uuid4()) + 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 + + 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"]} + 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 @@ -90,91 +113,119 @@ def get_user_leads(user_info: dict, limit: int = 120): """ 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) + elif user_role == "manager": + # Check if manager has an active assigned lead (status = 'ASSIGNED') + check_query = """ + SELECT gmail_id FROM gmail_messages + WHERE assigned_to = ? AND status = 'ASSIGNED' + """ + assigned_leads = conn.execute(check_query, [user_id]).fetchall() - return formatted_leads - + if assigned_leads: + # Show only assigned leads + assigned_ids = [lead[0] for lead in assigned_leads] + placeholders = ','.join(['?' for _ in assigned_ids]) + query = f""" + SELECT + 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 + WHERE gm.gmail_id IN ({placeholders}) + ORDER BY gm.created_at DESC + """ + leads = conn.execute(query, assigned_ids).fetchall() + else: + # Show all available leads + query = """ + SELECT + 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, [limit]).fetchall() else: - # Manager sees only their assigned leads - 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 - LIMIT ? + 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( """ - 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 + 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/sheetService.py b/Gradient-Backend/service/sheetService.py index 687e4e8..4d27a03 100644 --- a/Gradient-Backend/service/sheetService.py +++ b/Gradient-Backend/service/sheetService.py @@ -173,7 +173,7 @@ def fetch_sheet_rows(limit: int | None = 120) -> list[dict[str, str]]: return leads -ALLOWED_STATUS_VALUES = {"confirmed", "rejected", "snoozed", "waiting", "new"} +ALLOWED_STATUS_VALUES = {"ASSIGNED", "EMAIL_SENT", "WAITING_REPLY", "CLOSED", "NEW", "LOST", "REPLY_READY", "SNOOZED", "CONFIRMED", "REJECTED"} def update_lead_status(row_number: int, status: str) -> None: @@ -396,26 +396,59 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None leads.append(lead_dict) - elif user_info and user_info.get("role") == "manager": - # Manager sees only their assigned leads - 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 - LIMIT ? - """ - leads_data = conn.execute(query, [user_info["id"], limit]).fetchall() + elif user_info and user_info.get("role") in ("manager", "admin"): + # Manager and Admin see all leads with assignment info + # But if manager has an ASSIGNED lead, show only that lead + user_id = user_info.get("id") + user_role = user_info.get("role") + + # Check if manager has an active assigned lead (status = 'ASSIGNED') + assigned_leads = [] + if user_role == "manager" and user_id: + check_query = """ + SELECT gmail_id FROM gmail_messages + WHERE assigned_to = ? AND status = 'ASSIGNED' + """ + assigned_leads = conn.execute(check_query, [user_id]).fetchall() + + # If manager has assigned leads, show only those + if user_role == "manager" and assigned_leads: + assigned_ids = [lead[0] for lead in assigned_leads] + placeholders = ','.join(['?' for _ in assigned_ids]) + query = f""" + SELECT + 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 + WHERE gm.gmail_id IN ({placeholders}) + ORDER BY gm.created_at DESC + """ + leads_data = conn.execute(query, assigned_ids).fetchall() + else: + # Show all leads (unassigned or manager without active assignment) + query = """ + SELECT + 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_data = conn.execute(query, [limit]).fetchall() leads = [] for lead in leads_data: lead_dict = { "gmail_id": lead[0], - "status": lead[1] or "waiting", + "status": lead[1] or "NEW", "first_name": lead[2] or "", "last_name": lead[3] or "", "full_name": lead[4] or "", @@ -438,7 +471,10 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "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 "Unassigned" } # Process JSON fields diff --git a/Gradient-Backend/service/userService.py b/Gradient-Backend/service/userService.py index f13420c..aaea2d3 100644 --- a/Gradient-Backend/service/userService.py +++ b/Gradient-Backend/service/userService.py @@ -54,7 +54,7 @@ def login_user(user): username = user.username or user.email row = conn.execute( - "SELECT username, password FROM users WHERE username = ? OR email = ?", + "SELECT username, password, role FROM users WHERE username = ? OR email = ?", [username, user.email or username] ).fetchone() @@ -64,7 +64,7 @@ def login_user(user): detail="Invalid username or password" ) - stored_username, hashed_password = row + stored_username, hashed_password, user_role = row if not verify_password(user.password, hashed_password): raise HTTPException( @@ -72,5 +72,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-frontend/src/api/client.js b/gradient-frontend/src/api/client.js index eab28ee..233968b 100644 --- a/gradient-frontend/src/api/client.js +++ b/gradient-frontend/src/api/client.js @@ -1,129 +1,269 @@ 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 || {}); + headers.set('Content-Type', 'application/json'); + + if (!authToken) { + loadAuthToken(); + } + + if (authToken && !headers.has('Authorization')) { + headers.set('Authorization', `Bearer ${authToken}`); + } + + const response = await fetch(`${API_URL}${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 = () => { + console.log('[DEBUG] Fetching gmail leads...'); + return request('/gmail/leads').then(response => { + console.log('[DEBUG] getGmailLeads response:', response); + return response; + }).catch(error => { + console.error('[DEBUG] getGmailLeads error:', error); + throw error; + }); + }; + + 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), + }); + diff --git a/gradient-frontend/src/components/Header.js b/gradient-frontend/src/components/Header.js index e67eba8..80fff42 100644 --- a/gradient-frontend/src/components/Header.js +++ b/gradient-frontend/src/components/Header.js @@ -1,298 +1,596 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; + import { Link, NavLink, useNavigate } from 'react-router-dom'; + import styled, { keyframes } from 'styled-components'; + import userAvatar from '../assets/user.jpg'; + import ThemeToggle from './ThemeToggle'; + import { useAuth } from '../context/AuthContext'; + + const HeaderContainer = styled.header` + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 2rem; + background: ${({ theme }) => theme.colors.headerBackground}; + color: ${({ theme }) => theme.colors.text}; + border-bottom: 1px solid ${({ theme }) => theme.colors.border}; + box-shadow: 0 2px 12px ${({ theme }) => theme.colors.shadow}; + position: relative; + transition: background 0.3s ease, border-color 0.3s ease; + `; + + const Nav = styled.nav` + display: flex; + align-items: center; + position: absolute; + left: 50%; + transform: translateX(-50%); + + a { + color: ${({ theme }) => theme.colors.textSecondary}; + text-decoration: none; + margin: 0 1.25rem; + font-size: 1.2rem; + letter-spacing: 0.2px; + padding-bottom: 0.6rem; + transition: color 0.2s ease, border-color 0.2s ease, opacity 0.2s ease; + opacity: 0.9; + + &:hover { + color: ${({ theme }) => theme.colors.text}; + opacity: 1; + } + + &.active { + color: ${({ theme }) => theme.colors.text}; + border-bottom: 3px solid ${({ theme }) => theme.colors.primary}; + } + } + `; + + const UserMenu = styled.div` + display: flex; + align-items: center; + position: relative; + `; + + const UserAvatar = styled.div` + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #f1f3f6; + position: relative; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.border}; + `; + + const AvatarImage = styled.img` + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + `; + + const dropdownAppear = keyframes` + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + `; + + const UserButton = styled.button` + display: flex; + align-items: center; + background: none; + border: none; + color: ${({ theme }) => theme.colors.textSecondary}; + cursor: pointer; + padding: 0; + `; + + const UserDropdown = styled.div` + ${({ closing }) => closing && 'pointer-events: none;'} + position: absolute; + top: 52px; + right: 0; + width: 320px; + background: ${({ theme }) => theme.colors.headerBackground}; + border-radius: 16px; + box-shadow: 0 10px 30px ${({ theme }) => theme.colors.shadow}; + border: 1px solid ${({ theme }) => theme.colors.border}; + padding: 1.25rem 1.2rem 1rem; + z-index: 10; + animation: ${dropdownAppear} 0.22s ease-out forwards; + ${({ closing }) => closing && 'animation-direction: reverse; pointer-events: none;'} + `; + + const UserTop = styled.div` + display: flex; + align-items: center; + margin-bottom: 0.75rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid ${({ theme }) => theme.colors.border}; + `; + + const UserTopInfo = styled.div` + margin-left: 0.75rem; + + h4 { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + } + + span { + display: block; + font-size: 0.8rem; + opacity: 0.8; + } + `; + + const MenuList = styled.ul` + list-style: none; + margin: 0; + padding: 0; + `; + + const MenuItem = styled.li` + a, + button { + width: 100%; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 0.7rem; + border-radius: 999px; + background: transparent; + border: none; + text-align: left; + color: ${({ theme }) => theme.colors.textSecondary}; + text-decoration: none; + cursor: pointer; + font-size: 0.95rem; + transition: background 0.2s ease, color 0.2s ease; + + &:hover { + background: ${({ theme }) => theme.colors.cardBackground}; + color: ${({ theme }) => theme.colors.text}; + } + } + + &.logout button { + color: #ff4d4f; + + &:hover { + background: rgba(255, 77, 79, 0.12); + color: #ff4d4f; + } + } + `; + + const IconCircle = styled.span` + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: ${({ theme }) => theme.colors.cardBackground}; + font-size: 0.95rem; + `; + + const StatusIndicator = styled.span` + position: absolute; + right: -2px; + bottom: -2px; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid ${({ theme }) => theme.colors.headerBackground}; + background-color: #21ff00; /* online */ + `; + + const Header = () => { + const [open, setOpen] = useState(false); + const [closing, setClosing] = useState(false); + const menuRef = useRef(null); + const navigate = useNavigate(); + const { logout, user } = useAuth(); + + const toggleMenu = () => { + setOpen((prev) => !prev); + }; + + const closeMenu = useCallback(() => { + if (closing) return; + setClosing(true); + setTimeout(() => { + setOpen(false); + setClosing(false); + }, 220); // match animation duration + }, [closing]); + useEffect(() => { + if (!open) return; + + const handleClickOutside = (event) => { + if (menuRef.current && !menuRef.current.contains(event.target)) { + closeMenu(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [open, closeMenu]); + + const handleLogout = () => { + logout(); + closeMenu(); + navigate('/login'); + }; + + return ( + + + + + + + + + + + {(open || closing) && ( + + + + + + + +

{user?.email || 'User'}

- Admin + + {user?.role === 'admin' ? 'Admin' : 'Manager'} +
+
+ + + + 👤 + Профіль + + + + + 🕒 + Історія Лідів + + + + + ⚙️ + Налаштування + + + + + + +
+ )} +
+
+ ); + }; + + export default Header; + diff --git a/gradient-frontend/src/context/AuthContext.js b/gradient-frontend/src/context/AuthContext.js index 1f1a392..0f83d3a 100644 --- a/gradient-frontend/src/context/AuthContext.js +++ b/gradient-frontend/src/context/AuthContext.js @@ -1,317 +1,634 @@ 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; + + if (!receivedToken) { + throw new Error('Не вдалося отримати токен доступу.'); + } + + setAuthToken(receivedToken); + setToken(receivedToken); + setUser({ email }); + + 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 a40cda6..8f9eacb 100644 --- a/gradient-frontend/src/pages/Automation.js +++ b/gradient-frontend/src/pages/Automation.js @@ -1,2317 +1,4634 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; + import styled, { useTheme } from 'styled-components'; + import { getGmailLeads, postGenerateReplies, postLeadStatus, postLeadInsights } from '../api/client'; + import { useNavigate } from 'react-router-dom'; + import { useAuth } from '../context/AuthContext'; + + 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', + border: '1px solid rgba(71, 85, 105, 0.24)', + }, + }; + + const StatusBadge = styled.span` + border-radius: 999px; + padding: 0.35rem 0.85rem; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + ${({ $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` + 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: 'Очікує', + }; + + 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 { leadSnapshot, updateLeadSnapshot } = useAuth(); + const navigate = useNavigate(); + 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 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); + if (stageFilter === 'qualified' && !qualified) return false; + if (stageFilter === 'new' && (qualified || status === 'waiting')) return false; + if (stageFilter === 'waiting' && status !== 'waiting') return false; + + 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 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 handleRowClick = async (lead) => { + setSelectedLead(lead); + setShowReader(false); + setShowReplyComposer(false); + setInsightsError(null); + setReplyOptions({ quick: '', follow_up: '', recap: '' }); + setSelectedReplyKey(''); + setReplyDraft(''); + setReplyError(null); + setReplyStyle('semi_official'); + + try { + const needsEnrichment = !lead?.full_name && !lead?.company_info && !lead?.person_summary; + if (!needsEnrichment) return; + + const enriched = await postLeadInsights({ + sender: lead.email || 'unknown@example.com', + subject: lead.subject || '', + body: lead.body || '', + }); + if (enriched) { + setSelectedLead((prev) => ({ ...(prev || lead), ...enriched })); + } + } catch (err) { + setInsightsError(err?.message || 'Не вдалося завантажити інсайти.'); + } + }; + + const handleRowKeyDown = (event, lead) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedLead(lead); + setShowReader(false); + setShowReplyComposer(false); + } + }; + + 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 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; + + const gmailId = selectedLead.gmail_id; + const rowNumber = selectedLead.sheet_row || selectedLead.sheetRow; + if (!gmailId && !rowNumber) { + setStatusError('Не знайдено ідентифікатор ліда для збереження статусу.'); + return; + } + + const decidedAt = new Date().toISOString(); + setDecisions((prev) => ({ + ...prev, + [getLeadKey(selectedLead)]: { + status, + decidedAt, + }, + })); + setStatusError(null); + + closeModal(); + + try { + if (gmailId) { + await postLeadStatus({ gmail_id: gmailId, status }); + } else { + await postLeadStatus({ row_number: rowNumber, status }); + } + await refresh({ isManualRefresh: true }); + setDecisions((prev) => { + const { [getLeadKey(selectedLead)]: _removed, ...rest } = prev; + return rest; + }); + } catch (error) { + setDecisions((prev) => { + const updated = { ...prev }; + delete updated[getLeadKey(selectedLead)]; + return updated; + }); + setStatusError(error instanceof Error ? error.message : 'Не вдалося оновити статус.'); + } + }, + [selectedLead, closeModal, refresh] + ); + + 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)} + /> + + + + Період: {getRangeLabel(rangeFilter)} + + + {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 = ['confirmed', 'rejected', 'snoozed'].includes(resolvedStatus) + ? resolvedStatus + : resolvedStatus === 'waiting' + ? 'waiting' + : qualified + ? '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 ( + handleRowClick(lead)} + onKeyDown={(event) => handleRowKeyDown(event, lead)} + style={rowStyle} + > + + {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)}`} + + )} + + + + ); + })} + + + )} + +
+ + {selectedLead && ( + + event.stopPropagation()} + role="dialog" + aria-modal="true" + $shifted={showReader} + $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')}> + Відхилити + + + Підтвердити + + + + {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} + + +
+ + Скасувати + + handleDecision('confirmed')}> + Відповісти та підтвердити + +
+
+
+
+ )} + +
+ ); + }; + + export default Automation; + From b31b83d9295969d062aa6e15d99af58db4920da6 Mon Sep 17 00:00:00 2001 From: VladyslavFiniahin Date: Wed, 15 Apr 2026 20:14:18 +0300 Subject: [PATCH 3/5] fixmerge --- Gradient-Backend/db.py | 2 + Gradient-Backend/routes/gmailRoutes.py | 95 ++++-- Gradient-Backend/routes/leadRoutes.py | 153 +++++++++ Gradient-Backend/service/gmailService.py | 14 +- Gradient-Backend/service/sheetService.py | 361 ++++++++++----------- gradient-frontend/src/api/client.js | 68 ++-- gradient-frontend/src/components/Header.js | 2 +- gradient-frontend/src/pages/Automation.js | 343 ++++++++++++-------- 8 files changed, 654 insertions(+), 384 deletions(-) diff --git a/Gradient-Backend/db.py b/Gradient-Backend/db.py index ed89734..1d3fdae 100644 --- a/Gradient-Backend/db.py +++ b/Gradient-Backend/db.py @@ -93,6 +93,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/routes/gmailRoutes.py b/Gradient-Backend/routes/gmailRoutes.py index 167af50..95798c1 100644 --- a/Gradient-Backend/routes/gmailRoutes.py +++ b/Gradient-Backend/routes/gmailRoutes.py @@ -5,7 +5,7 @@ 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 @@ -30,13 +30,14 @@ def manual_sync(): @router.get("/leads") def get_leads( limit: int | None = Query(default=120, ge=1, le=500), + range_days: int | None = Query(default=None, ge=1, le=3650), user_info: dict | None = Depends(get_user_from_token) ): 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) + 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) @@ -100,15 +101,18 @@ def generate_replies(payload: ReplyGenerationRequest): } -# NEW STATUS SYSTEM - using gmail_id instead of row_number +# 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): - gmail_id: str + row_number: int | None = Field(gt=0, default=None) + gmail_id: str | None = None status: str + rejection_reason: str | None = None -def add_status_history(gmail_id: str, status: str, assignee: 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()) @@ -122,43 +126,72 @@ def add_status_history(gmail_id: str, status: str, assignee: str | None = None): conn.execute( """ - INSERT INTO lead_status_history (id, gmail_id, status, assignee, lead_name) - VALUES (?, ?, ?, ?, ?) + 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] + [history_id, gmail_id, status, assignee, lead_name, rejection_reason, datetime.now()] ) conn.commit() @router.post("/lead-status") def set_lead_status(payload: LeadStatusUpdateRequest, user_info: dict = Depends(get_user_from_token)): - """Update lead status and track in history""" + """Update lead status and track in history - supports both row_number and gmail_id""" status = payload.status.upper() - if status not in VALID_STATUSES: - raise HTTPException(status_code=400, detail=f"Invalid status. Valid statuses: {', '.join(VALID_STATUSES)}") - - # Check if lead exists - lead = conn.execute( - "SELECT gmail_id, assigned_to FROM gmail_messages WHERE gmail_id = ?", - [payload.gmail_id] - ).fetchone() + normalized_status = (payload.status or "").strip().lower() - 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 = ?", - [status, payload.gmail_id] - ) - - # Add to history - assignee = user_info.get("username") if user_info else None - add_status_history(payload.gmail_id, status, assignee) + # 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)}") - conn.commit() + # Use the uppercase version for storage + final_status = status if status in VALID_STATUSES else normalized_status.upper() - return {"gmail_id": payload.gmail_id, "status": status, "updated_by": assignee} + try: + if payload.gmail_id: + # 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: + 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") diff --git a/Gradient-Backend/routes/leadRoutes.py b/Gradient-Backend/routes/leadRoutes.py index b091b63..aa28161 100644 --- a/Gradient-Backend/routes/leadRoutes.py +++ b/Gradient-Backend/routes/leadRoutes.py @@ -4,6 +4,7 @@ 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, delete_lead_by_gmail_id +from db import conn router = APIRouter(prefix="/leads", tags=["Lead Management"]) security = HTTPBearer() @@ -119,3 +120,155 @@ def delete_lead( result = delete_lead_by_gmail_id(gmail_id, user_info) return result + + +@router.get("/{email}") +def get_lead_profile(email: str): + """ + Return aggregated lead profile by sender email. + Used by frontend route: `/lead/:email`. + """ + email_norm = (email or "").strip().lower() + if not email_norm: + raise HTTPException(status_code=400, detail="Email is required") + + messages = conn.execute( + """ + SELECT + gmail_id, + status, + first_name, + last_name, + full_name, + email, + subject, + received_at, + company, + body, + phone, + person_role, + is_priority, + pending_review, + company_name + FROM gmail_messages + WHERE lower(email) = ? + ORDER BY received_at DESC NULLS LAST, created_at DESC NULLS LAST + """, + [email_norm], + ).fetchall() + + if not messages: + raise HTTPException(status_code=404, detail="Lead profile not found") + + # Latest message becomes the "current" view for status/pending flags. + latest = messages[0] + + ( + latest_gmail_id, + latest_status, + latest_first_name, + latest_last_name, + latest_full_name, + latest_email, + latest_subject, + latest_received_at, + latest_company, + latest_body, + latest_phone, + latest_person_role, + latest_is_priority, + latest_pending_review, + latest_company_name, + ) = latest + + name = ( + (latest_full_name or "").strip() + or " ".join(filter(bool, [latest_first_name, latest_last_name])) + or latest_email + or "Unknown" + ) + + # Collect all gmail_ids for status history. + gmail_ids = [m[0] for m in messages] + + history_rows = conn.execute( + """ + SELECT + id, + changed_at, + lead_name, + status, + assignee, + rejection_reason + FROM lead_status_history + WHERE gmail_id IN ({placeholders}) + ORDER BY changed_at DESC NULLS LAST + """.format(placeholders=",".join(["?"] * len(gmail_ids))), + gmail_ids, + ).fetchall() + + history = [] + for row in history_rows: + changed_at = row[1] + if changed_at is not None and hasattr(changed_at, "isoformat"): + changed_at_val = changed_at.isoformat() + else: + changed_at_val = str(changed_at) if changed_at is not None else None + history.append( + { + "id": row[0], + "date": changed_at_val, + "leadName": row[2], + "status": row[3], + "assignee": row[4], + "rejection_reason": row[5], + } + ) + + emails = [] + for m in messages: + ( + gmail_id, + status, + first_name, + last_name, + full_name, + msg_email, + subject, + received_at, + company, + body, + phone, + person_role, + is_priority, + pending_review, + company_name, + ) = m + + emails.append( + { + "gmail_id": gmail_id, + "status": status, + "subject": subject, + "received_at": ( + received_at.isoformat() + if received_at is not None and hasattr(received_at, "isoformat") + else (str(received_at) if received_at is not None else None) + ), + "body": body, + } + ) + + return { + "id": latest_gmail_id, + "name": name, + "email": latest_email, + "phone": latest_phone, + "company": (latest_company or latest_company_name or ""), + "role": latest_person_role, + "status": latest_status or "waiting", + "pending_review": bool(latest_pending_review), + "is_priority": bool(latest_is_priority), + "emails": emails, + "history": history, + } diff --git a/Gradient-Backend/service/gmailService.py b/Gradient-Backend/service/gmailService.py index 0a2b823..a88c8fe 100644 --- a/Gradient-Backend/service/gmailService.py +++ b/Gradient-Backend/service/gmailService.py @@ -6,12 +6,18 @@ from db import conn from service.aiService import analyze_email +from service.leadIntentService import detect_sales_intent BASE_DIR = Path(__file__).resolve().parent.parent CREDENTIALS_DIR = BASE_DIR / "credentials" TOKEN_FILE = CREDENTIALS_DIR / "token.json" -SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"] +# Unified scopes for the entire application (Gmail + Sheets) +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/spreadsheets", +] _MESSAGE_VALUE_COLUMNS = [ "status", @@ -253,6 +259,10 @@ def fetch_new_gmail_data(limit: int = 20): parsed = analyze_email(subject=subject, body=body, sender=sender_email) + 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 'NEW' + # Prioritize name from signature/body if available final_sender_name = parsed.get("full_name") if parsed.get("full_name") else sender_name @@ -271,7 +281,7 @@ def fetch_new_gmail_data(limit: int = 20): company_insights_value = json.dumps(parsed.get("company_insights") or [], ensure_ascii=False) row = [ - "NEW", # status - new lead status + lead_status, # status - dynamic based on sales intent detection first_name, last_name, final_sender_name, diff --git a/Gradient-Backend/service/sheetService.py b/Gradient-Backend/service/sheetService.py index 4d27a03..0cc6712 100644 --- a/Gradient-Backend/service/sheetService.py +++ b/Gradient-Backend/service/sheetService.py @@ -16,7 +16,12 @@ CREDENTIALS_DIR = BASE_DIR / "credentials" TOKEN_FILE = CREDENTIALS_DIR / "token.json" -SCOPES = ["https://www.googleapis.com/auth/spreadsheets"] +# Unified scopes for the entire application (Gmail + Sheets) +SCOPES = [ + "https://www.googleapis.com/auth/gmail.readonly", + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/spreadsheets", +] def _get_sheet_service(): @@ -38,16 +43,12 @@ def append_to_sheet(rows: list[list[str]]): if not rows: return - spreadsheet_id = os.getenv("SPREADSHEET_ID") - if not spreadsheet_id: - raise ValueError("SPREADSHEET_ID not set in environment") - service = _get_sheet_service() body = {"values": rows} service.spreadsheets().values().append( - spreadsheetId=spreadsheet_id, + spreadsheetId=os.getenv("SPREADSHEET_ID"), range="A:T", valueInputOption="RAW", insertDataOption="INSERT_ROWS", @@ -173,10 +174,10 @@ def fetch_sheet_rows(limit: int | None = 120) -> list[dict[str, str]]: return leads -ALLOWED_STATUS_VALUES = {"ASSIGNED", "EMAIL_SENT", "WAITING_REPLY", "CLOSED", "NEW", "LOST", "REPLY_READY", "SNOOZED", "CONFIRMED", "REJECTED"} +ALLOWED_STATUS_VALUES = {"confirmed", "rejected", "snoozed", "waiting", "new"} -def update_lead_status(row_number: int, status: str) -> None: +def update_lead_status(row_number: int, status: str, rejection_reason: str | None = None) -> None: if row_number is None or row_number < 1: raise ValueError("row_number must be a positive integer") @@ -196,6 +197,43 @@ def update_lead_status(row_number: int, status: str) -> None: ).execute() +def update_lead_status_gmail_id(gmail_id: str, status: str, rejection_reason: str | None = None) -> None: + """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") + + conn.execute( + """ + UPDATE gmail_messages + SET status = ? + WHERE gmail_id = ? + """, + [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 = lead_name[0] if lead_name else "Unknown" + + 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()], + ) + + conn.commit() + + def _parse_datetime(value: str | None) -> datetime | None: if not value: return None @@ -236,113 +274,38 @@ def _is_qualified(lead: dict[str, str]) -> bool: def build_leads_payload(limit: int | None = 120) -> dict[str, Any]: - leads = fetch_sheet_rows(limit) - now = datetime.utcnow() - active_cutoff = now - timedelta(days=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 - week_index = diff_days // 7 - if week_index < 4: - slot = 3 - week_index - week_totals[slot] += 1 - if qualified: - week_qualified[slot] += 1 - - month_buckets = _generate_month_buckets() - line_chart = [] - for bucket in month_buckets: - key = (bucket.year, bucket.month) - bucket_totals = month_totals.get(key, {"total": 0, "qualified": 0}) - line_chart.append({ - "name": MONTH_LABELS[bucket.month - 1], - "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, - "percentage": percentage, - "qualified": qualified_total, - "waiting": waiting_total, - } - - return { - "leads": leads, - "stats": stats, - "line": line_chart, - "quarter": quarter_chart, - "month": month_chart, - "pie": pie_chart, - "generated_at": now.isoformat(), - } - - -def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None = None) -> dict[str, Any]: + try: + dummy_admin = {"role": "admin", "id": -1} + return build_leads_payload_from_db(limit, dummy_admin) + except Exception as e: + print(f"Fallback to Sheets API due to DB error: {e}") + return build_leads_payload_from_db(limit, {"role": "admin", "id": -1}) + + +def build_leads_payload_from_db( + limit: int | None = 120, + user_info: dict | None = None, + 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 - 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, + 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, 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 + ORDER BY created_at DESC LIMIT ? """ leads_data = conn.execute(query, [limit]).fetchall() - + leads = [] for lead in leads_data: lead_dict = { @@ -375,7 +338,7 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "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: @@ -384,7 +347,7 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None lead_dict["person_links"] = [] else: lead_dict["person_links"] = [] - + for field in ["person_insights", "company_insights"]: if lead_dict[field]: try: @@ -393,62 +356,29 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None lead_dict[field] = [] else: lead_dict[field] = [] - + leads.append(lead_dict) - - elif user_info and user_info.get("role") in ("manager", "admin"): - # Manager and Admin see all leads with assignment info - # But if manager has an ASSIGNED lead, show only that lead - user_id = user_info.get("id") - user_role = user_info.get("role") - - # Check if manager has an active assigned lead (status = 'ASSIGNED') - assigned_leads = [] - if user_role == "manager" and user_id: - check_query = """ - SELECT gmail_id FROM gmail_messages - WHERE assigned_to = ? AND status = 'ASSIGNED' - """ - assigned_leads = conn.execute(check_query, [user_id]).fetchall() - - # If manager has assigned leads, show only those - if user_role == "manager" and assigned_leads: - assigned_ids = [lead[0] for lead in assigned_leads] - placeholders = ','.join(['?' for _ in assigned_ids]) - query = f""" - SELECT - 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 - WHERE gm.gmail_id IN ({placeholders}) - ORDER BY gm.created_at DESC - """ - leads_data = conn.execute(query, assigned_ids).fetchall() - else: - # Show all leads (unassigned or manager without active assignment) - query = """ - SELECT - 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_data = conn.execute(query, [limit]).fetchall() - + + elif user_info and user_info.get("role") == "manager": + # Manager sees only their assigned leads + 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 + LIMIT ? + """ + leads_data = conn.execute(query, [user_info["id"], limit]).fetchall() + leads = [] for lead in leads_data: lead_dict = { "gmail_id": lead[0], - "status": lead[1] or "NEW", + "status": lead[1] or "waiting", "first_name": lead[2] or "", "last_name": lead[3] or "", "full_name": lead[4] or "", @@ -471,12 +401,9 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "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" + "created_at": lead[24] } - + # Process JSON fields if lead_dict["person_links"]: try: @@ -485,7 +412,7 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None lead_dict["person_links"] = [] else: lead_dict["person_links"] = [] - + for field in ["person_insights", "company_insights"]: if lead_dict[field]: try: @@ -494,44 +421,75 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None lead_dict[field] = [] else: lead_dict[field] = [] - + leads.append(lead_dict) - + else: # No user info, return empty leads leads = [] - - # Calculate stats + now = datetime.utcnow() - active_cutoff = now - timedelta(days=30) - + + # Optional global range filter (used by Analytics global filter panel). + if range_days is not None: + cutoff = now - timedelta(days=range_days) + filtered: list[dict[str, Any]] = [] + for lead in leads: + lead_dt = _parse_datetime(lead.get("received_at")) + if not lead_dt: + continue + if lead_dt >= cutoff: + filtered.append(lead) + leads = filtered + + # Attach latest rejection reason (needed for drill-down). + for lead in leads: + gmail_id = lead.get("gmail_id") + 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() + 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 @@ -541,7 +499,7 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None week_totals[slot] += 1 if qualified: week_qualified[slot] += 1 - + month_buckets = _generate_month_buckets() line_chart = [] for bucket in month_buckets: @@ -552,23 +510,23 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "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, @@ -576,7 +534,45 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "qualified": qualified_total, "waiting": waiting_total, } - + + pending_groups: list[dict[str, Any]] = [] + pending_buckets: dict[str, list[dict[str, Any]]] = {"3": [], "5": [], "10": []} + + for lead in leads: + status = (lead.get("status") or "").lower() + if status != "waiting": + continue + lead_dt = _parse_datetime(lead.get("received_at")) + if not lead_dt: + continue + waiting_days = (now - lead_dt).days + if waiting_days < 3: + continue + if waiting_days >= 10: + pending_buckets["10"].append(lead) + elif waiting_days >= 5: + pending_buckets["5"].append(lead) + else: + pending_buckets["3"].append(lead) + + bucket_meta = { + "3": {"label": "3–4 дні"}, + "5": {"label": "5–9 днів"}, + "10": {"label": "10+ днів"}, + } + for key in ["3", "5", "10"]: + items = pending_buckets[key] + # Oldest first (largest waiting time). + items.sort(key=lambda x: (_parse_datetime(x.get("received_at")) or datetime.min)) + pending_groups.append( + { + "key": key, + "label": bucket_meta[key]["label"], + "count": len(items), + "leads": items[:25], + } + ) + return { "leads": leads, "stats": stats, @@ -584,6 +580,7 @@ def build_leads_payload_from_db(limit: int | None = 120, user_info: dict | None "quarter": quarter_chart, "month": month_chart, "pie": pie_chart, + "pending_groups": pending_groups, "generated_at": now.isoformat(), "user_role": user_info.get("role") if user_info else None, "user_id": user_info.get("id") if user_info else None diff --git a/gradient-frontend/src/api/client.js b/gradient-frontend/src/api/client.js index 233968b..ee0deab 100644 --- a/gradient-frontend/src/api/client.js +++ b/gradient-frontend/src/api/client.js @@ -91,35 +91,25 @@ const parseJsonSafely = async response => { const request = async (path, options = {}) => { - const headers = new Headers(options.headers || {}); - - headers.set('Content-Type', 'application/json'); - - + const isFormData = + typeof FormData !== 'undefined' && options?.body && options.body instanceof FormData; + // For FormData we must NOT set Content-Type manually (browser adds proper boundary). + if (!isFormData) { + headers.set('Content-Type', 'application/json'); + } if (!authToken) { - loadAuthToken(); - } - - if (authToken && !headers.has('Authorization')) { - headers.set('Authorization', `Bearer ${authToken}`); - } - - const response = await fetch(`${API_URL}${path}`, { - ...options, - headers, - }); @@ -184,24 +174,9 @@ export const postGmailSync = () => -export const getGmailLeads = () => { - - console.log('[DEBUG] Fetching gmail leads...'); - - return request('/gmail/leads').then(response => { - - console.log('[DEBUG] getGmailLeads response:', response); - - return response; - - }).catch(error => { - - console.error('[DEBUG] getGmailLeads error:', error); - - throw error; - - }); - +export const getGmailLeads = (rangeDays = null) => { + const qs = rangeDays ? `?range_days=${encodeURIComponent(rangeDays)}` : ''; + return request(`/gmail/leads${qs}`); }; @@ -258,12 +233,31 @@ export const getReplyPrompts = () => request('/settings/reply-prompts'); export const updateReplyPrompts = (payload) => - request('/settings/reply-prompts', { - method: 'PUT', - body: JSON.stringify(payload), + }); +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, + headers: {}, // Let browser set Content-Type for FormData + }); +}; diff --git a/gradient-frontend/src/components/Header.js b/gradient-frontend/src/components/Header.js index 80fff42..bbb2d29 100644 --- a/gradient-frontend/src/components/Header.js +++ b/gradient-frontend/src/components/Header.js @@ -484,7 +484,7 @@ const Header = () => { (isActive ? 'active' : '')}>Аналітика - (isActive ? 'active' : '')}>Автоматизація + (isActive ? 'active' : '')}>Робоча зона diff --git a/gradient-frontend/src/pages/Automation.js b/gradient-frontend/src/pages/Automation.js index 8f9eacb..894d84b 100644 --- a/gradient-frontend/src/pages/Automation.js +++ b/gradient-frontend/src/pages/Automation.js @@ -1,12 +1,9 @@ -import React, { useCallback, useMemo, useRef, useState } from 'react'; - +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import styled, { useTheme } from 'styled-components'; - -import { getGmailLeads, postGenerateReplies, postLeadStatus, postLeadInsights } from '../api/client'; - -import { useNavigate } from 'react-router-dom'; - +import { getGmailLeads, postGenerateReplies, postLeadStatus, postLeadInsights, sendEmailWithAttachments } from '../api/client'; +import { useNavigate, useLocation } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; +import { useModalManager } from '../context/ModalManagerContext'; @@ -695,20 +692,20 @@ const BADGE_VARIANTS = { }, waiting: { - color: '#475569', - background: '#ffffff', - border: '1px solid rgba(71, 85, 105, 0.24)', - }, - + call_lead: { + color: '#7c3aed', + background: 'rgba(124, 58, 237, 0.1)', + border: '2px solid #7c3aed', + fontWeight: '700', + boxShadow: '0 0 8px rgba(124, 58, 237, 0.3)', + }, }; - - -const StatusBadge = styled.span` +const StatusBadge = styled.button` border-radius: 999px; @@ -2392,6 +2389,8 @@ const BADGE_LABELS = { waiting: 'Очікує', + call_lead: '📞 Дзвінок з лідом', + }; @@ -2715,13 +2714,11 @@ const formatRelative = (value) => { const Automation = () => { - const theme = useTheme(); - - const { leadSnapshot, updateLeadSnapshot } = useAuth(); - const navigate = useNavigate(); - + const location = useLocation(); + const { activeModals, openModal, closeModal: closeGlobalModal } = useModalManager(); + const { leadSnapshot, updateLeadSnapshot, pushNotification } = useAuth(); const { data, loading, error, refresh } = useLeadData(leadSnapshot, updateLeadSnapshot); const [searchTerm, setSearchTerm] = useState(''); @@ -2927,12 +2924,18 @@ const Automation = () => { const decisionStatus = decisions[getLeadKey(lead)]?.status; const status = decisionStatus ?? getLeadStatus(lead); + const badgeVariantForFilter = + status === 'call_lead' + ? 'call_lead' + : ['confirmed', 'rejected', 'snoozed'].includes(status) + ? status + : status === 'waiting' + ? 'waiting' + : qualified + ? 'qualified' + : 'new'; - if (stageFilter === 'qualified' && !qualified) return false; - - if (stageFilter === 'new' && (qualified || status === 'waiting')) return false; - - if (stageFilter === 'waiting' && status !== 'waiting') return false; + if (stageFilter !== 'all' && badgeVariantForFilter !== stageFilter) return false; @@ -3228,59 +3231,46 @@ const Automation = () => { - const handleRowClick = async (lead) => { - + const handleRowClick = useCallback(async (lead) => { setSelectedLead(lead); - setShowReader(false); - setShowReplyComposer(false); - setInsightsError(null); - setReplyOptions({ quick: '', follow_up: '', recap: '' }); - setSelectedReplyKey(''); - setReplyDraft(''); - setReplyError(null); - setReplyStyle('semi_official'); - - try { - const needsEnrichment = !lead?.full_name && !lead?.company_info && !lead?.person_summary; - if (!needsEnrichment) return; - - const enriched = await postLeadInsights({ - sender: lead.email || 'unknown@example.com', - subject: lead.subject || '', - body: lead.body || '', - }); - if (enriched) { - setSelectedLead((prev) => ({ ...(prev || lead), ...enriched })); - } - } catch (err) { - setInsightsError(err?.message || 'Не вдалося завантажити інсайти.'); - } + }, []); - }; + // Effect to open lead if passed from Analytics + useEffect(() => { + const emailToOpen = location.state?.openLeadEmail; + if (emailToOpen && dedupedLeads.length > 0) { + const lead = dedupedLeads.find(l => l.email === emailToOpen); + if (lead) { + // Clear state to avoid reopening on every render + window.history.replaceState({}, document.title); + handleRowClick(lead); + } + } + }, [location.state, dedupedLeads, handleRowClick]); @@ -3482,95 +3472,180 @@ const Automation = () => { - const handleDecision = useCallback( - - async (status) => { - + const handleDecisionWithReason = useCallback( + async (status, rejectionReason) => { if (!selectedLead) return; - - - const gmailId = selectedLead.gmail_id; - + const gmailId = selectedLead.gmail_id || selectedLead.gmailId; const rowNumber = selectedLead.sheet_row || selectedLead.sheetRow; - if (!gmailId && !rowNumber) { - setStatusError('Не знайдено ідентифікатор ліда для збереження статусу.'); - return; - } - - const decidedAt = new Date().toISOString(); - setDecisions((prev) => ({ - ...prev, - [getLeadKey(selectedLead)]: { - status, - decidedAt, - + rejectionReason, }, - })); - setStatusError(null); - - - closeModal(); - - + closeLocalModal(); try { - if (gmailId) { - - await postLeadStatus({ gmail_id: gmailId, status }); - + await postLeadStatus({ + gmail_id: gmailId, + status, + rejection_reason: rejectionReason + }); } else { - - await postLeadStatus({ row_number: rowNumber, status }); - + await postLeadStatus({ + row_number: rowNumber, + status, + rejection_reason: rejectionReason + }); } - await refresh({ isManualRefresh: true }); - - setDecisions((prev) => { - - const { [getLeadKey(selectedLead)]: _removed, ...rest } = prev; - - return rest; - + pushNotification({ + variant: 'success', + title: 'Статус оновлено', + message: `Статус змінено на "${DECISION_LABELS[status]}"${rejectionReason ? ` з причиною: ${rejectionReason}` : ''}`, }); - } catch (error) { - setDecisions((prev) => { - + const key = getLeadKey(selectedLead); const updated = { ...prev }; - - delete updated[getLeadKey(selectedLead)]; - + delete updated[key]; return updated; - }); - setStatusError(error instanceof Error ? error.message : 'Не вдалося оновити статус.'); + } + }, + [selectedLead, closeModal, refresh, pushNotification, closeLocalModal] + ); + const handleDecision = useCallback( + async (status) => { + if (!selectedLead) return; + + if (status === 'rejected') { + openModal({ + id: 'reject-form', + type: 'reject_modal', + props: { + title: 'Відхилення ліда', + content: ( +
+

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

+

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

+