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:
@@ -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>
|
||||
Reference in New Issue
Block a user