feat: add OPC (One Person Company) management system with Kanban board
Phase 1 MVP implementation: - PostgreSQL database schema for tasks, agents, executions, time tracking - Backend API: task CRUD, agent management, automatic time tracking - Frontend: Kanban board with drag-and-drop (Parking Lot → In Progress → Done) - Task modal for creating/editing tasks with tags, priority, assignee - 3 core agents seeded: PM, CTO, COO - Agent approval workflow: CxO level requires approval, others auto-execute - Automatic time tracking: starts on in_progress, stops on done - RBAC integration: OPC page accessible to admin/member roles Features: - Drag-and-drop tasks between columns - Assign tasks to humans or AI agents - Priority badges (low, medium, high, urgent) - Tags and due dates - Task history tracking - Time entry tracking with automatic timer - Agent execution records with approval workflow Next steps: - Initialize OPC database on NAS - Implement agent executor service - Add WebSocket for real-time updates - Add email notifications - Add PDF invoice generation
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
import LiteLLM from "./routes/LiteLLM.svelte";
|
||||
import CcConnect from "./routes/CcConnect.svelte";
|
||||
import InfoEngine from "./routes/InfoEngine.svelte";
|
||||
import OPC from "./routes/OPC.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
||||
@@ -29,6 +30,7 @@
|
||||
"litellm",
|
||||
"cc-connect",
|
||||
"info-engine",
|
||||
"opc",
|
||||
]);
|
||||
|
||||
let page = $state("dashboard");
|
||||
@@ -207,6 +209,8 @@
|
||||
<CcConnect />
|
||||
{:else if page === "info-engine" && hasPageAccess("dashboard")}
|
||||
<InfoEngine />
|
||||
{:else if page === "opc" && hasPageAccess("opc")}
|
||||
<OPC />
|
||||
{:else if page !== "terminal"}
|
||||
<Dashboard />
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
{ id: "dashboard", label: "Overview", icon: "grid" },
|
||||
{ id: "info-engine", label: "Info Engine", icon: "sparkles" },
|
||||
{ id: "litellm", label: "LiteLLM", icon: "bolt" },
|
||||
{ id: "opc", label: "OPC", icon: "kanban" },
|
||||
{ id: "docker", label: "Docker", icon: "box" },
|
||||
{ id: "files", label: "Files", icon: "folder" },
|
||||
{ id: "terminal", label: "Terminal", icon: "terminal" },
|
||||
@@ -243,6 +244,8 @@
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
|
||||
{:else if icon === "shield"}
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
{:else if icon === "kanban"}
|
||||
<svg class="w-[18px] h-[18px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2zm8 0h-2a2 2 0 00-2 2v8a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2z" /></svg>
|
||||
{:else if icon === "music"}
|
||||
<svg class="w-[16px] h-[16px]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8"><path stroke-linecap="round" stroke-linejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" /></svg>
|
||||
{:else if icon === "play"}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script>
|
||||
import KanbanColumn from "./KanbanColumn.svelte";
|
||||
import { moveTask } from "../../lib/opc-api.js";
|
||||
|
||||
let { tasks = [], agents = [], onEdit, onDelete, onAssign, onTaskMoved } = $props();
|
||||
|
||||
const columns = [
|
||||
{ id: "parking_lot", title: "Parking Lot", status: "parking_lot" },
|
||||
{ id: "in_progress", title: "In Progress", status: "in_progress" },
|
||||
{ id: "done", title: "Done", status: "done" }
|
||||
];
|
||||
|
||||
function getTasksByStatus(status) {
|
||||
return tasks.filter(t => t.status === status);
|
||||
}
|
||||
|
||||
async function handleDrop(taskId, newStatus) {
|
||||
try {
|
||||
await moveTask(taskId, newStatus);
|
||||
onTaskMoved?.(taskId, newStatus);
|
||||
} catch (e) {
|
||||
alert("Failed to move task: " + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 h-full">
|
||||
{#each columns as column}
|
||||
<KanbanColumn
|
||||
title={column.title}
|
||||
status={column.status}
|
||||
tasks={getTasksByStatus(column.status)}
|
||||
{agents}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onAssign}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script>
|
||||
import TaskCard from "./TaskCard.svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
status,
|
||||
tasks = [],
|
||||
agents = [],
|
||||
onEdit,
|
||||
onDelete,
|
||||
onAssign,
|
||||
onDrop
|
||||
} = $props();
|
||||
|
||||
const columnColors = {
|
||||
parking_lot: "bg-slate-100 dark:bg-slate-800 border-slate-300",
|
||||
in_progress: "bg-blue-50 dark:bg-blue-900/20 border-blue-300",
|
||||
done: "bg-emerald-50 dark:bg-emerald-900/20 border-emerald-300"
|
||||
};
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.add("ring-2", "ring-primary-400");
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove("ring-2", "ring-primary-400");
|
||||
|
||||
const taskId = parseInt(e.dataTransfer.getData("taskId"));
|
||||
const fromStatus = e.dataTransfer.getData("fromStatus");
|
||||
|
||||
if (fromStatus !== status) {
|
||||
onDrop?.(taskId, status);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e, task) {
|
||||
e.dataTransfer.setData("taskId", task.id);
|
||||
e.dataTransfer.setData("fromStatus", task.status);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<!-- Column header -->
|
||||
<div class="flex items-center justify-between mb-3 pb-2 border-b border-surface-300 dark:border-surface-600">
|
||||
<h3 class="font-semibold text-surface-900 dark:text-surface-100">
|
||||
{title}
|
||||
</h3>
|
||||
<span class="text-sm text-surface-600 dark:text-surface-400 bg-surface-200 dark:bg-surface-700 px-2 py-0.5 rounded-full">
|
||||
{tasks.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Drop zone -->
|
||||
<div
|
||||
class="flex-1 min-h-[200px] p-2 rounded-lg border-2 border-dashed transition-all {columnColors[status] || columnColors.parking_lot}"
|
||||
ondragover={handleDragOver}
|
||||
ondragleave={handleDragLeave}
|
||||
ondrop={handleDrop}
|
||||
>
|
||||
{#if tasks.length === 0}
|
||||
<div class="flex items-center justify-center h-32 text-surface-400 dark:text-surface-500 text-sm">
|
||||
Drop tasks here
|
||||
</div>
|
||||
{:else}
|
||||
{#each tasks as task (task.id)}
|
||||
<div draggable="true" ondragstart={(e) => handleDragStart(e, task)}>
|
||||
<TaskCard
|
||||
{task}
|
||||
{agents}
|
||||
{onEdit}
|
||||
{onDelete}
|
||||
{onAssign}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,169 @@
|
||||
<script>
|
||||
import { deleteTask, assignTask, getTaskTime } from "../../lib/opc-api.js";
|
||||
|
||||
let { task, agents = [], onEdit, onDelete, onAssign } = $props();
|
||||
|
||||
let showActions = $state(false);
|
||||
let timeInfo = $state(null);
|
||||
|
||||
const priorityColors = {
|
||||
low: "bg-slate-200 text-slate-700",
|
||||
medium: "bg-blue-100 text-blue-700",
|
||||
high: "bg-amber-100 text-amber-700",
|
||||
urgent: "bg-rose-100 text-rose-700"
|
||||
};
|
||||
|
||||
async function loadTimeInfo() {
|
||||
if (task.status === "in_progress") {
|
||||
try {
|
||||
timeInfo = await getTaskTime(task.id);
|
||||
} catch (e) {
|
||||
console.error("Failed to load time info:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadTimeInfo();
|
||||
});
|
||||
|
||||
async function handleDelete() {
|
||||
if (confirm(`Delete task "${task.title}"?`)) {
|
||||
try {
|
||||
await deleteTask(task.id);
|
||||
onDelete?.(task.id);
|
||||
} catch (e) {
|
||||
alert("Failed to delete task: " + e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignAgent(agentId) {
|
||||
try {
|
||||
await assignTask(task.id, agentId, "agent");
|
||||
onAssign?.(task.id, agentId, "agent");
|
||||
} catch (e) {
|
||||
alert("Failed to assign agent: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function getAgentIcon(agentId) {
|
||||
const icons = {
|
||||
pm: "📋",
|
||||
cto: "💻",
|
||||
coo: "⚙️",
|
||||
ceo: "🎯",
|
||||
marketing: "📢",
|
||||
social_media: "📱"
|
||||
};
|
||||
return icons[agentId] || "🤖";
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="bg-white dark:bg-surface-800 rounded-lg p-3 mb-2 shadow-sm border border-surface-200 dark:border-surface-700 cursor-move hover:shadow-md transition-shadow"
|
||||
onmouseenter={() => showActions = true}
|
||||
onmouseleave={() => showActions = false}
|
||||
>
|
||||
<!-- Priority badge -->
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<span class="text-xs px-2 py-0.5 rounded {priorityColors[task.priority] || priorityColors.medium}">
|
||||
{task.priority}
|
||||
</span>
|
||||
{#if showActions}
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
onclick={() => onEdit?.(task)}
|
||||
class="text-xs text-surface-600 hover:text-primary-600 dark:text-surface-400"
|
||||
title="Edit"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onclick={handleDelete}
|
||||
class="text-xs text-surface-600 hover:text-rose-600 dark:text-surface-400"
|
||||
title="Delete"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h4 class="font-medium text-surface-900 dark:text-surface-100 mb-1 text-sm">
|
||||
{task.title}
|
||||
</h4>
|
||||
|
||||
<!-- Description preview -->
|
||||
{#if task.description}
|
||||
<p class="text-xs text-surface-600 dark:text-surface-400 mb-2 line-clamp-2">
|
||||
{task.description}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Tags -->
|
||||
{#if task.tags && task.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1 mb-2">
|
||||
{#each task.tags as tag}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-surface-100 dark:bg-surface-700 text-surface-700 dark:text-surface-300">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Assignee and time -->
|
||||
<div class="flex items-center justify-between text-xs text-surface-600 dark:text-surface-400">
|
||||
<div class="flex items-center gap-1">
|
||||
{#if task.assigned_to}
|
||||
{#if task.assigned_type === "agent"}
|
||||
<span title={task.assigned_to}>
|
||||
{getAgentIcon(task.assigned_to)}
|
||||
</span>
|
||||
{:else}
|
||||
<span>👤 {task.assigned_to}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<button
|
||||
onclick={() => showActions = true}
|
||||
class="text-surface-500 hover:text-primary-600"
|
||||
title="Assign"
|
||||
>
|
||||
Unassigned
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if timeInfo && timeInfo.total_hours > 0}
|
||||
<span class="text-xs text-surface-500">
|
||||
⏱️ {timeInfo.total_hours}h
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
{#if task.due_date}
|
||||
<div class="text-xs text-surface-500 mt-1">
|
||||
📅 {new Date(task.due_date).toLocaleDateString()}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Quick assign to agent (when hovering) -->
|
||||
{#if showActions && !task.assigned_to}
|
||||
<div class="mt-2 pt-2 border-t border-surface-200 dark:border-surface-700">
|
||||
<div class="text-xs text-surface-600 mb-1">Assign to agent:</div>
|
||||
<div class="flex gap-1">
|
||||
{#each agents as agent}
|
||||
<button
|
||||
onclick={() => handleAssignAgent(agent.id)}
|
||||
class="text-lg hover:scale-110 transition-transform"
|
||||
title={agent.name}
|
||||
>
|
||||
{getAgentIcon(agent.id)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,250 @@
|
||||
<script>
|
||||
import { createTask, updateTask } from "../../lib/opc-api.js";
|
||||
|
||||
let { task = null, agents = [], onClose, onSave } = $props();
|
||||
|
||||
let isEdit = $state(!!task);
|
||||
let formData = $state({
|
||||
title: task?.title || "",
|
||||
description: task?.description || "",
|
||||
status: task?.status || "parking_lot",
|
||||
priority: task?.priority || "medium",
|
||||
assigned_to: task?.assigned_to || "",
|
||||
assigned_type: task?.assigned_type || "human",
|
||||
tags: task?.tags || [],
|
||||
due_date: task?.due_date ? task.due_date.split("T")[0] : ""
|
||||
});
|
||||
|
||||
let tagInput = $state("");
|
||||
let saving = $state(false);
|
||||
|
||||
function addTag() {
|
||||
if (tagInput.trim() && !formData.tags.includes(tagInput.trim())) {
|
||||
formData.tags = [...formData.tags, tagInput.trim()];
|
||||
tagInput = "";
|
||||
}
|
||||
}
|
||||
|
||||
function removeTag(tag) {
|
||||
formData.tags = formData.tags.filter(t => t !== tag);
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
due_date: formData.due_date ? new Date(formData.due_date).toISOString() : null,
|
||||
assigned_to: formData.assigned_to || null
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
await updateTask(task.id, payload);
|
||||
} else {
|
||||
await createTask(payload);
|
||||
}
|
||||
|
||||
onSave?.();
|
||||
onClose?.();
|
||||
} catch (e) {
|
||||
alert("Failed to save task: " + e.message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Modal backdrop -->
|
||||
<div
|
||||
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose?.()}
|
||||
>
|
||||
<!-- Modal content -->
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-surface-200 dark:border-surface-700">
|
||||
<h2 class="text-xl font-semibold text-surface-900 dark:text-surface-100">
|
||||
{isEdit ? "Edit Task" : "Create Task"}
|
||||
</h2>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form onsubmit={handleSubmit} class="p-4 space-y-4">
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Title *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.title}
|
||||
required
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Task title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
bind:value={formData.description}
|
||||
rows="4"
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Task description"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Status and Priority -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.status}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="parking_lot">Parking Lot</option>
|
||||
<option value="in_progress">In Progress</option>
|
||||
<option value="done">Done</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.priority}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Assign to
|
||||
</label>
|
||||
<select
|
||||
bind:value={formData.assigned_type}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="human">Human</option>
|
||||
<option value="agent">Agent</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
{formData.assigned_type === "agent" ? "Agent" : "Username"}
|
||||
</label>
|
||||
{#if formData.assigned_type === "agent"}
|
||||
<select
|
||||
bind:value={formData.assigned_to}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{#each agents as agent}
|
||||
<option value={agent.id}>{agent.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={formData.assigned_to}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Username"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Due Date
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
bind:value={formData.due_date}
|
||||
class="w-full px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tags -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
|
||||
Tags
|
||||
</label>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={tagInput}
|
||||
onkeydown={(e) => e.key === "Enter" && (e.preventDefault(), addTag())}
|
||||
class="flex-1 px-3 py-2 border border-surface-300 dark:border-surface-600 rounded-lg bg-white dark:bg-surface-900 text-surface-900 dark:text-surface-100"
|
||||
placeholder="Add tag and press Enter"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={addTag}
|
||||
class="px-4 py-2 bg-surface-200 dark:bg-surface-700 text-surface-700 dark:text-surface-300 rounded-lg hover:bg-surface-300 dark:hover:bg-surface-600"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{#if formData.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each formData.tags as tag}
|
||||
<span class="inline-flex items-center gap-1 px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded text-sm">
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeTag(tag)}
|
||||
class="hover:text-primary-900 dark:hover:text-primary-100"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-4 border-t border-surface-200 dark:border-surface-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="px-4 py-2 text-surface-700 dark:text-surface-300 hover:bg-surface-100 dark:hover:bg-surface-700 rounded-lg"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Update" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* OPC API Client
|
||||
*/
|
||||
import { get, post, put, del } from "./api.js";
|
||||
|
||||
// Tasks
|
||||
export async function getTasks(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.assigned_to) params.append("assigned_to", filters.assigned_to);
|
||||
if (filters.project_id) params.append("project_id", filters.project_id);
|
||||
if (filters.priority) params.append("priority", filters.priority);
|
||||
if (filters.tags) params.append("tags", filters.tags.join(","));
|
||||
if (filters.limit) params.append("limit", filters.limit);
|
||||
if (filters.offset) params.append("offset", filters.offset);
|
||||
|
||||
const query = params.toString();
|
||||
return get(`/opc/tasks${query ? "?" + query : ""}`);
|
||||
}
|
||||
|
||||
export async function createTask(task) {
|
||||
return post("/opc/tasks", task);
|
||||
}
|
||||
|
||||
export async function updateTask(taskId, updates) {
|
||||
return put(`/opc/tasks/${taskId}`, updates);
|
||||
}
|
||||
|
||||
export async function deleteTask(taskId) {
|
||||
return del(`/opc/tasks/${taskId}`);
|
||||
}
|
||||
|
||||
export async function moveTask(taskId, status) {
|
||||
return put(`/opc/tasks/${taskId}/move`, { status });
|
||||
}
|
||||
|
||||
export async function assignTask(taskId, assigned_to, assigned_type = "human") {
|
||||
return post(`/opc/tasks/${taskId}/assign`, { assigned_to, assigned_type });
|
||||
}
|
||||
|
||||
export async function getTaskHistory(taskId) {
|
||||
return get(`/opc/tasks/${taskId}/history`);
|
||||
}
|
||||
|
||||
export async function getTaskTime(taskId) {
|
||||
return get(`/opc/tasks/${taskId}/time`);
|
||||
}
|
||||
|
||||
// Agents
|
||||
export async function getAgents(enabledOnly = true) {
|
||||
return get(`/opc/agents?enabled_only=${enabledOnly}`);
|
||||
}
|
||||
|
||||
export async function getAgent(agentId) {
|
||||
return get(`/opc/agents/${agentId}`);
|
||||
}
|
||||
|
||||
export async function triggerAgent(agentId, taskId) {
|
||||
return post(`/opc/agents/${agentId}/execute?task_id=${taskId}`);
|
||||
}
|
||||
|
||||
// Executions
|
||||
export async function getExecutions(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.task_id) params.append("task_id", filters.task_id);
|
||||
if (filters.agent_id) params.append("agent_id", filters.agent_id);
|
||||
if (filters.status) params.append("status", filters.status);
|
||||
if (filters.limit) params.append("limit", filters.limit);
|
||||
|
||||
const query = params.toString();
|
||||
return get(`/opc/executions${query ? "?" + query : ""}`);
|
||||
}
|
||||
|
||||
export async function getExecution(executionId) {
|
||||
return get(`/opc/executions/${executionId}`);
|
||||
}
|
||||
|
||||
export async function approveExecution(executionId, approved) {
|
||||
return post(`/opc/executions/${executionId}/approve`, { approved });
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import KanbanBoard from "../components/opc/KanbanBoard.svelte";
|
||||
import TaskModal from "../components/opc/TaskModal.svelte";
|
||||
import { getTasks, getAgents } from "../lib/opc-api.js";
|
||||
|
||||
let tasks = $state([]);
|
||||
let agents = $state([]);
|
||||
let loading = $state(true);
|
||||
let showTaskModal = $state(false);
|
||||
let editingTask = $state(null);
|
||||
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tasksRes, agentsRes] = await Promise.all([
|
||||
getTasks(),
|
||||
getAgents()
|
||||
]);
|
||||
tasks = tasksRes.items || [];
|
||||
agents = agentsRes.items || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load data:", e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCreateTask() {
|
||||
editingTask = null;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleEditTask(task) {
|
||||
editingTask = task;
|
||||
showTaskModal = true;
|
||||
}
|
||||
|
||||
function handleDeleteTask(taskId) {
|
||||
tasks = tasks.filter(t => t.id !== taskId);
|
||||
}
|
||||
|
||||
function handleAssignTask(taskId, assignedTo, assignedType) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.assigned_to = assignedTo;
|
||||
task.assigned_type = assignedType;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTaskMoved(taskId, newStatus) {
|
||||
const task = tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = newStatus;
|
||||
tasks = [...tasks];
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
showTaskModal = false;
|
||||
editingTask = null;
|
||||
}
|
||||
|
||||
function handleModalSave() {
|
||||
loadData();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-6 h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100">
|
||||
OPC Management
|
||||
</h1>
|
||||
<p class="text-surface-600 dark:text-surface-400 mt-1">
|
||||
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>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Total Tasks</div>
|
||||
<div class="text-2xl font-bold text-surface-900 dark:text-surface-100 mt-1">
|
||||
{tasks.length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Parking Lot</div>
|
||||
<div class="text-2xl font-bold text-slate-600 dark:text-slate-400 mt-1">
|
||||
{tasks.filter(t => t.status === "parking_lot").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">In Progress</div>
|
||||
<div class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-1">
|
||||
{tasks.filter(t => t.status === "in_progress").length}
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white dark:bg-surface-800 rounded-lg p-4 border border-surface-200 dark:border-surface-700">
|
||||
<div class="text-sm text-surface-600 dark:text-surface-400">Done</div>
|
||||
<div class="text-2xl font-bold text-emerald-600 dark:text-emerald-400 mt-1">
|
||||
{tasks.filter(t => t.status === "done").length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kanban Board -->
|
||||
{#if loading}
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-surface-600 dark:text-surface-400">Loading...</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<KanbanBoard
|
||||
{tasks}
|
||||
{agents}
|
||||
onEdit={handleEditTask}
|
||||
onDelete={handleDeleteTask}
|
||||
onAssign={handleAssignTask}
|
||||
onTaskMoved={handleTaskMoved}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Task Modal -->
|
||||
{#if showTaskModal}
|
||||
<TaskModal
|
||||
task={editingTask}
|
||||
{agents}
|
||||
onClose={handleModalClose}
|
||||
onSave={handleModalSave}
|
||||
/>
|
||||
{/if}
|
||||
Reference in New Issue
Block a user