1ca019fa48
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
251 lines
8.6 KiB
Svelte
251 lines
8.6 KiB
Svelte
<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>
|