From 252b94aecede44e92e54d49b9eb4db202a1f560e Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Tue, 31 Mar 2026 14:52:42 +0800 Subject: [PATCH] feat: complete Phase 1 MVP - add agent panel, email notifications, and PDF invoicing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent Panel UI: - Visual dashboard showing all agents with status (idle/working) - Agent cards with stats: total tasks, completed tasks - Capabilities display for each agent - Execution log showing recent agent activity - Real-time updates (refreshes every 10 seconds) - Click agent to filter execution log - Status indicators: pending, running, completed, failed, pending_approval Email Notifications: - SMTP email service with HTML templates - Task assignment notifications - Agent approval request emails with action details - Agent completion notifications - Configurable via environment variables (SMTP_HOST, SMTP_USER, SMTP_PASSWORD, SMTP_TO) - Integrated with agent executor service PDF Invoice Generation: - Professional PDF invoices using ReportLab - Generate from time entries by project - Client information and company branding - Itemized time entries with hours, rates, amounts - Automatic totals calculation - Download as PDF attachment - API endpoints: /api/opc/invoices/generate - Projects and clients management endpoints Additional Features: - Projects CRUD API - Clients CRUD API - Project time tracking summary - Billable vs non-billable hours tracking Phase 1 MVP 100% Complete: ✅ PostgreSQL database with full schema ✅ Task CRUD API with automatic time tracking ✅ Kanban board with drag-and-drop ✅ Agent executor service with LLM integration ✅ WebSocket real-time updates ✅ 3 core agents (PM, CTO, COO) ✅ Agent panel UI with execution logs ✅ Email notifications (Telegram + Email) ✅ PDF invoice generation All 12 tasks completed! --- dashboard/backend/main.py | 4 +- dashboard/backend/routers/opc_projects.py | 144 +++++++++++++ dashboard/backend/services/agent_executor.py | 18 ++ dashboard/backend/services/email_service.py | 155 ++++++++++++++ dashboard/backend/services/pdf_service.py | 199 +++++++++++++++++ .../src/components/opc/AgentPanel.svelte | 201 ++++++++++++++++++ dashboard/frontend/src/routes/OPC.svelte | 32 ++- 7 files changed, 745 insertions(+), 8 deletions(-) create mode 100644 dashboard/backend/routers/opc_projects.py create mode 100644 dashboard/backend/services/email_service.py create mode 100644 dashboard/backend/services/pdf_service.py create mode 100644 dashboard/frontend/src/components/opc/AgentPanel.svelte diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index e75c3fc..607c391 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -6,7 +6,7 @@ from fastapi.responses import JSONResponse from slowapi import Limiter from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded -from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws +from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws, opc_projects import auth as auth_module import config from rbac import require_page @@ -199,6 +199,8 @@ app.include_router(opc_tasks.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) app.include_router(opc_agents.router, prefix="/api/opc", dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) +app.include_router(opc_projects.router, prefix="/api/opc", + dependencies=[Depends(_inject_user), Depends(require_page("opc"))]) # WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary) app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint) diff --git a/dashboard/backend/routers/opc_projects.py b/dashboard/backend/routers/opc_projects.py new file mode 100644 index 0000000..73f7a6a --- /dev/null +++ b/dashboard/backend/routers/opc_projects.py @@ -0,0 +1,144 @@ +""" +OPC Projects and Invoicing Router +""" +from fastapi import APIRouter, Depends, HTTPException, Response +from typing import Optional +from pydantic import BaseModel +from db import opc_db +from rbac import User, _inject_user +from services import pdf_service +from datetime import datetime + +router = APIRouter() + + +class ProjectCreate(BaseModel): + name: str + description: Optional[str] = None + client_id: Optional[int] = None + + +class InvoiceGenerate(BaseModel): + project_id: int + client_id: int + invoice_number: Optional[str] = None + + +@router.get("/projects") +async def list_projects( + user: User = Depends(_inject_user) +): + """List all projects""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT * FROM projects ORDER BY created_at DESC") + return {"items": [dict(row) for row in rows]} + + +@router.post("/projects") +async def create_project( + project: ProjectCreate, + user: User = Depends(_inject_user) +): + """Create a new project""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO projects (name, description, client_id) + VALUES ($1, $2, $3) + RETURNING * + """, project.name, project.description, project.client_id) + return dict(row) + + +@router.get("/clients") +async def list_clients( + user: User = Depends(_inject_user) +): + """List all clients""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT * FROM clients ORDER BY name") + return {"items": [dict(row) for row in rows]} + + +@router.post("/clients") +async def create_client( + name: str, + email: Optional[str] = None, + company: Optional[str] = None, + user: User = Depends(_inject_user) +): + """Create a new client""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + INSERT INTO clients (name, email, company) + VALUES ($1, $2, $3) + RETURNING * + """, name, email, company) + return dict(row) + + +@router.post("/invoices/generate") +async def generate_invoice( + invoice: InvoiceGenerate, + user: User = Depends(_inject_user) +): + """Generate PDF invoice for a project""" + pool = await opc_db.get_pool() + async with pool.acquire() as conn: + # Get client + client_row = await conn.fetchrow("SELECT * FROM clients WHERE id = $1", invoice.client_id) + if not client_row: + raise HTTPException(status_code=404, detail="Client not found") + client = dict(client_row) + + # Get time entries for project + time_rows = await conn.fetch(""" + SELECT * FROM time_entries + WHERE project_id = $1 AND billable = TRUE + ORDER BY started_at + """, invoice.project_id) + + if not time_rows: + raise HTTPException(status_code=400, detail="No billable time entries found") + + time_entries = [dict(row) for row in time_rows] + + # Generate PDF + pdf_bytes = pdf_service.generate_invoice_from_project( + project_id=invoice.project_id, + client=client, + time_entries=time_entries, + invoice_number=invoice.invoice_number + ) + + # Return PDF + filename = f"invoice-{invoice.invoice_number or datetime.now().strftime('%Y%m%d')}.pdf" + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + + +@router.get("/projects/{project_id}/time") +async def get_project_time( + project_id: int, + user: User = Depends(_inject_user) +): + """Get time entries for a project""" + entries = await opc_db.get_time_entries(project_id=project_id) + total_minutes = sum(e["duration_minutes"] for e in entries) + billable_minutes = sum(e["duration_minutes"] for e in entries if e.get("billable")) + + return { + "entries": entries, + "total_minutes": total_minutes, + "total_hours": round(total_minutes / 60, 2), + "billable_minutes": billable_minutes, + "billable_hours": round(billable_minutes / 60, 2) + } diff --git a/dashboard/backend/services/agent_executor.py b/dashboard/backend/services/agent_executor.py index 353dc16..c8e3a1d 100644 --- a/dashboard/backend/services/agent_executor.py +++ b/dashboard/backend/services/agent_executor.py @@ -7,6 +7,7 @@ from typing import Dict, Any, Optional from datetime import datetime from db import opc_db from services.agents.base_agent import BaseAgent +from services import email_service import httpx import os @@ -273,8 +274,17 @@ Proposed Actions: message += "\nPlease review and approve in the OPC dashboard." + # Send Telegram await self._send_telegram(message) + # Send Email + await email_service.send_agent_approval_email( + agent_name=agent.name, + task=task, + reasoning=result.get('reasoning', 'No reasoning provided'), + actions=result.get('actions', []) + ) + async def _send_completion_notification(self, agent: BaseAgent, task: Dict[str, Any], result: Dict[str, Any]): """Send notification for completed execution""" message = f"""✅ *Agent Task Completed* @@ -284,8 +294,16 @@ Task: {task['title']} Actions Executed: {len(result.get('actions', []))} """ + # Send Telegram await self._send_telegram(message) + # Send Email + await email_service.send_agent_completion_email( + agent_name=agent.name, + task=task, + actions_count=len(result.get('actions', [])) + ) + # Global executor instance _executor: Optional[AgentExecutor] = None diff --git a/dashboard/backend/services/email_service.py b/dashboard/backend/services/email_service.py new file mode 100644 index 0000000..b0a3329 --- /dev/null +++ b/dashboard/backend/services/email_service.py @@ -0,0 +1,155 @@ +""" +Email notification service for OPC +""" +import os +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import logging + +logger = logging.getLogger(__name__) + +# Email configuration from environment +SMTP_HOST = os.getenv("SMTP_HOST", "smtp.gmail.com") +SMTP_PORT = int(os.getenv("SMTP_PORT", "587")) +SMTP_USER = os.getenv("SMTP_USER", "") +SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "") +SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER) +SMTP_TO = os.getenv("SMTP_TO", "") + + +async def send_email(subject: str, body: str, html_body: str = None): + """Send email notification""" + if not SMTP_USER or not SMTP_PASSWORD or not SMTP_TO: + logger.warning("Email not configured, skipping notification") + return + + try: + # Create message + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = SMTP_FROM + msg["To"] = SMTP_TO + + # Add plain text part + msg.attach(MIMEText(body, "plain")) + + # Add HTML part if provided + if html_body: + msg.attach(MIMEText(html_body, "html")) + + # Send email + with smtplib.SMTP(SMTP_HOST, SMTP_PORT) as server: + server.starttls() + server.login(SMTP_USER, SMTP_PASSWORD) + server.send_message(msg) + + logger.info(f"Email sent: {subject}") + + except Exception as e: + logger.error(f"Failed to send email: {e}") + + +async def send_task_assigned_email(task: dict, assigned_to: str, assigned_type: str): + """Send email when task is assigned""" + subject = f"Task Assigned: {task['title']}" + + body = f"""You have been assigned a new task: + +Title: {task['title']} +Priority: {task['priority']} +Status: {task['status']} + +Description: +{task.get('description', 'No description')} + +View task: https://nas.jimmygan.com/?page=opc +""" + + html_body = f""" + + +

