feat: complete Phase 1 MVP - add agent panel, email notifications, and PDF invoicing
Deploy Dashboard (Dev) / deploy-dev (push) Failing after 24m36s
Run Tests / Backend Tests (push) Successful in 10m13s
Run Tests / Frontend Tests (push) Failing after 22s
Run Tests / Test Summary (push) Failing after 41s

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!
This commit is contained in:
Gan, Jimmy
2026-03-31 14:52:42 +08:00
parent 1422cc9bc8
commit 252b94aece
7 changed files with 745 additions and 8 deletions
+3 -1
View File
@@ -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)
+144
View File
@@ -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)
}
@@ -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
+155
View File
@@ -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"""
<html>
<body>
<h2>Task Assigned</h2>
<p>You have been assigned a new task:</p>
<table style="border-collapse: collapse; margin: 20px 0;">
<tr><td style="padding: 8px; font-weight: bold;">Title:</td><td style="padding: 8px;">{task['title']}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Priority:</td><td style="padding: 8px;">{task['priority']}</td></tr>
<tr><td style="padding: 8px; font-weight: bold;">Status:</td><td style="padding: 8px;">{task['status']}</td></tr>
</table>
<p><strong>Description:</strong></p>
<p>{task.get('description', 'No description')}</p>
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Task</a></p>
</body>
</html>
"""
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"""
<html>
<body>
<h2>🤖 Agent Approval Required</h2>
<p><strong>Agent:</strong> {agent_name}</p>
<p><strong>Task:</strong> {task['title']}</p>
<h3>Reasoning:</h3>
<p>{reasoning}</p>
<h3>Proposed Actions:</h3>
<ul>
{"".join([f"<li><strong>{action.get('type')}</strong>: {action.get('title', action.get('message', 'N/A'))}</li>" for action in actions])}
</ul>
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #4F46E5; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">Review & Approve</a></p>
</body>
</html>
"""
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"""
<html>
<body>
<h2>✅ Agent Task Completed</h2>
<p><strong>Agent:</strong> {agent_name}</p>
<p><strong>Task:</strong> {task['title']}</p>
<p><strong>Actions Executed:</strong> {actions_count}</p>
<p><a href="https://nas.jimmygan.com/?page=opc" style="background-color: #10B981; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; display: inline-block;">View Details</a></p>
</body>
</html>
"""
await send_email(subject, body, html_body)
+199
View File
@@ -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("<b>Payment Terms:</b> 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"
)