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
@@ -0,0 +1,201 @@
<script>
import { onMount } from "svelte";
import { getAgents, getExecutions } from "../../lib/opc-api.js";
let agents = $state([]);
let executions = $state([]);
let loading = $state(true);
let selectedAgent = $state(null);
async function loadData() {
loading = true;
try {
const [agentsRes, executionsRes] = await Promise.all([
getAgents(),
getExecutions({ limit: 20 })
]);
agents = agentsRes.items || [];
executions = executionsRes.items || [];
} catch (e) {
console.error("Failed to load agent data:", e);
} finally {
loading = false;
}
}
function getAgentIcon(agentId) {
const icons = {
pm: "📋",
cto: "💻",
coo: "⚙️",
ceo: "🎯",
marketing: "📢",
social_media: "📱"
};
return icons[agentId] || "🤖";
}
function getStatusColor(status) {
const colors = {
pending: "bg-slate-100 text-slate-700",
running: "bg-blue-100 text-blue-700",
completed: "bg-emerald-100 text-emerald-700",
failed: "bg-rose-100 text-rose-700",
pending_approval: "bg-amber-100 text-amber-700"
};
return colors[status] || colors.pending;
}
function getAgentExecutions(agentId) {
return executions.filter(e => e.agent_id === agentId);
}
function formatTimestamp(timestamp) {
if (!timestamp) return "N/A";
return new Date(timestamp).toLocaleString();
}
onMount(() => {
loadData();
// Refresh every 10 seconds
const interval = setInterval(loadData, 10000);
return () => clearInterval(interval);
});
</script>
<div class="space-y-6">
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
AI Agents
</h2>
{#if loading}
<div class="text-surface-600 dark:text-surface-400">Loading agents...</div>
{:else}
<!-- Agent Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
{#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")}
<button
onclick={() => selectedAgent = selectedAgent?.id === agent.id ? null : agent}
class="bg-white dark:bg-surface-800 rounded-lg p-4 border-2 transition-all text-left hover:shadow-lg {selectedAgent?.id === agent.id ? 'border-primary-500' : 'border-surface-200 dark:border-surface-700'}"
>
<!-- Agent Header -->
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-2">
<span class="text-3xl">{getAgentIcon(agent.id)}</span>
<div>
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
{agent.name}
</h3>
<p class="text-xs text-surface-600 dark:text-surface-400">
{agent.role}
</p>
</div>
</div>
{#if runningExecs.length > 0}
<span class="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400">
<span class="animate-pulse"></span>
Working
</span>
{:else}
<span class="text-xs text-surface-500">Idle</span>
{/if}
</div>
<!-- Agent Stats -->
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
<div class="text-xs text-surface-600 dark:text-surface-400">Total Tasks</div>
<div class="text-lg font-semibold text-surface-900 dark:text-surface-100">
{agentExecs.length}
</div>
</div>
<div class="bg-surface-50 dark:bg-surface-900 rounded p-2">
<div class="text-xs text-surface-600 dark:text-surface-400">Completed</div>
<div class="text-lg font-semibold text-emerald-600 dark:text-emerald-400">
{completedExecs.length}
</div>
</div>
</div>
<!-- Capabilities -->
<div class="mt-3">
<div class="text-xs text-surface-600 dark:text-surface-400 mb-1">Capabilities:</div>
<div class="flex flex-wrap gap-1">
{#each agent.capabilities.slice(0, 3) as capability}
<span class="text-xs px-2 py-0.5 rounded bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300">
{capability.replace(/_/g, ' ')}
</span>
{/each}
{#if agent.capabilities.length > 3}
<span class="text-xs text-surface-500">+{agent.capabilities.length - 3}</span>
{/if}
</div>
</div>
</button>
{/each}
</div>
<!-- Execution Log -->
<div class="bg-white dark:bg-surface-800 rounded-lg border border-surface-200 dark:border-surface-700">
<div class="p-4 border-b border-surface-200 dark:border-surface-700">
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
{selectedAgent ? `${selectedAgent.name} Execution Log` : "Recent Executions"}
</h3>
</div>
<div class="divide-y divide-surface-200 dark:divide-surface-700 max-h-96 overflow-y-auto">
{#each (selectedAgent ? getAgentExecutions(selectedAgent.id) : executions) as execution}
<div class="p-4 hover:bg-surface-50 dark:hover:bg-surface-900/50">
<div class="flex items-start justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-xl">{getAgentIcon(execution.agent_id)}</span>
<div>
<div class="font-medium text-surface-900 dark:text-surface-100">
Task #{execution.task_id}
</div>
<div class="text-xs text-surface-600 dark:text-surface-400">
{formatTimestamp(execution.started_at)}
</div>
</div>
</div>
<span class="text-xs px-2 py-1 rounded {getStatusColor(execution.status)}">
{execution.status.replace(/_/g, ' ')}
</span>
</div>
{#if execution.output_result?.reasoning}
<p class="text-sm text-surface-700 dark:text-surface-300 mb-2">
{execution.output_result.reasoning}
</p>
{/if}
{#if execution.actions_proposed && execution.actions_proposed.length > 0}
<div class="text-xs text-surface-600 dark:text-surface-400">
Actions: {execution.actions_proposed.length}
{#each execution.actions_proposed.slice(0, 2) as action}
<span class="ml-2 px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700">
{action.type}
</span>
{/each}
</div>
{/if}
{#if execution.error_message}
<div class="mt-2 text-xs text-rose-600 dark:text-rose-400">
Error: {execution.error_message}
</div>
{/if}
</div>
{:else}
<div class="p-8 text-center text-surface-500">
No executions yet
</div>
{/each}
</div>
</div>
{/if}
</div>
+25 -7
View File
@@ -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
</p>
</div>
<button
onclick={handleCreateTask}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<span></span>
<span>New Task</span>
</button>
<div class="flex gap-2">
<button
onclick={() => showAgentPanel = !showAgentPanel}
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600 flex items-center gap-2"
>
<span>🤖</span>
<span>{showAgentPanel ? "Hide" : "Show"} Agents</span>
</button>
<button
onclick={handleCreateTask}
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 flex items-center gap-2"
>
<span></span>
<span>New Task</span>
</button>
</div>
</div>
<!-- Stats -->
@@ -166,6 +177,13 @@
/>
</div>
{/if}
<!-- Agent Panel -->
{#if showAgentPanel}
<div class="mt-6">
<AgentPanel />
</div>
{/if}
</div>
<!-- Task Modal -->