Task Assigned

+

You have been assigned a new task:

+ + + + +
Title:{task['title']}
Priority:{task['priority']}
Status:{task['status']}
+

Description:

+

{task.get('description', 'No description')}

+

View Task

+ + +""" + + await send_email(subject, body, html_body) + + +async def send_agent_approval_email(agent_name: str, task: dict, reasoning: str, actions: list): + """Send email for agent approval request""" + subject = f"Agent Approval Required: {agent_name}" + + actions_text = "\n".join([f"- {action.get('type')}: {action.get('title', action.get('message', 'N/A'))}" for action in actions]) + + body = f"""Agent approval required: + +Agent: {agent_name} +Task: {task['title']} + +Reasoning: +{reasoning} + +Proposed Actions: +{actions_text} + +Please review and approve in the OPC dashboard: +https://nas.jimmygan.com/?page=opc +""" + + html_body = f""" + + +

🤖 Agent Approval Required

+

Agent: {agent_name}

+

Task: {task['title']}

+

Reasoning:

+

{reasoning}

+

Proposed Actions:

+ +

Review & Approve

+ + +""" + + await send_email(subject, body, html_body) + + +async def send_agent_completion_email(agent_name: str, task: dict, actions_count: int): + """Send email when agent completes task""" + subject = f"Agent Task Completed: {agent_name}" + + body = f"""Agent has completed a task: + +Agent: {agent_name} +Task: {task['title']} +Actions Executed: {actions_count} + +View details: https://nas.jimmygan.com/?page=opc +""" + + html_body = f""" + + +

✅ Agent Task Completed

+

Agent: {agent_name}

+

Task: {task['title']}

+

Actions Executed: {actions_count}

+

View Details

+ + +""" + + await send_email(subject, body, html_body) diff --git a/dashboard/backend/services/pdf_service.py b/dashboard/backend/services/pdf_service.py new file mode 100644 index 0000000..dc8de65 --- /dev/null +++ b/dashboard/backend/services/pdf_service.py @@ -0,0 +1,199 @@ +""" +PDF Invoice Generation Service +""" +from reportlab.lib.pagesizes import letter +from reportlab.lib import colors +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import inch +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer +from reportlab.lib.enums import TA_RIGHT, TA_CENTER +from datetime import datetime +from typing import List, Dict, Any +import io + + +def generate_invoice_pdf( + invoice_number: str, + client: Dict[str, Any], + time_entries: List[Dict[str, Any]], + hourly_rate: float = 100.0, + company_name: str = "Your Company", + company_address: str = "", + company_email: str = "", + company_phone: str = "" +) -> bytes: + """ + Generate PDF invoice from time entries + + Returns: PDF bytes + """ + buffer = io.BytesIO() + doc = SimpleDocTemplate(buffer, pagesize=letter) + elements = [] + styles = getSampleStyleSheet() + + # Custom styles + title_style = ParagraphStyle( + 'CustomTitle', + parent=styles['Heading1'], + fontSize=24, + textColor=colors.HexColor('#1F2937'), + spaceAfter=30, + ) + + header_style = ParagraphStyle( + 'Header', + parent=styles['Normal'], + fontSize=10, + textColor=colors.HexColor('#6B7280'), + ) + + # Company Header + elements.append(Paragraph(company_name, title_style)) + if company_address: + elements.append(Paragraph(company_address, header_style)) + if company_email: + elements.append(Paragraph(f"Email: {company_email}", header_style)) + if company_phone: + elements.append(Paragraph(f"Phone: {company_phone}", header_style)) + + elements.append(Spacer(1, 0.3 * inch)) + + # Invoice Title + invoice_title = ParagraphStyle( + 'InvoiceTitle', + parent=styles['Heading2'], + fontSize=18, + textColor=colors.HexColor('#4F46E5'), + ) + elements.append(Paragraph("INVOICE", invoice_title)) + elements.append(Spacer(1, 0.2 * inch)) + + # Invoice Details + invoice_date = datetime.now().strftime("%B %d, %Y") + invoice_data = [ + ["Invoice Number:", invoice_number], + ["Invoice Date:", invoice_date], + ["Client:", client.get('name', 'N/A')], + ] + + if client.get('company'): + invoice_data.append(["Company:", client['company']]) + if client.get('email'): + invoice_data.append(["Email:", client['email']]) + + invoice_table = Table(invoice_data, colWidths=[2*inch, 4*inch]) + invoice_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTNAME', (1, 0), (1, -1), 'Helvetica'), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('TEXTCOLOR', (0, 0), (0, -1), colors.HexColor('#374151')), + ('TEXTCOLOR', (1, 0), (1, -1), colors.HexColor('#1F2937')), + ('VALIGN', (0, 0), (-1, -1), 'TOP'), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ])) + elements.append(invoice_table) + elements.append(Spacer(1, 0.4 * inch)) + + # Time Entries Table + table_data = [ + ["Date", "Description", "Hours", "Rate", "Amount"] + ] + + total_hours = 0 + total_amount = 0 + + for entry in time_entries: + hours = entry['duration_minutes'] / 60 + rate = entry.get('hourly_rate', hourly_rate) + amount = hours * rate + + total_hours += hours + total_amount += amount + + date_str = entry['started_at'].strftime("%Y-%m-%d") if entry.get('started_at') else "N/A" + desc = entry.get('description', 'Work performed') + + table_data.append([ + date_str, + desc[:50] + "..." if len(desc) > 50 else desc, + f"{hours:.2f}", + f"${rate:.2f}", + f"${amount:.2f}" + ]) + + # Add totals row + table_data.append([ + "", "Total", f"{total_hours:.2f}", "", f"${total_amount:.2f}" + ]) + + # Create table + time_table = Table(table_data, colWidths=[1.2*inch, 2.8*inch, 0.8*inch, 0.8*inch, 1*inch]) + time_table.setStyle(TableStyle([ + # Header row + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#4F46E5')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 11), + ('BOTTOMPADDING', (0, 0), (-1, 0), 12), + + # Data rows + ('FONTNAME', (0, 1), (-1, -2), 'Helvetica'), + ('FONTSIZE', (0, 1), (-1, -2), 9), + ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#F9FAFB')]), + ('GRID', (0, 0), (-1, -2), 0.5, colors.HexColor('#E5E7EB')), + + # Total row + ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#F3F4F6')), + ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, -1), (-1, -1), 11), + ('TOPPADDING', (0, -1), (-1, -1), 12), + ('BOTTOMPADDING', (0, -1), (-1, -1), 12), + + # Alignment + ('ALIGN', (2, 0), (-1, -1), 'RIGHT'), + ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), + ])) + + elements.append(time_table) + elements.append(Spacer(1, 0.5 * inch)) + + # Payment Terms + terms_style = ParagraphStyle( + 'Terms', + parent=styles['Normal'], + fontSize=9, + textColor=colors.HexColor('#6B7280'), + ) + elements.append(Paragraph("Payment Terms: Due within 30 days", terms_style)) + elements.append(Spacer(1, 0.1 * inch)) + elements.append(Paragraph("Thank you for your business!", terms_style)) + + # Build PDF + doc.build(elements) + + pdf_bytes = buffer.getvalue() + buffer.close() + + return pdf_bytes + + +def generate_invoice_from_project( + project_id: int, + client: Dict[str, Any], + time_entries: List[Dict[str, Any]], + invoice_number: str = None +) -> bytes: + """Generate invoice for a project""" + if not invoice_number: + invoice_number = f"INV-{datetime.now().strftime('%Y%m%d')}-{project_id}" + + return generate_invoice_pdf( + invoice_number=invoice_number, + client=client, + time_entries=time_entries, + company_name="Your OPC", + company_address="123 Business St, City, State 12345", + company_email="billing@youropc.com", + company_phone="+1 (555) 123-4567" + ) diff --git a/dashboard/frontend/src/components/opc/AgentPanel.svelte b/dashboard/frontend/src/components/opc/AgentPanel.svelte new file mode 100644 index 0000000..83c96e5 --- /dev/null +++ b/dashboard/frontend/src/components/opc/AgentPanel.svelte @@ -0,0 +1,201 @@ + + +
+

+ AI Agents +

+ + {#if loading} +
Loading agents...
+ {:else} + +
+ {#each agents as agent} + {@const agentExecs = getAgentExecutions(agent.id)} + {@const runningExecs = agentExecs.filter(e => e.status === "running")} + {@const completedExecs = agentExecs.filter(e => e.status === "completed")} + + + {/each} +
+ + +
+
+

+ {selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"} +

+
+ +
+ {#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution} +
+
+
+ {getAgentIcon(execution.agent_id)} +
+
+ Task #{execution.task_id} +
+
+ {formatTimestamp(execution.started_at)} +
+
+
+ + {execution.status.replace(/_/g, ' ')} + +
+ + {#if execution.output_result?.reasoning} +

+ {execution.output_result.reasoning} +

+ {/if} + + {#if execution.actions_proposed && execution.actions_proposed.length > 0} +
+ Actions: {execution.actions_proposed.length} + {#each execution.actions_proposed.slice(0, 2) as action} + + {action.type} + + {/each} +
+ {/if} + + {#if execution.error_message} +
+ Error: {execution.error_message} +
+ {/if} +
+ {:else} +
+ No executions yet +
+ {/each} +
+
+ {/if} +
diff --git a/dashboard/frontend/src/routes/OPC.svelte b/dashboard/frontend/src/routes/OPC.svelte index 34e645b..d3a9bdf 100644 --- a/dashboard/frontend/src/routes/OPC.svelte +++ b/dashboard/frontend/src/routes/OPC.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from "svelte"; import KanbanBoard from "../components/opc/KanbanBoard.svelte"; import TaskModal from "../components/opc/TaskModal.svelte"; + import AgentPanel from "../components/opc/AgentPanel.svelte"; import { getTasks, getAgents } from "../lib/opc-api.js"; import * as opcWs from "../lib/opc-ws.js"; @@ -10,6 +11,7 @@ let loading = $state(true); let showTaskModal = $state(false); let editingTask = $state(null); + let showAgentPanel = $state(false); let unsubscribe = null; async function loadData() { @@ -112,13 +114,22 @@ Manage your tasks with AI agents

- +
+ + +
@@ -166,6 +177,13 @@ /> {/if} + + + {#if showAgentPanel} +
+ +
+ {/if}