@leejungkiin/awkit 1.1.6 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -1
- package/bin/awk.js +2 -2
- package/core/GEMINI.md +45 -7
- package/package.json +8 -5
- package/skill-packs/neural-memory/skills/nm-memory-sync/SKILL.md +14 -1
- package/skills/ab-test-store-listing/SKILL.md +220 -0
- package/skills/android-aso/SKILL.md +197 -0
- package/skills/app-analytics/SKILL.md +210 -0
- package/skills/app-clips/SKILL.md +163 -0
- package/skills/app-icon-optimization/SKILL.md +170 -0
- package/skills/app-launch/SKILL.md +153 -0
- package/skills/app-marketing-context/SKILL.md +129 -0
- package/skills/app-store-featured/SKILL.md +213 -0
- package/skills/apple-search-ads/SKILL.md +205 -0
- package/skills/asc-metrics/SKILL.md +157 -0
- package/skills/aso-audit/SKILL.md +179 -0
- package/skills/competitor-analysis/SKILL.md +163 -0
- package/skills/competitor-tracking/SKILL.md +185 -0
- package/skills/crash-analytics/SKILL.md +181 -0
- package/skills/gitnexus-intelligence/SKILL.md +224 -0
- package/skills/in-app-events/SKILL.md +176 -0
- package/skills/keyword-research/SKILL.md +141 -0
- package/skills/localization/SKILL.md +165 -0
- package/skills/market-movers/SKILL.md +137 -0
- package/skills/market-pulse/SKILL.md +170 -0
- package/skills/metadata-optimization/SKILL.md +170 -0
- package/skills/monetization-strategy/SKILL.md +175 -0
- package/skills/onboarding-optimization/SKILL.md +194 -0
- package/skills/orchestrator/SKILL.md +306 -25
- package/skills/press-and-pr/SKILL.md +204 -0
- package/skills/rating-prompt-strategy/SKILL.md +184 -0
- package/skills/retention-optimization/SKILL.md +165 -0
- package/skills/review-management/SKILL.md +154 -0
- package/skills/screenshot-optimization/SKILL.md +167 -0
- package/skills/seasonal-aso/SKILL.md +141 -0
- package/skills/spec-gate/SKILL.md +312 -0
- package/skills/subscription-lifecycle/SKILL.md +206 -0
- package/skills/swiftui-pro/references/design.md +44 -0
- package/skills/symphony-enforcer/SKILL.md +92 -11
- package/skills/symphony-orchestrator/SKILL.md +9 -7
- package/skills/systematic-debugging/SKILL.md +32 -7
- package/skills/ua-campaign/SKILL.md +207 -0
- package/skills/verification-gate/SKILL.md +23 -2
- package/workflows/gitnexus.md +123 -0
- package/symphony/LICENSE +0 -21
- package/symphony/README.md +0 -178
- package/symphony/app/api/agents/route.js +0 -152
- package/symphony/app/api/events/route.js +0 -22
- package/symphony/app/api/knowledge/route.js +0 -253
- package/symphony/app/api/locks/route.js +0 -29
- package/symphony/app/api/notes/route.js +0 -125
- package/symphony/app/api/preflight/route.js +0 -23
- package/symphony/app/api/projects/route.js +0 -116
- package/symphony/app/api/roles/route.js +0 -134
- package/symphony/app/api/skills/route.js +0 -82
- package/symphony/app/api/status/route.js +0 -18
- package/symphony/app/api/tasks/route.js +0 -157
- package/symphony/app/api/workflows/route.js +0 -61
- package/symphony/app/api/workspaces/route.js +0 -15
- package/symphony/app/globals.css +0 -2605
- package/symphony/app/layout.js +0 -20
- package/symphony/app/page.js +0 -2122
- package/symphony/cli/index.js +0 -1060
- package/symphony/core/agent-manager.js +0 -357
- package/symphony/core/context-bus.js +0 -100
- package/symphony/core/db.js +0 -223
- package/symphony/core/file-lock-manager.js +0 -154
- package/symphony/core/merge-pipeline.js +0 -234
- package/symphony/core/orchestrator.js +0 -236
- package/symphony/core/task-manager.js +0 -335
- package/symphony/core/workspace-manager.js +0 -168
- package/symphony/jsconfig.json +0 -7
- package/symphony/lib/core.mjs +0 -1034
- package/symphony/mcp/index.js +0 -29
- package/symphony/mcp/server.js +0 -110
- package/symphony/mcp/tools/context.js +0 -80
- package/symphony/mcp/tools/locks.js +0 -99
- package/symphony/mcp/tools/status.js +0 -82
- package/symphony/mcp/tools/tasks.js +0 -216
- package/symphony/mcp/tools/workspace.js +0 -143
- package/symphony/next.config.mjs +0 -7
- package/symphony/package.json +0 -53
- package/symphony/scripts/postinstall.js +0 -49
- package/symphony/symphony.config.js +0 -41
package/symphony/app/page.js
DELETED
|
@@ -1,2122 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
-
|
|
5
|
-
const COLUMNS = [
|
|
6
|
-
{ key: "draft", label: "Nháp", icon: "📝", color: "#f59e0b" },
|
|
7
|
-
{ key: "ready", label: "Sẵn sàng", icon: "⬜", color: "#8888a0" },
|
|
8
|
-
{ key: "in_progress", label: "Đang làm", icon: "🔵", color: "#4f7cff", includeStatuses: ["claimed", "in_progress"] },
|
|
9
|
-
{ key: "review", label: "Duyệt", icon: "🟣", color: "#7c5cff" },
|
|
10
|
-
{ key: "done", label: "Xong", icon: "✅", color: "#34d399" },
|
|
11
|
-
];
|
|
12
|
-
|
|
13
|
-
const PRIORITY_COLORS = { 1: "#ef4444", 2: "#f59e0b", 3: "#22c55e" };
|
|
14
|
-
const PRIORITY_LABELS = { 1: "Cao", 2: "Trung bình", 3: "Thấp" };
|
|
15
|
-
|
|
16
|
-
export default function Dashboard() {
|
|
17
|
-
const [tasks, setTasks] = useState([]);
|
|
18
|
-
const [stats, setStats] = useState({});
|
|
19
|
-
const [status, setStatus] = useState(null);
|
|
20
|
-
const [events, setEvents] = useState([]);
|
|
21
|
-
const [agents, setAgents] = useState([]);
|
|
22
|
-
const [showModal, setShowModal] = useState(false);
|
|
23
|
-
const [selectedTask, setSelectedTask] = useState(null);
|
|
24
|
-
const [activeTab, setActiveTab] = useState("roles");
|
|
25
|
-
const [roles, setRoles] = useState(null);
|
|
26
|
-
const [showRoleModal, setShowRoleModal] = useState(false);
|
|
27
|
-
const [editingRole, setEditingRole] = useState(null);
|
|
28
|
-
const [notes, setNotes] = useState([]);
|
|
29
|
-
|
|
30
|
-
// Knowledge state
|
|
31
|
-
const [knowledgeItems, setKnowledgeItems] = useState([]);
|
|
32
|
-
const [showKnowledgeEditor, setShowKnowledgeEditor] = useState(null);
|
|
33
|
-
|
|
34
|
-
// Multi-project state
|
|
35
|
-
const [projects, setProjects] = useState([]);
|
|
36
|
-
const [activeProject, setActiveProject] = useState(null);
|
|
37
|
-
const [showProjectDropdown, setShowProjectDropdown] = useState(false);
|
|
38
|
-
const [projectStats, setProjectStats] = useState([]);
|
|
39
|
-
const [showAddProject, setShowAddProject] = useState(false);
|
|
40
|
-
|
|
41
|
-
// Responsive side panel
|
|
42
|
-
const [showSidePanel, setShowSidePanel] = useState(true);
|
|
43
|
-
|
|
44
|
-
// Drag state
|
|
45
|
-
const [draggedTask, setDraggedTask] = useState(null);
|
|
46
|
-
const [dragOverColumn, setDragOverColumn] = useState(null);
|
|
47
|
-
const [dragOverIndex, setDragOverIndex] = useState(null);
|
|
48
|
-
|
|
49
|
-
const fetchData = useCallback(async () => {
|
|
50
|
-
try {
|
|
51
|
-
const projectParam = activeProject ? `project=${activeProject.id}` : '';
|
|
52
|
-
const [tasksRes, statusRes, eventsRes, projectsRes, rolesRes] = await Promise.all([
|
|
53
|
-
fetch(`/api/tasks?${projectParam}`),
|
|
54
|
-
fetch(`/api/status?${projectParam}`),
|
|
55
|
-
fetch(`/api/events?limit=30&${projectParam}`),
|
|
56
|
-
|
|
57
|
-
fetch("/api/projects?stats=true"),
|
|
58
|
-
roles ? null : fetch("/api/roles"),
|
|
59
|
-
].filter(Boolean));
|
|
60
|
-
// Fetch notes (project-scoped when project selected)
|
|
61
|
-
const notesUrl = activeProject ? `/api/notes?projectId=${activeProject.id}` : '/api/notes';
|
|
62
|
-
fetch(notesUrl).then(r => r.json()).then(d => setNotes(d.notes || [])).catch(() => { });
|
|
63
|
-
// Fetch knowledge items
|
|
64
|
-
fetch('/api/knowledge').then(r => r.json()).then(d => setKnowledgeItems(d.items || [])).catch(() => { });
|
|
65
|
-
const tasksData = await tasksRes.json();
|
|
66
|
-
const statusData = await statusRes.json();
|
|
67
|
-
const eventsData = await eventsRes.json();
|
|
68
|
-
const projectsData = await projectsRes.json();
|
|
69
|
-
setTasks(tasksData.tasks || []);
|
|
70
|
-
setStats(tasksData.stats || {});
|
|
71
|
-
setStatus(statusData);
|
|
72
|
-
setEvents(eventsData.events || []);
|
|
73
|
-
setProjectStats(projectsData.projects || []);
|
|
74
|
-
// Set active project from server if not yet set
|
|
75
|
-
if (!activeProject && statusData.activeProject) {
|
|
76
|
-
setActiveProject(statusData.activeProject);
|
|
77
|
-
}
|
|
78
|
-
// Build projects list from stats
|
|
79
|
-
setProjects(projectsData.projects || []);
|
|
80
|
-
if (rolesRes) {
|
|
81
|
-
const rolesData = await rolesRes.json();
|
|
82
|
-
setRoles(rolesData);
|
|
83
|
-
}
|
|
84
|
-
} catch (err) {
|
|
85
|
-
console.error("Failed to fetch data:", err);
|
|
86
|
-
}
|
|
87
|
-
}, [roles, activeProject]);
|
|
88
|
-
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
fetchData();
|
|
91
|
-
// Pause auto-refresh when a modal is open (prevents confirm() dialog from being dismissed)
|
|
92
|
-
if (selectedTask || showModal || showRoleModal || editingRole || showAddProject) return;
|
|
93
|
-
const interval = setInterval(fetchData, 3000);
|
|
94
|
-
return () => clearInterval(interval);
|
|
95
|
-
}, [fetchData, selectedTask, showModal, showRoleModal, editingRole, showAddProject]);
|
|
96
|
-
|
|
97
|
-
// Responsive: auto-collapse side panel on small screens
|
|
98
|
-
useEffect(() => {
|
|
99
|
-
const mq = window.matchMedia("(max-width: 900px)");
|
|
100
|
-
const handleChange = (e) => setShowSidePanel(!e.matches);
|
|
101
|
-
handleChange(mq);
|
|
102
|
-
mq.addEventListener("change", handleChange);
|
|
103
|
-
return () => mq.removeEventListener("change", handleChange);
|
|
104
|
-
}, []);
|
|
105
|
-
|
|
106
|
-
const getColumnTasks = (column) => {
|
|
107
|
-
const statuses = column.includeStatuses || [column.key];
|
|
108
|
-
return tasks
|
|
109
|
-
.filter((t) => statuses.includes(t.status))
|
|
110
|
-
.sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0) || a.priority - b.priority);
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
// Resolve role from task phase using role manifest
|
|
114
|
-
const resolveRole = (task) => {
|
|
115
|
-
if (!roles?.roles || !task?.phase) return null;
|
|
116
|
-
const phase = task.phase.toLowerCase();
|
|
117
|
-
for (const [key, role] of Object.entries(roles.roles)) {
|
|
118
|
-
if (role.match?.phases?.some(p => p.toLowerCase() === phase)) return { key, ...role };
|
|
119
|
-
}
|
|
120
|
-
// Try keyword match from title
|
|
121
|
-
const title = (task.title || '').toLowerCase();
|
|
122
|
-
for (const [key, role] of Object.entries(roles.roles)) {
|
|
123
|
-
if (role.match?.keywords?.some(k => title.includes(k))) return { key, ...role };
|
|
124
|
-
}
|
|
125
|
-
return null;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const handleSwitchProject = async (project) => {
|
|
129
|
-
try {
|
|
130
|
-
await fetch("/api/projects", {
|
|
131
|
-
method: "PATCH",
|
|
132
|
-
headers: { "Content-Type": "application/json" },
|
|
133
|
-
body: JSON.stringify({ id: project.id, action: "activate" }),
|
|
134
|
-
});
|
|
135
|
-
setActiveProject(project);
|
|
136
|
-
setShowProjectDropdown(false);
|
|
137
|
-
fetchData();
|
|
138
|
-
} catch (err) {
|
|
139
|
-
console.error("Failed to switch project:", err);
|
|
140
|
-
}
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const handleShowAllProjects = () => {
|
|
144
|
-
setActiveProject(null);
|
|
145
|
-
setShowProjectDropdown(false);
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
const handleAddProject = async (formData) => {
|
|
149
|
-
try {
|
|
150
|
-
await fetch("/api/projects", {
|
|
151
|
-
method: "POST",
|
|
152
|
-
headers: { "Content-Type": "application/json" },
|
|
153
|
-
body: JSON.stringify(formData),
|
|
154
|
-
});
|
|
155
|
-
setShowAddProject(false);
|
|
156
|
-
fetchData();
|
|
157
|
-
} catch (err) {
|
|
158
|
-
console.error("Failed to add project:", err);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const handleCreateTask = async (formData) => {
|
|
163
|
-
try {
|
|
164
|
-
await fetch("/api/tasks", {
|
|
165
|
-
method: "POST",
|
|
166
|
-
headers: { "Content-Type": "application/json" },
|
|
167
|
-
body: JSON.stringify({ ...formData, isDraft: true, project_id: activeProject?.id }),
|
|
168
|
-
});
|
|
169
|
-
setShowModal(false);
|
|
170
|
-
fetchData();
|
|
171
|
-
} catch (err) {
|
|
172
|
-
console.error("Failed to create task:", err);
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
const handleUpdateTask = async (id, fields) => {
|
|
177
|
-
try {
|
|
178
|
-
await fetch("/api/tasks", {
|
|
179
|
-
method: "PATCH",
|
|
180
|
-
headers: { "Content-Type": "application/json" },
|
|
181
|
-
body: JSON.stringify({ id, ...fields }),
|
|
182
|
-
});
|
|
183
|
-
fetchData();
|
|
184
|
-
} catch (err) {
|
|
185
|
-
console.error("Failed to update task:", err);
|
|
186
|
-
}
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
const handleDeleteTask = async (id) => {
|
|
190
|
-
try {
|
|
191
|
-
await fetch("/api/tasks", {
|
|
192
|
-
method: "DELETE",
|
|
193
|
-
headers: { "Content-Type": "application/json" },
|
|
194
|
-
body: JSON.stringify({ id }),
|
|
195
|
-
});
|
|
196
|
-
setSelectedTask(null);
|
|
197
|
-
fetchData();
|
|
198
|
-
} catch (err) {
|
|
199
|
-
console.error("Failed to delete task:", err);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
const handleApproveTask = async (id) => {
|
|
204
|
-
await handleUpdateTask(id, { action: "approve" });
|
|
205
|
-
setSelectedTask(null);
|
|
206
|
-
};
|
|
207
|
-
|
|
208
|
-
const handleBulkApprove = async () => {
|
|
209
|
-
const draftIds = getColumnTasks(COLUMNS[0]).map((t) => t.id);
|
|
210
|
-
if (draftIds.length === 0) return;
|
|
211
|
-
try {
|
|
212
|
-
await fetch("/api/tasks", {
|
|
213
|
-
method: "PATCH",
|
|
214
|
-
headers: { "Content-Type": "application/json" },
|
|
215
|
-
body: JSON.stringify({ action: "bulk_approve", ids: draftIds }),
|
|
216
|
-
});
|
|
217
|
-
fetchData();
|
|
218
|
-
} catch (err) {
|
|
219
|
-
console.error("Failed to bulk approve:", err);
|
|
220
|
-
}
|
|
221
|
-
};
|
|
222
|
-
|
|
223
|
-
const handleReopenTask = async (id) => {
|
|
224
|
-
await handleUpdateTask(id, { action: "reopen" });
|
|
225
|
-
setSelectedTask(null);
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
const handleCyclePriority = async (e, task) => {
|
|
229
|
-
e.stopPropagation();
|
|
230
|
-
const next = task.priority >= 3 ? 1 : task.priority + 1;
|
|
231
|
-
await handleUpdateTask(task.id, { priority: next });
|
|
232
|
-
};
|
|
233
|
-
|
|
234
|
-
const handleReleaseLock = async (filePath) => {
|
|
235
|
-
try {
|
|
236
|
-
await fetch("/api/locks", {
|
|
237
|
-
method: "DELETE",
|
|
238
|
-
headers: { "Content-Type": "application/json" },
|
|
239
|
-
body: JSON.stringify({ file: filePath }),
|
|
240
|
-
});
|
|
241
|
-
fetchData();
|
|
242
|
-
} catch (err) {
|
|
243
|
-
console.error("Failed to release lock:", err);
|
|
244
|
-
}
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
// ─── Drag & Drop handlers ──────────────────────────────────────────
|
|
248
|
-
const handleDragStart = (e, task) => {
|
|
249
|
-
setDraggedTask(task);
|
|
250
|
-
e.dataTransfer.effectAllowed = "move";
|
|
251
|
-
e.target.style.opacity = "0.5";
|
|
252
|
-
};
|
|
253
|
-
|
|
254
|
-
const handleDragEnd = (e) => {
|
|
255
|
-
e.target.style.opacity = "1";
|
|
256
|
-
setDraggedTask(null);
|
|
257
|
-
setDragOverColumn(null);
|
|
258
|
-
setDragOverIndex(null);
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const handleDragOver = (e, columnKey, index) => {
|
|
262
|
-
e.preventDefault();
|
|
263
|
-
e.dataTransfer.dropEffect = "move";
|
|
264
|
-
setDragOverColumn(columnKey);
|
|
265
|
-
setDragOverIndex(index);
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const handleDrop = async (e, targetColumnKey) => {
|
|
269
|
-
e.preventDefault();
|
|
270
|
-
if (!draggedTask) return;
|
|
271
|
-
|
|
272
|
-
const sourceStatus = draggedTask.status;
|
|
273
|
-
const targetColumn = COLUMNS.find((c) => c.key === targetColumnKey);
|
|
274
|
-
const targetStatus = targetColumn.includeStatuses ? targetColumn.includeStatuses[0] : targetColumn.key;
|
|
275
|
-
|
|
276
|
-
// Determine which moves are allowed
|
|
277
|
-
const allowedMoves = {
|
|
278
|
-
draft: ["ready"],
|
|
279
|
-
ready: ["draft"],
|
|
280
|
-
done: ["ready"],
|
|
281
|
-
};
|
|
282
|
-
|
|
283
|
-
if (sourceStatus !== targetStatus) {
|
|
284
|
-
const allowed = allowedMoves[sourceStatus];
|
|
285
|
-
if (!allowed || !allowed.includes(targetStatus)) {
|
|
286
|
-
setDraggedTask(null);
|
|
287
|
-
setDragOverColumn(null);
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
if (targetStatus === "ready" && sourceStatus === "draft") {
|
|
292
|
-
await handleUpdateTask(draggedTask.id, { action: "approve" });
|
|
293
|
-
} else if (targetStatus === "draft" && sourceStatus === "ready") {
|
|
294
|
-
// Move back to draft (update status directly)
|
|
295
|
-
await fetch("/api/tasks", {
|
|
296
|
-
method: "PATCH",
|
|
297
|
-
headers: { "Content-Type": "application/json" },
|
|
298
|
-
body: JSON.stringify({ id: draggedTask.id, action: "reopen" }),
|
|
299
|
-
});
|
|
300
|
-
// Actually we want draft, not ready — let's set status manually
|
|
301
|
-
// For now reopen sets to ready, so we handle draft↔ready via approve only
|
|
302
|
-
} else if (targetStatus === "ready" && sourceStatus === "done") {
|
|
303
|
-
await handleUpdateTask(draggedTask.id, { action: "reopen" });
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// Reorder within same column
|
|
308
|
-
if (sourceStatus === targetStatus || (sourceStatus === "draft" && targetStatus === "draft")) {
|
|
309
|
-
const columnTasks = getColumnTasks(targetColumn);
|
|
310
|
-
const ids = columnTasks.map((t) => t.id).filter((id) => id !== draggedTask.id);
|
|
311
|
-
const insertAt = dragOverIndex !== null ? dragOverIndex : ids.length;
|
|
312
|
-
ids.splice(insertAt, 0, draggedTask.id);
|
|
313
|
-
|
|
314
|
-
await fetch("/api/tasks", {
|
|
315
|
-
method: "PATCH",
|
|
316
|
-
headers: { "Content-Type": "application/json" },
|
|
317
|
-
body: JSON.stringify({ action: "reorder", orderedIds: ids }),
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
setDraggedTask(null);
|
|
322
|
-
setDragOverColumn(null);
|
|
323
|
-
setDragOverIndex(null);
|
|
324
|
-
fetchData();
|
|
325
|
-
};
|
|
326
|
-
|
|
327
|
-
// ─── Role handlers ─────────────────────────────────────────────────
|
|
328
|
-
const handleCreateRole = async (formData) => {
|
|
329
|
-
try {
|
|
330
|
-
await fetch("/api/roles", {
|
|
331
|
-
method: "POST",
|
|
332
|
-
headers: { "Content-Type": "application/json" },
|
|
333
|
-
body: JSON.stringify(formData),
|
|
334
|
-
});
|
|
335
|
-
setShowRoleModal(false);
|
|
336
|
-
setRoles(null); // Force re-fetch
|
|
337
|
-
fetchData();
|
|
338
|
-
} catch (err) {
|
|
339
|
-
console.error("Failed to create role:", err);
|
|
340
|
-
}
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const handleUpdateRole = async (formData) => {
|
|
344
|
-
try {
|
|
345
|
-
await fetch("/api/roles", {
|
|
346
|
-
method: "PATCH",
|
|
347
|
-
headers: { "Content-Type": "application/json" },
|
|
348
|
-
body: JSON.stringify(formData),
|
|
349
|
-
});
|
|
350
|
-
setEditingRole(null);
|
|
351
|
-
setRoles(null); // Force re-fetch
|
|
352
|
-
fetchData();
|
|
353
|
-
} catch (err) {
|
|
354
|
-
console.error("Failed to update role:", err);
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
const handleRemoveRole = async (key) => {
|
|
359
|
-
if (!confirm(`Xoá vai trò "${key}"?`)) return;
|
|
360
|
-
try {
|
|
361
|
-
await fetch("/api/roles", {
|
|
362
|
-
method: "DELETE",
|
|
363
|
-
headers: { "Content-Type": "application/json" },
|
|
364
|
-
body: JSON.stringify({ key }),
|
|
365
|
-
});
|
|
366
|
-
setRoles(null); // Force re-fetch
|
|
367
|
-
fetchData();
|
|
368
|
-
} catch (err) {
|
|
369
|
-
console.error("Failed to remove role:", err);
|
|
370
|
-
}
|
|
371
|
-
};
|
|
372
|
-
|
|
373
|
-
const workingAgents = agents?.filter((a) => a.status === "working").length || 0;
|
|
374
|
-
const draftCount = getColumnTasks(COLUMNS[0]).length;
|
|
375
|
-
|
|
376
|
-
return (
|
|
377
|
-
<>
|
|
378
|
-
{/* Header */}
|
|
379
|
-
<header className="header">
|
|
380
|
-
<div className="header-left">
|
|
381
|
-
<span className="header-logo">🎼</span>
|
|
382
|
-
<span className="header-title">Symphony</span>
|
|
383
|
-
{/* Project Selector */}
|
|
384
|
-
<div className="project-selector" style={{ position: 'relative', marginLeft: 12 }}>
|
|
385
|
-
<button
|
|
386
|
-
className="project-selector-btn"
|
|
387
|
-
onClick={() => setShowProjectDropdown(!showProjectDropdown)}
|
|
388
|
-
>
|
|
389
|
-
<span className="project-icon">{activeProject?.icon || '📊'}</span>
|
|
390
|
-
<span className="project-name">{activeProject?.name || 'Tất cả dự án'}</span>
|
|
391
|
-
<span className="dropdown-arrow">▾</span>
|
|
392
|
-
</button>
|
|
393
|
-
{showProjectDropdown && (
|
|
394
|
-
<div className="project-dropdown">
|
|
395
|
-
<div
|
|
396
|
-
className={`project-option ${!activeProject ? 'active' : ''}`}
|
|
397
|
-
onClick={handleShowAllProjects}
|
|
398
|
-
>
|
|
399
|
-
<span>📊</span>
|
|
400
|
-
<span>Tất cả dự án</span>
|
|
401
|
-
</div>
|
|
402
|
-
{projects.length > 0 && <div className="project-divider" />}
|
|
403
|
-
{projects.map(p => (
|
|
404
|
-
<div
|
|
405
|
-
key={p.id}
|
|
406
|
-
className={`project-option ${activeProject?.id === p.id ? 'active' : ''}`}
|
|
407
|
-
onClick={() => handleSwitchProject(p)}
|
|
408
|
-
>
|
|
409
|
-
<span>{p.icon || '📁'}</span>
|
|
410
|
-
<span>{p.name}</span>
|
|
411
|
-
<span className="project-task-count">{p.total_tasks || 0}</span>
|
|
412
|
-
</div>
|
|
413
|
-
))}
|
|
414
|
-
<div className="project-divider" />
|
|
415
|
-
<div className="project-option add-project" onClick={() => { setShowAddProject(true); setShowProjectDropdown(false); }}>
|
|
416
|
-
<span>+</span>
|
|
417
|
-
<span>Đăng ký dự án</span>
|
|
418
|
-
</div>
|
|
419
|
-
</div>
|
|
420
|
-
)}
|
|
421
|
-
</div>
|
|
422
|
-
</div>
|
|
423
|
-
<div className="header-right">
|
|
424
|
-
<div className="status-badge">
|
|
425
|
-
<span className="status-dot" />
|
|
426
|
-
{workingAgents} agent đang hoạt động
|
|
427
|
-
</div>
|
|
428
|
-
<button className="create-btn" onClick={() => setShowModal(true)}>
|
|
429
|
-
+ Tạo Task
|
|
430
|
-
</button>
|
|
431
|
-
<button
|
|
432
|
-
className={`side-panel-toggle ${showSidePanel ? "active" : ""}`}
|
|
433
|
-
onClick={() => setShowSidePanel(!showSidePanel)}
|
|
434
|
-
title="Hiện/ẩn bảng phụ"
|
|
435
|
-
>
|
|
436
|
-
☰
|
|
437
|
-
</button>
|
|
438
|
-
</div>
|
|
439
|
-
</header>
|
|
440
|
-
|
|
441
|
-
{/* Stats Bar */}
|
|
442
|
-
<div className="stats-bar">
|
|
443
|
-
<div className="stat-card">
|
|
444
|
-
<span className="stat-icon">📋</span>
|
|
445
|
-
<div className="stat-info">
|
|
446
|
-
<span className="stat-value">{stats.total || 0}</span>
|
|
447
|
-
<span className="stat-label">Tổng</span>
|
|
448
|
-
</div>
|
|
449
|
-
</div>
|
|
450
|
-
<div className="stat-card">
|
|
451
|
-
<span className="stat-icon">📝</span>
|
|
452
|
-
<div className="stat-info">
|
|
453
|
-
<span className="stat-value">{stats.draft || 0}</span>
|
|
454
|
-
<span className="stat-label">Nháp</span>
|
|
455
|
-
</div>
|
|
456
|
-
</div>
|
|
457
|
-
<div className="stat-card">
|
|
458
|
-
<span className="stat-icon">⬜</span>
|
|
459
|
-
<div className="stat-info">
|
|
460
|
-
<span className="stat-value">{stats.ready || 0}</span>
|
|
461
|
-
<span className="stat-label">Sẵn sàng</span>
|
|
462
|
-
</div>
|
|
463
|
-
</div>
|
|
464
|
-
<div className="stat-card">
|
|
465
|
-
<span className="stat-icon">🔵</span>
|
|
466
|
-
<div className="stat-info">
|
|
467
|
-
<span className="stat-value">{(stats.claimed || 0) + (stats.in_progress || 0)}</span>
|
|
468
|
-
<span className="stat-label">Đang làm</span>
|
|
469
|
-
</div>
|
|
470
|
-
</div>
|
|
471
|
-
<div className="stat-card">
|
|
472
|
-
<span className="stat-icon">✅</span>
|
|
473
|
-
<div className="stat-info">
|
|
474
|
-
<span className="stat-value">{stats.done || 0}</span>
|
|
475
|
-
<span className="stat-label">Xong</span>
|
|
476
|
-
</div>
|
|
477
|
-
</div>
|
|
478
|
-
<div className="stat-card">
|
|
479
|
-
<span className="stat-icon">🔒</span>
|
|
480
|
-
<div className="stat-info">
|
|
481
|
-
<span className="stat-value">{status?.lockedFiles?.length || 0}</span>
|
|
482
|
-
<span className="stat-label">Khoá</span>
|
|
483
|
-
</div>
|
|
484
|
-
</div>
|
|
485
|
-
</div>
|
|
486
|
-
|
|
487
|
-
{/* All Projects Overview (when no project selected) */}
|
|
488
|
-
{!activeProject && projects.length > 0 && (
|
|
489
|
-
<div className="all-projects-bar">
|
|
490
|
-
{projects.map(p => {
|
|
491
|
-
const total = p.total_tasks || 0;
|
|
492
|
-
const done = p.done || 0;
|
|
493
|
-
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
494
|
-
return (
|
|
495
|
-
<div
|
|
496
|
-
key={p.id}
|
|
497
|
-
className="project-summary-card"
|
|
498
|
-
onClick={() => handleSwitchProject(p)}
|
|
499
|
-
style={{ borderLeftColor: p.color || '#8888a0' }}
|
|
500
|
-
>
|
|
501
|
-
<div className="project-summary-header">
|
|
502
|
-
<span>{p.icon || '📁'} {p.name}</span>
|
|
503
|
-
</div>
|
|
504
|
-
<div className="project-progress-row">
|
|
505
|
-
<div className="progress-bar-bg" style={{ flex: 1 }}>
|
|
506
|
-
<div className="progress-bar-fill" style={{ width: `${pct}%`, background: p.color || '#4f7cff' }} />
|
|
507
|
-
</div>
|
|
508
|
-
<span className="project-pct">{pct}%</span>
|
|
509
|
-
</div>
|
|
510
|
-
<div className="project-summary-stats">
|
|
511
|
-
<span>{p.ready || 0} ready</span>
|
|
512
|
-
<span>{p.active || 0} active</span>
|
|
513
|
-
<span>{done} done</span>
|
|
514
|
-
</div>
|
|
515
|
-
</div>
|
|
516
|
-
);
|
|
517
|
-
})}
|
|
518
|
-
</div>
|
|
519
|
-
)}
|
|
520
|
-
|
|
521
|
-
{/* Main Content */}
|
|
522
|
-
<div className="main">
|
|
523
|
-
{/* Kanban Board */}
|
|
524
|
-
<div className="kanban">
|
|
525
|
-
{COLUMNS.map((col) => {
|
|
526
|
-
const columnTasks = getColumnTasks(col);
|
|
527
|
-
const isDragTarget = dragOverColumn === col.key;
|
|
528
|
-
return (
|
|
529
|
-
<div
|
|
530
|
-
className={`kanban-column ${isDragTarget ? "drag-over" : ""}`}
|
|
531
|
-
key={col.key}
|
|
532
|
-
onDragOver={(e) => handleDragOver(e, col.key, columnTasks.length)}
|
|
533
|
-
onDrop={(e) => handleDrop(e, col.key)}
|
|
534
|
-
>
|
|
535
|
-
<div className="column-header">
|
|
536
|
-
<span className="column-title">
|
|
537
|
-
{col.icon} {col.label}
|
|
538
|
-
</span>
|
|
539
|
-
<div className="column-actions">
|
|
540
|
-
<span className="column-count">{columnTasks.length}</span>
|
|
541
|
-
{col.key === "draft" && draftCount > 0 && (
|
|
542
|
-
<button
|
|
543
|
-
className="approve-all-btn"
|
|
544
|
-
onClick={handleBulkApprove}
|
|
545
|
-
title="Duyệt tất cả task nháp"
|
|
546
|
-
>
|
|
547
|
-
✅ Duyệt tất cả
|
|
548
|
-
</button>
|
|
549
|
-
)}
|
|
550
|
-
</div>
|
|
551
|
-
</div>
|
|
552
|
-
<div className="column-body">
|
|
553
|
-
{columnTasks.length === 0 ? (
|
|
554
|
-
<div
|
|
555
|
-
className="empty-state"
|
|
556
|
-
onDragOver={(e) => handleDragOver(e, col.key, 0)}
|
|
557
|
-
>
|
|
558
|
-
<div className="empty-text">
|
|
559
|
-
{col.key === "draft"
|
|
560
|
-
? "Các task brainstorm sẽ xuất hiện ở đây"
|
|
561
|
-
: "Không có task"}
|
|
562
|
-
</div>
|
|
563
|
-
</div>
|
|
564
|
-
) : (
|
|
565
|
-
columnTasks.map((task, index) => (
|
|
566
|
-
<div
|
|
567
|
-
key={task.id}
|
|
568
|
-
onDragOver={(e) => {
|
|
569
|
-
e.preventDefault();
|
|
570
|
-
e.stopPropagation();
|
|
571
|
-
setDragOverColumn(col.key);
|
|
572
|
-
setDragOverIndex(index);
|
|
573
|
-
}}
|
|
574
|
-
>
|
|
575
|
-
{dragOverIndex === index && isDragTarget && draggedTask?.id !== task.id && (
|
|
576
|
-
<div className="drop-indicator" />
|
|
577
|
-
)}
|
|
578
|
-
<TaskCard
|
|
579
|
-
task={task}
|
|
580
|
-
role={resolveRole(task)}
|
|
581
|
-
agents={agents}
|
|
582
|
-
onClick={() => setSelectedTask(task)}
|
|
583
|
-
onCyclePriority={handleCyclePriority}
|
|
584
|
-
onApprove={col.key === "draft" ? () => handleApproveTask(task.id) : null}
|
|
585
|
-
onReopen={col.key === "done" ? () => handleReopenTask(task.id) : null}
|
|
586
|
-
onDragStart={handleDragStart}
|
|
587
|
-
onDragEnd={handleDragEnd}
|
|
588
|
-
isDraggable={["draft", "ready", "done"].includes(task.status)}
|
|
589
|
-
/>
|
|
590
|
-
</div>
|
|
591
|
-
))
|
|
592
|
-
)}
|
|
593
|
-
</div>
|
|
594
|
-
</div>
|
|
595
|
-
);
|
|
596
|
-
})}
|
|
597
|
-
</div>
|
|
598
|
-
|
|
599
|
-
{/* Side Panel Overlay (mobile) */}
|
|
600
|
-
<div
|
|
601
|
-
className={`side-panel-overlay ${showSidePanel ? "visible" : ""}`}
|
|
602
|
-
onClick={() => setShowSidePanel(false)}
|
|
603
|
-
/>
|
|
604
|
-
|
|
605
|
-
{/* Side Panel */}
|
|
606
|
-
<div className={`side-panel ${showSidePanel ? "visible" : ""}`}>
|
|
607
|
-
<div className="panel-tabs">
|
|
608
|
-
<div
|
|
609
|
-
className={`panel-tab ${activeTab === "roles" ? "active" : ""}`}
|
|
610
|
-
onClick={() => setActiveTab("roles")}
|
|
611
|
-
>
|
|
612
|
-
🎭 Vai trò
|
|
613
|
-
</div>
|
|
614
|
-
<div
|
|
615
|
-
className={`panel-tab ${activeTab === "locks" ? "active" : ""}`}
|
|
616
|
-
onClick={() => setActiveTab("locks")}
|
|
617
|
-
>
|
|
618
|
-
🔒 Khoá
|
|
619
|
-
</div>
|
|
620
|
-
<div
|
|
621
|
-
className={`panel-tab ${activeTab === "events" ? "active" : ""}`}
|
|
622
|
-
onClick={() => setActiveTab("events")}
|
|
623
|
-
>
|
|
624
|
-
📡 Sự kiện
|
|
625
|
-
</div>
|
|
626
|
-
<div
|
|
627
|
-
className={`panel-tab ${activeTab === "notes" ? "active" : ""}`}
|
|
628
|
-
onClick={() => setActiveTab("notes")}
|
|
629
|
-
>
|
|
630
|
-
📝 Ghi chú {notes.length > 0 && <span className="tab-badge">{notes.length}</span>}
|
|
631
|
-
</div>
|
|
632
|
-
<div
|
|
633
|
-
className={`panel-tab ${activeTab === "knowledge" ? "active" : ""}`}
|
|
634
|
-
onClick={() => setActiveTab("knowledge")}
|
|
635
|
-
>
|
|
636
|
-
📚 Knowledge {knowledgeItems.length > 0 && <span className="tab-badge">{knowledgeItems.length}</span>}
|
|
637
|
-
</div>
|
|
638
|
-
</div>
|
|
639
|
-
<div className="panel-content">
|
|
640
|
-
{activeTab === "roles" && (
|
|
641
|
-
<RolesPanel
|
|
642
|
-
roles={roles}
|
|
643
|
-
agents={agents}
|
|
644
|
-
onAddRole={() => setShowRoleModal(true)}
|
|
645
|
-
onEditRole={(role) => setEditingRole(role)}
|
|
646
|
-
onRemoveRole={handleRemoveRole}
|
|
647
|
-
/>
|
|
648
|
-
)}
|
|
649
|
-
{activeTab === "locks" && (
|
|
650
|
-
<LocksPanel locks={status?.lockedFiles || []} onRelease={handleReleaseLock} />
|
|
651
|
-
)}
|
|
652
|
-
{activeTab === "events" && (
|
|
653
|
-
<EventsPanel events={events} />
|
|
654
|
-
)}
|
|
655
|
-
{activeTab === "notes" && (
|
|
656
|
-
<NotesPanel notes={notes} onRefresh={fetchData} activeProject={activeProject} />
|
|
657
|
-
)}
|
|
658
|
-
{activeTab === "knowledge" && (
|
|
659
|
-
<KnowledgePanel items={knowledgeItems} onOpenEditor={(item) => setShowKnowledgeEditor(item)} onRefresh={() => fetch('/api/knowledge').then(r => r.json()).then(d => setKnowledgeItems(d.items || []))} />
|
|
660
|
-
)}
|
|
661
|
-
</div>
|
|
662
|
-
</div>
|
|
663
|
-
</div>
|
|
664
|
-
|
|
665
|
-
{/* Modals */}
|
|
666
|
-
{showModal && (
|
|
667
|
-
<CreateTaskModal
|
|
668
|
-
onClose={() => setShowModal(false)}
|
|
669
|
-
onSubmit={handleCreateTask}
|
|
670
|
-
agents={agents}
|
|
671
|
-
/>
|
|
672
|
-
)}
|
|
673
|
-
{selectedTask && (
|
|
674
|
-
<EditTaskModal
|
|
675
|
-
task={selectedTask}
|
|
676
|
-
agents={agents}
|
|
677
|
-
onClose={() => setSelectedTask(null)}
|
|
678
|
-
onSave={handleUpdateTask}
|
|
679
|
-
onDelete={handleDeleteTask}
|
|
680
|
-
onApprove={handleApproveTask}
|
|
681
|
-
onReopen={handleReopenTask}
|
|
682
|
-
onRefresh={fetchData}
|
|
683
|
-
/>
|
|
684
|
-
)}
|
|
685
|
-
{(showRoleModal || editingRole) && (
|
|
686
|
-
<RoleModal
|
|
687
|
-
role={editingRole}
|
|
688
|
-
onClose={() => { setShowRoleModal(false); setEditingRole(null); }}
|
|
689
|
-
onSubmit={editingRole ? handleUpdateRole : handleCreateRole}
|
|
690
|
-
/>
|
|
691
|
-
)}
|
|
692
|
-
{showKnowledgeEditor && (
|
|
693
|
-
<KnowledgeEditorModal
|
|
694
|
-
item={showKnowledgeEditor}
|
|
695
|
-
onClose={() => setShowKnowledgeEditor(null)}
|
|
696
|
-
onRefresh={() => fetch('/api/knowledge').then(r => r.json()).then(d => setKnowledgeItems(d.items || []))}
|
|
697
|
-
/>
|
|
698
|
-
)}
|
|
699
|
-
{showAddProject && (
|
|
700
|
-
<AddProjectModal
|
|
701
|
-
onClose={() => setShowAddProject(false)}
|
|
702
|
-
onSubmit={handleAddProject}
|
|
703
|
-
/>
|
|
704
|
-
)}
|
|
705
|
-
</>
|
|
706
|
-
);
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
// ─── Task Card Component ─────────────────────────────────────────────────────
|
|
710
|
-
|
|
711
|
-
function TaskCard({ task, role, agents, onClick, onCyclePriority, onApprove, onReopen, onDragStart, onDragEnd, isDraggable }) {
|
|
712
|
-
const [copied, setCopied] = useState(false);
|
|
713
|
-
|
|
714
|
-
const handleCopyId = (e) => {
|
|
715
|
-
e.stopPropagation();
|
|
716
|
-
navigator.clipboard.writeText(task.id).then(() => {
|
|
717
|
-
setCopied(true);
|
|
718
|
-
setTimeout(() => setCopied(false), 1500);
|
|
719
|
-
});
|
|
720
|
-
};
|
|
721
|
-
|
|
722
|
-
const agentName = task.agent_id && agents?.length > 0
|
|
723
|
-
? agents.find(a => a.id === task.agent_id)?.name || task.agent_id
|
|
724
|
-
: task.agent_id;
|
|
725
|
-
|
|
726
|
-
return (
|
|
727
|
-
<div
|
|
728
|
-
className={`task-card ${isDraggable ? "draggable" : ""}`}
|
|
729
|
-
onClick={onClick}
|
|
730
|
-
draggable={isDraggable}
|
|
731
|
-
onDragStart={(e) => isDraggable && onDragStart(e, task)}
|
|
732
|
-
onDragEnd={onDragEnd}
|
|
733
|
-
>
|
|
734
|
-
<div className="task-card-header">
|
|
735
|
-
<div className="task-card-title">{task.title}</div>
|
|
736
|
-
<div className="task-card-actions">
|
|
737
|
-
{onApprove && (
|
|
738
|
-
<button className="card-action-btn approve" onClick={(e) => { e.stopPropagation(); onApprove(); }} title="Duyệt">
|
|
739
|
-
✅
|
|
740
|
-
</button>
|
|
741
|
-
)}
|
|
742
|
-
{onReopen && (
|
|
743
|
-
<button className="card-action-btn reopen" onClick={(e) => { e.stopPropagation(); onReopen(); }} title="Mở lại">
|
|
744
|
-
🔄
|
|
745
|
-
</button>
|
|
746
|
-
)}
|
|
747
|
-
</div>
|
|
748
|
-
</div>
|
|
749
|
-
<div className="task-card-meta">
|
|
750
|
-
<div className="task-id-group">
|
|
751
|
-
<span className="task-card-id">{task.id}</span>
|
|
752
|
-
<button
|
|
753
|
-
className="copy-id-btn"
|
|
754
|
-
onClick={handleCopyId}
|
|
755
|
-
title="Copy Task ID"
|
|
756
|
-
>
|
|
757
|
-
{copied ? "✓" : "📋"}
|
|
758
|
-
</button>
|
|
759
|
-
</div>
|
|
760
|
-
<button
|
|
761
|
-
className="task-card-priority-btn"
|
|
762
|
-
style={{ background: PRIORITY_COLORS[task.priority], color: "#fff" }}
|
|
763
|
-
onClick={(e) => onCyclePriority(e, task)}
|
|
764
|
-
title={`Ưu tiên: ${PRIORITY_LABELS[task.priority]} — Nhấn để đổi`}
|
|
765
|
-
>
|
|
766
|
-
P{task.priority}
|
|
767
|
-
</button>
|
|
768
|
-
</div>
|
|
769
|
-
{(task.phase || role) && (
|
|
770
|
-
<div className="task-card-phase">
|
|
771
|
-
{role && <span className="role-badge" style={{ borderColor: role.color }}>{role.icon} {role.name}</span>}
|
|
772
|
-
{task.phase && !role && <span className="phase-tag">{task.phase}</span>}
|
|
773
|
-
</div>
|
|
774
|
-
)}
|
|
775
|
-
{task.agent_id && (
|
|
776
|
-
<div className="task-card-agent">
|
|
777
|
-
🤖 {agentName}
|
|
778
|
-
</div>
|
|
779
|
-
)}
|
|
780
|
-
{task.progress > 0 && task.status !== "done" && (
|
|
781
|
-
<div className="task-card-progress">
|
|
782
|
-
<div className="progress-bar-bg">
|
|
783
|
-
<div
|
|
784
|
-
className="progress-bar-fill"
|
|
785
|
-
style={{ width: `${task.progress}%` }}
|
|
786
|
-
/>
|
|
787
|
-
</div>
|
|
788
|
-
<div className="progress-label">{task.progress}%</div>
|
|
789
|
-
</div>
|
|
790
|
-
)}
|
|
791
|
-
</div>
|
|
792
|
-
);
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
// ─── Roles Panel (Enhanced with CRUD) ────────────────────────────────────────
|
|
796
|
-
|
|
797
|
-
function RolesPanel({ roles, agents, onAddRole, onEditRole, onRemoveRole }) {
|
|
798
|
-
if (!roles?.roles) {
|
|
799
|
-
return (
|
|
800
|
-
<div className="empty-state">
|
|
801
|
-
<div className="empty-icon">🎭</div>
|
|
802
|
-
<div className="empty-text">Đang tải vai trò...</div>
|
|
803
|
-
</div>
|
|
804
|
-
);
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
const roleEntries = Object.entries(roles.roles);
|
|
808
|
-
|
|
809
|
-
return (
|
|
810
|
-
<>
|
|
811
|
-
<div style={{ marginBottom: 12, fontSize: 12, color: "var(--text-muted)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
812
|
-
<span>{roleEntries.length} vai trò</span>
|
|
813
|
-
<button className="add-agent-btn" onClick={onAddRole}>+ Thêm</button>
|
|
814
|
-
</div>
|
|
815
|
-
<div className="roles-list">
|
|
816
|
-
{roleEntries.map(([key, role]) => (
|
|
817
|
-
<div className="role-card" key={key}>
|
|
818
|
-
<div className="role-header">
|
|
819
|
-
<span className="role-icon" style={{ background: role.color + '22', color: role.color }}>
|
|
820
|
-
{role.icon}
|
|
821
|
-
</span>
|
|
822
|
-
<div className="role-info">
|
|
823
|
-
<div className="role-name">{role.name}</div>
|
|
824
|
-
<div className="role-meta">
|
|
825
|
-
{role.skills?.length > 0 && <span>{role.skills.length} kỹ năng</span>}
|
|
826
|
-
<span>{role.workflows?.length || 0} quy trình</span>
|
|
827
|
-
</div>
|
|
828
|
-
</div>
|
|
829
|
-
<div className="role-actions">
|
|
830
|
-
<button className="agent-edit-btn" onClick={() => onEditRole({ key, ...role })} title="Sửa">✏️</button>
|
|
831
|
-
<button className="agent-remove-btn" onClick={() => onRemoveRole(key)} title="Xoá">✕</button>
|
|
832
|
-
</div>
|
|
833
|
-
</div>
|
|
834
|
-
{role.match?.phases?.length > 0 && (
|
|
835
|
-
<div className="role-phases">
|
|
836
|
-
{role.match.phases.map(p => (
|
|
837
|
-
<span className="phase-tag" key={p}>{p}</span>
|
|
838
|
-
))}
|
|
839
|
-
</div>
|
|
840
|
-
)}
|
|
841
|
-
{role.skills?.length > 0 && (
|
|
842
|
-
<div className="role-skills">
|
|
843
|
-
{role.skills.map(s => (
|
|
844
|
-
<span className="skill-tag" key={s}>📚 {s}</span>
|
|
845
|
-
))}
|
|
846
|
-
</div>
|
|
847
|
-
)}
|
|
848
|
-
{role.match?.keywords?.length > 0 && (
|
|
849
|
-
<div className="role-keywords">
|
|
850
|
-
{role.match.keywords.map(k => (
|
|
851
|
-
<span className="keyword-tag" key={k}>{k}</span>
|
|
852
|
-
))}
|
|
853
|
-
</div>
|
|
854
|
-
)}
|
|
855
|
-
</div>
|
|
856
|
-
))}
|
|
857
|
-
{roles.shared && (
|
|
858
|
-
<div className="role-card shared-card">
|
|
859
|
-
<div className="role-header">
|
|
860
|
-
<span className="role-icon" style={{ background: '#8888a022', color: '#8888a0' }}>⚙️</span>
|
|
861
|
-
<div className="role-info">
|
|
862
|
-
<div className="role-name">Chung (Tất cả Agent)</div>
|
|
863
|
-
<div className="role-meta">
|
|
864
|
-
<span>{roles.shared.auto_skills.length} kỹ năng tự động</span>
|
|
865
|
-
<span>{roles.shared.common_workflows.length} quy trình</span>
|
|
866
|
-
</div>
|
|
867
|
-
</div>
|
|
868
|
-
</div>
|
|
869
|
-
<div className="role-skills">
|
|
870
|
-
{roles.shared.auto_skills.map(s => (
|
|
871
|
-
<span className="skill-tag" key={s}>⚡ {s}</span>
|
|
872
|
-
))}
|
|
873
|
-
</div>
|
|
874
|
-
</div>
|
|
875
|
-
)}
|
|
876
|
-
</div>
|
|
877
|
-
</>
|
|
878
|
-
);
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
// ─── Locks Panel ─────────────────────────────────────────────────────────────
|
|
882
|
-
|
|
883
|
-
function LocksPanel({ locks, onRelease }) {
|
|
884
|
-
if (!locks || locks.length === 0) {
|
|
885
|
-
return (
|
|
886
|
-
<div className="empty-state">
|
|
887
|
-
<div className="empty-icon">🟢</div>
|
|
888
|
-
<div className="empty-text">Không có file bị khoá</div>
|
|
889
|
-
</div>
|
|
890
|
-
);
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
return locks.map((lock, i) => (
|
|
894
|
-
<div className="lock-item" key={i}>
|
|
895
|
-
<span>🔒</span>
|
|
896
|
-
<span className="lock-file" title={lock.file}>
|
|
897
|
-
{lock.file}
|
|
898
|
-
</span>
|
|
899
|
-
<span className="lock-agent">{lock.agent}</span>
|
|
900
|
-
<button
|
|
901
|
-
className="lock-release-btn"
|
|
902
|
-
onClick={() => onRelease(lock.file)}
|
|
903
|
-
title="Giải phóng khoá"
|
|
904
|
-
>
|
|
905
|
-
✕
|
|
906
|
-
</button>
|
|
907
|
-
</div>
|
|
908
|
-
));
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
// ─── Events Panel ────────────────────────────────────────────────────────────
|
|
912
|
-
|
|
913
|
-
function EventsPanel({ events }) {
|
|
914
|
-
if (!events || events.length === 0) {
|
|
915
|
-
return (
|
|
916
|
-
<div className="empty-state">
|
|
917
|
-
<div className="empty-icon">📡</div>
|
|
918
|
-
<div className="empty-text">
|
|
919
|
-
Sự kiện sẽ xuất hiện khi agent phát thay đổi.
|
|
920
|
-
</div>
|
|
921
|
-
</div>
|
|
922
|
-
);
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
const typeIcons = {
|
|
926
|
-
file_modified: "📝",
|
|
927
|
-
api_changed: "🔌",
|
|
928
|
-
schema_updated: "🗄️",
|
|
929
|
-
dependency_added: "📦",
|
|
930
|
-
component_created: "🧩",
|
|
931
|
-
task_completed: "✅",
|
|
932
|
-
custom: "💬",
|
|
933
|
-
};
|
|
934
|
-
|
|
935
|
-
const impactColors = {
|
|
936
|
-
low: "var(--text-muted)",
|
|
937
|
-
medium: "var(--accent-yellow)",
|
|
938
|
-
high: "var(--accent-red)",
|
|
939
|
-
};
|
|
940
|
-
|
|
941
|
-
return events.map((event) => {
|
|
942
|
-
const icon = typeIcons[event.event_type] || "📡";
|
|
943
|
-
const impact = event.payload?.impact || "low";
|
|
944
|
-
const timeAgo = formatTimeAgo(event.created_at);
|
|
945
|
-
|
|
946
|
-
return (
|
|
947
|
-
<div className="event-item" key={event.id}>
|
|
948
|
-
<div className="event-header">
|
|
949
|
-
<span className="event-icon">{icon}</span>
|
|
950
|
-
<span className="event-type">{event.event_type.replace(/_/g, " ")}</span>
|
|
951
|
-
<span className="event-time">{timeAgo}</span>
|
|
952
|
-
</div>
|
|
953
|
-
<div className="event-desc">{event.payload?.description || "Không có mô tả"}</div>
|
|
954
|
-
<div className="event-footer">
|
|
955
|
-
<span className="event-agent">🤖 {event.agent_id}</span>
|
|
956
|
-
<span className="event-impact" style={{ color: impactColors[impact] }}>
|
|
957
|
-
{impact}
|
|
958
|
-
</span>
|
|
959
|
-
</div>
|
|
960
|
-
{event.payload?.files?.length > 0 && (
|
|
961
|
-
<div className="event-files">
|
|
962
|
-
{event.payload.files.map((f, i) => (
|
|
963
|
-
<span className="event-file-tag" key={i}>{f}</span>
|
|
964
|
-
))}
|
|
965
|
-
</div>
|
|
966
|
-
)}
|
|
967
|
-
</div>
|
|
968
|
-
);
|
|
969
|
-
});
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// (RolesPanel moved above, combined with former AgentsPanel)
|
|
973
|
-
|
|
974
|
-
// ─── Notes Panel ─────────────────────────────────────────────────────────────
|
|
975
|
-
|
|
976
|
-
const NOTE_TYPE_ICONS = {
|
|
977
|
-
brief: "💡", plan: "📋", spec: "📐", conversation: "💬", decision: "⚖️", reference: "📎",
|
|
978
|
-
};
|
|
979
|
-
|
|
980
|
-
function NotesPanel({ notes, onRefresh, activeProject }) {
|
|
981
|
-
const [deleting, setDeleting] = useState(null);
|
|
982
|
-
|
|
983
|
-
const handleDelete = async (id) => {
|
|
984
|
-
try {
|
|
985
|
-
await fetch("/api/notes", {
|
|
986
|
-
method: "DELETE",
|
|
987
|
-
headers: { "Content-Type": "application/json" },
|
|
988
|
-
body: JSON.stringify({ id }),
|
|
989
|
-
});
|
|
990
|
-
onRefresh();
|
|
991
|
-
} catch (err) {
|
|
992
|
-
console.error("Failed to delete note:", err);
|
|
993
|
-
}
|
|
994
|
-
setDeleting(null);
|
|
995
|
-
};
|
|
996
|
-
|
|
997
|
-
if (!notes || notes.length === 0) {
|
|
998
|
-
return (
|
|
999
|
-
<div className="empty-state">
|
|
1000
|
-
<div className="empty-icon">📝</div>
|
|
1001
|
-
<div className="empty-text">
|
|
1002
|
-
Chưa có ghi chú.
|
|
1003
|
-
<br />
|
|
1004
|
-
Ghi chú được tạo từ <code>/brainstorm</code> và <code>/plan</code>.
|
|
1005
|
-
</div>
|
|
1006
|
-
</div>
|
|
1007
|
-
);
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
// Group by type
|
|
1011
|
-
const grouped = {};
|
|
1012
|
-
for (const n of notes) {
|
|
1013
|
-
if (!grouped[n.type]) grouped[n.type] = [];
|
|
1014
|
-
grouped[n.type].push(n);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
return (
|
|
1018
|
-
<div className="notes-panel">
|
|
1019
|
-
{Object.entries(grouped).map(([type, items]) => (
|
|
1020
|
-
<div key={type} className="notes-group">
|
|
1021
|
-
<div className="notes-group-header">
|
|
1022
|
-
<span>{NOTE_TYPE_ICONS[type] || "📄"} {type.charAt(0).toUpperCase() + type.slice(1)}</span>
|
|
1023
|
-
<span className="notes-group-count">{items.length}</span>
|
|
1024
|
-
</div>
|
|
1025
|
-
{items.map(note => (
|
|
1026
|
-
<div className="note-card" key={note.id}>
|
|
1027
|
-
<div className="note-card-header">
|
|
1028
|
-
<span className="note-title">{note.title}</span>
|
|
1029
|
-
{deleting === note.id ? (
|
|
1030
|
-
<div style={{ display: 'flex', gap: 4 }}>
|
|
1031
|
-
<button className="note-action-btn danger" onClick={() => handleDelete(note.id)}>✕</button>
|
|
1032
|
-
<button className="note-action-btn" onClick={() => setDeleting(null)}>↩</button>
|
|
1033
|
-
</div>
|
|
1034
|
-
) : (
|
|
1035
|
-
<button className="note-action-btn" onClick={() => setDeleting(note.id)} title="Delete">🗑️</button>
|
|
1036
|
-
)}
|
|
1037
|
-
</div>
|
|
1038
|
-
{note.file_path && (
|
|
1039
|
-
<div className="note-meta">
|
|
1040
|
-
<span className="note-meta-label">📁</span>
|
|
1041
|
-
<span className="note-meta-value" title={note.file_path}>
|
|
1042
|
-
{note.file_path.split('/').slice(-2).join('/')}
|
|
1043
|
-
</span>
|
|
1044
|
-
</div>
|
|
1045
|
-
)}
|
|
1046
|
-
{note.conversation_id && (
|
|
1047
|
-
<div className="note-meta">
|
|
1048
|
-
<span className="note-meta-label">💬</span>
|
|
1049
|
-
<code className="note-conv-id">{note.conversation_id.slice(0, 12)}…</code>
|
|
1050
|
-
</div>
|
|
1051
|
-
)}
|
|
1052
|
-
{note.task_id && (
|
|
1053
|
-
<div className="note-meta">
|
|
1054
|
-
<span className="note-meta-label">🎯</span>
|
|
1055
|
-
<code className="note-conv-id">{note.task_id}</code>
|
|
1056
|
-
</div>
|
|
1057
|
-
)}
|
|
1058
|
-
<div className="note-time">{formatTimeAgo(note.created_at)}</div>
|
|
1059
|
-
</div>
|
|
1060
|
-
))}
|
|
1061
|
-
</div>
|
|
1062
|
-
))}
|
|
1063
|
-
</div>
|
|
1064
|
-
);
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function formatTimeAgo(dateStr) {
|
|
1068
|
-
if (!dateStr) return "";
|
|
1069
|
-
const diff = Date.now() - new Date(dateStr).getTime();
|
|
1070
|
-
const sec = Math.floor(diff / 1000);
|
|
1071
|
-
if (sec < 60) return `${sec} giây trước`;
|
|
1072
|
-
const min = Math.floor(sec / 60);
|
|
1073
|
-
if (min < 60) return `${min} phút trước`;
|
|
1074
|
-
const hr = Math.floor(min / 60);
|
|
1075
|
-
if (hr < 24) return `${hr} giờ trước`;
|
|
1076
|
-
return `${Math.floor(hr / 24)} ngày trước`;
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// ─── Edit Task Modal (replaces read-only detail modal) ───────────────────────
|
|
1080
|
-
|
|
1081
|
-
function EditTaskModal({ task, agents, onClose, onSave, onDelete, onApprove, onReopen, onRefresh }) {
|
|
1082
|
-
const [form, setForm] = useState({
|
|
1083
|
-
title: task.title,
|
|
1084
|
-
description: task.description || "",
|
|
1085
|
-
priority: task.priority,
|
|
1086
|
-
acceptance: task.acceptance || "",
|
|
1087
|
-
phase: task.phase || "",
|
|
1088
|
-
agent_id: task.agent_id || "",
|
|
1089
|
-
});
|
|
1090
|
-
const [saving, setSaving] = useState(false);
|
|
1091
|
-
const [copied, setCopied] = useState(false);
|
|
1092
|
-
const [confirmDelete, setConfirmDelete] = useState(false);
|
|
1093
|
-
const [taskNotes, setTaskNotes] = useState([]);
|
|
1094
|
-
const [convInput, setConvInput] = useState("");
|
|
1095
|
-
const isEditable = ["draft", "ready"].includes(task.status);
|
|
1096
|
-
|
|
1097
|
-
const refreshNotes = useCallback(() => {
|
|
1098
|
-
const params = new URLSearchParams();
|
|
1099
|
-
if (task.id) params.set('taskId', task.id);
|
|
1100
|
-
fetch(`/api/notes?${params}`)
|
|
1101
|
-
.then(r => r.json())
|
|
1102
|
-
.then(d => {
|
|
1103
|
-
const notes = d.notes || [];
|
|
1104
|
-
if (task.conversation_id) {
|
|
1105
|
-
fetch(`/api/notes?conversationId=${task.conversation_id}`)
|
|
1106
|
-
.then(r => r.json())
|
|
1107
|
-
.then(d2 => {
|
|
1108
|
-
const convNotes = d2.notes || [];
|
|
1109
|
-
const allNotes = [...notes];
|
|
1110
|
-
for (const n of convNotes) {
|
|
1111
|
-
if (!allNotes.find(existing => existing.id === n.id)) allNotes.push(n);
|
|
1112
|
-
}
|
|
1113
|
-
setTaskNotes(allNotes);
|
|
1114
|
-
})
|
|
1115
|
-
.catch(() => setTaskNotes(notes));
|
|
1116
|
-
} else {
|
|
1117
|
-
setTaskNotes(notes);
|
|
1118
|
-
}
|
|
1119
|
-
})
|
|
1120
|
-
.catch(() => { });
|
|
1121
|
-
}, [task.id, task.conversation_id]);
|
|
1122
|
-
|
|
1123
|
-
useEffect(() => { refreshNotes(); }, [refreshNotes]);
|
|
1124
|
-
|
|
1125
|
-
const handleAddConversation = async () => {
|
|
1126
|
-
const id = convInput.trim();
|
|
1127
|
-
if (!id) return;
|
|
1128
|
-
try {
|
|
1129
|
-
await fetch('/api/notes', {
|
|
1130
|
-
method: 'POST',
|
|
1131
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1132
|
-
body: JSON.stringify({
|
|
1133
|
-
task_id: task.id,
|
|
1134
|
-
project_id: task.project_id,
|
|
1135
|
-
type: 'conversation',
|
|
1136
|
-
title: `Conversation ${id.slice(0, 8)}…`,
|
|
1137
|
-
conversation_id: id,
|
|
1138
|
-
}),
|
|
1139
|
-
});
|
|
1140
|
-
setConvInput('');
|
|
1141
|
-
refreshNotes();
|
|
1142
|
-
onRefresh();
|
|
1143
|
-
} catch (err) {
|
|
1144
|
-
console.error('Failed to add conversation:', err);
|
|
1145
|
-
}
|
|
1146
|
-
};
|
|
1147
|
-
|
|
1148
|
-
const handleRemoveNote = async (noteId) => {
|
|
1149
|
-
try {
|
|
1150
|
-
await fetch('/api/notes', {
|
|
1151
|
-
method: 'DELETE',
|
|
1152
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1153
|
-
body: JSON.stringify({ id: noteId }),
|
|
1154
|
-
});
|
|
1155
|
-
refreshNotes();
|
|
1156
|
-
onRefresh();
|
|
1157
|
-
} catch (err) {
|
|
1158
|
-
console.error('Failed to remove note:', err);
|
|
1159
|
-
}
|
|
1160
|
-
};
|
|
1161
|
-
|
|
1162
|
-
const handleSave = async () => {
|
|
1163
|
-
setSaving(true);
|
|
1164
|
-
await onSave(task.id, { ...form, agent_id: form.agent_id || null });
|
|
1165
|
-
setSaving(false);
|
|
1166
|
-
onClose();
|
|
1167
|
-
};
|
|
1168
|
-
|
|
1169
|
-
const handleCopyId = () => {
|
|
1170
|
-
navigator.clipboard.writeText(task.id).then(() => {
|
|
1171
|
-
setCopied(true);
|
|
1172
|
-
setTimeout(() => setCopied(false), 1500);
|
|
1173
|
-
});
|
|
1174
|
-
};
|
|
1175
|
-
|
|
1176
|
-
const statusLabel = {
|
|
1177
|
-
draft: "📝 Nháp",
|
|
1178
|
-
ready: "⬜ Sẵn sàng",
|
|
1179
|
-
claimed: "🟡 Đã nhận",
|
|
1180
|
-
in_progress: "🔵 Đang làm",
|
|
1181
|
-
review: "🟣 Duyệt",
|
|
1182
|
-
done: "✅ Xong",
|
|
1183
|
-
abandoned: "⚫ Huỷ bỏ",
|
|
1184
|
-
};
|
|
1185
|
-
|
|
1186
|
-
return (
|
|
1187
|
-
<div className="modal-overlay" onClick={onClose}>
|
|
1188
|
-
<div className="modal task-detail-modal" onClick={(e) => e.stopPropagation()}>
|
|
1189
|
-
<div className="detail-header">
|
|
1190
|
-
<div className="detail-status-badge" style={{ color: COLUMNS.find(c => c.key === task.status || c.includeStatuses?.includes(task.status))?.color || "#8888a0" }}>
|
|
1191
|
-
{statusLabel[task.status] || task.status}
|
|
1192
|
-
</div>
|
|
1193
|
-
<div className="task-id-group">
|
|
1194
|
-
<span className="task-card-id" style={{ fontSize: 11 }}>{task.id}</span>
|
|
1195
|
-
<button className="copy-id-btn" onClick={handleCopyId} title="Sao chép ID">
|
|
1196
|
-
{copied ? "✓" : "📋"}
|
|
1197
|
-
</button>
|
|
1198
|
-
</div>
|
|
1199
|
-
</div>
|
|
1200
|
-
|
|
1201
|
-
<div className="edit-form">
|
|
1202
|
-
<div className="form-group">
|
|
1203
|
-
<label className="form-label">Tiêu đề</label>
|
|
1204
|
-
{isEditable ? (
|
|
1205
|
-
<input
|
|
1206
|
-
className="form-input"
|
|
1207
|
-
value={form.title}
|
|
1208
|
-
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
|
1209
|
-
/>
|
|
1210
|
-
) : (
|
|
1211
|
-
<div className="form-value">{task.title}</div>
|
|
1212
|
-
)}
|
|
1213
|
-
</div>
|
|
1214
|
-
|
|
1215
|
-
<div className="form-group">
|
|
1216
|
-
<label className="form-label">Mô tả</label>
|
|
1217
|
-
{isEditable ? (
|
|
1218
|
-
<textarea
|
|
1219
|
-
className="form-textarea"
|
|
1220
|
-
value={form.description}
|
|
1221
|
-
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
1222
|
-
placeholder="Cần làm gì..."
|
|
1223
|
-
/>
|
|
1224
|
-
) : (
|
|
1225
|
-
<div className="form-value">{task.description || "—"}</div>
|
|
1226
|
-
)}
|
|
1227
|
-
</div>
|
|
1228
|
-
|
|
1229
|
-
<div className="form-row">
|
|
1230
|
-
<div className="form-group half">
|
|
1231
|
-
<label className="form-label">Ʈu tiên</label>
|
|
1232
|
-
{isEditable ? (
|
|
1233
|
-
<select
|
|
1234
|
-
className="form-select"
|
|
1235
|
-
value={form.priority}
|
|
1236
|
-
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) })}
|
|
1237
|
-
>
|
|
1238
|
-
<option value={1}>P1 — Cao</option>
|
|
1239
|
-
<option value={2}>P2 — Trung bình</option>
|
|
1240
|
-
<option value={3}>P3 — Thấp</option>
|
|
1241
|
-
</select>
|
|
1242
|
-
) : (
|
|
1243
|
-
<div className="form-value">
|
|
1244
|
-
<span style={{ color: PRIORITY_COLORS[task.priority] }}>P{task.priority} — {PRIORITY_LABELS[task.priority]}</span>
|
|
1245
|
-
</div>
|
|
1246
|
-
)}
|
|
1247
|
-
</div>
|
|
1248
|
-
<div className="form-group half">
|
|
1249
|
-
<label className="form-label">Giai đoạn / Nhóm</label>
|
|
1250
|
-
{isEditable ? (
|
|
1251
|
-
<input
|
|
1252
|
-
className="form-input"
|
|
1253
|
-
value={form.phase}
|
|
1254
|
-
onChange={(e) => setForm({ ...form, phase: e.target.value })}
|
|
1255
|
-
placeholder="iOS, Android, Backend..."
|
|
1256
|
-
/>
|
|
1257
|
-
) : (
|
|
1258
|
-
<div className="form-value">{task.phase || "—"}</div>
|
|
1259
|
-
)}
|
|
1260
|
-
</div>
|
|
1261
|
-
</div>
|
|
1262
|
-
|
|
1263
|
-
<div className="form-group">
|
|
1264
|
-
<label className="form-label">Agent phụ trách</label>
|
|
1265
|
-
{isEditable ? (
|
|
1266
|
-
<select
|
|
1267
|
-
className="form-select"
|
|
1268
|
-
value={form.agent_id}
|
|
1269
|
-
onChange={(e) => setForm({ ...form, agent_id: e.target.value })}
|
|
1270
|
-
>
|
|
1271
|
-
<option value="">— Chưa gán —</option>
|
|
1272
|
-
{agents?.map(a => (
|
|
1273
|
-
<option key={a.id} value={a.id}>
|
|
1274
|
-
🤖 {a.name || a.id}{a.specialties?.length > 0 ? ` (${a.specialties.join(', ')})` : ''}
|
|
1275
|
-
</option>
|
|
1276
|
-
))}
|
|
1277
|
-
</select>
|
|
1278
|
-
) : (
|
|
1279
|
-
<div className="form-value">
|
|
1280
|
-
{task.agent_id ? `🤖 ${agents?.find(a => a.id === task.agent_id)?.name || task.agent_id}` : '—'}
|
|
1281
|
-
</div>
|
|
1282
|
-
)}
|
|
1283
|
-
</div>
|
|
1284
|
-
|
|
1285
|
-
<div className="form-group">
|
|
1286
|
-
<label className="form-label">Tiêu chí nghiệm thu</label>
|
|
1287
|
-
{isEditable ? (
|
|
1288
|
-
<textarea
|
|
1289
|
-
className="form-textarea"
|
|
1290
|
-
value={form.acceptance}
|
|
1291
|
-
onChange={(e) => setForm({ ...form, acceptance: e.target.value })}
|
|
1292
|
-
placeholder="Cách xác nhận task hoàn thành..."
|
|
1293
|
-
/>
|
|
1294
|
-
) : (
|
|
1295
|
-
<div className="form-value">{task.acceptance || "—"}</div>
|
|
1296
|
-
)}
|
|
1297
|
-
</div>
|
|
1298
|
-
|
|
1299
|
-
{task.progress > 0 && (
|
|
1300
|
-
<div className="form-group">
|
|
1301
|
-
<label className="form-label">Tiến độ</label>
|
|
1302
|
-
<div className="progress-bar-bg" style={{ height: 8 }}>
|
|
1303
|
-
<div className="progress-bar-fill" style={{ width: `${task.progress}%` }} />
|
|
1304
|
-
</div>
|
|
1305
|
-
<div className="form-value" style={{ fontSize: 12, marginTop: 4 }}>{task.progress}%</div>
|
|
1306
|
-
</div>
|
|
1307
|
-
)}
|
|
1308
|
-
|
|
1309
|
-
{task.summary && (
|
|
1310
|
-
<div className="form-group">
|
|
1311
|
-
<label className="form-label">Tóm tắt</label>
|
|
1312
|
-
<div className="form-value">{task.summary}</div>
|
|
1313
|
-
</div>
|
|
1314
|
-
)}
|
|
1315
|
-
|
|
1316
|
-
{/* Related Conversations & Notes */}
|
|
1317
|
-
<div className="form-group">
|
|
1318
|
-
<label className="form-label">💬 Hội thoại liên quan</label>
|
|
1319
|
-
<div className="task-conversations">
|
|
1320
|
-
{/* Primary conversation_id */}
|
|
1321
|
-
{task.conversation_id && (
|
|
1322
|
-
<div className="conv-item primary">
|
|
1323
|
-
<span className="conv-label">Chính</span>
|
|
1324
|
-
<code className="note-conv-id">{task.conversation_id.slice(0, 20)}…</code>
|
|
1325
|
-
<button
|
|
1326
|
-
className="copy-id-btn"
|
|
1327
|
-
onClick={() => navigator.clipboard.writeText(task.conversation_id)}
|
|
1328
|
-
title="Sao chép ID"
|
|
1329
|
-
style={{ fontSize: 10 }}
|
|
1330
|
-
>
|
|
1331
|
-
📋
|
|
1332
|
-
</button>
|
|
1333
|
-
</div>
|
|
1334
|
-
)}
|
|
1335
|
-
{/* Notes linked to this task */}
|
|
1336
|
-
{taskNotes.filter(n => n.conversation_id && n.conversation_id !== task.conversation_id).map(note => (
|
|
1337
|
-
<div className="conv-item" key={note.id}>
|
|
1338
|
-
<span className="conv-type-icon">{NOTE_TYPE_ICONS[note.type] || '📄'}</span>
|
|
1339
|
-
<span className="conv-note-title">{note.title}</span>
|
|
1340
|
-
<code className="note-conv-id">{note.conversation_id.slice(0, 12)}…</code>
|
|
1341
|
-
<button
|
|
1342
|
-
className="copy-id-btn"
|
|
1343
|
-
onClick={() => navigator.clipboard.writeText(note.conversation_id)}
|
|
1344
|
-
title="Sao chép ID"
|
|
1345
|
-
style={{ fontSize: 10 }}
|
|
1346
|
-
>
|
|
1347
|
-
📋
|
|
1348
|
-
</button>
|
|
1349
|
-
<button
|
|
1350
|
-
className="conv-remove-btn"
|
|
1351
|
-
onClick={() => handleRemoveNote(note.id)}
|
|
1352
|
-
title="Remove"
|
|
1353
|
-
>
|
|
1354
|
-
✕
|
|
1355
|
-
</button>
|
|
1356
|
-
</div>
|
|
1357
|
-
))}
|
|
1358
|
-
{/* Notes without conversation_id (file-linked) */}
|
|
1359
|
-
{taskNotes.filter(n => !n.conversation_id && n.file_path).map(note => (
|
|
1360
|
-
<div className="conv-item" key={note.id}>
|
|
1361
|
-
<span className="conv-type-icon">{NOTE_TYPE_ICONS[note.type] || '📄'}</span>
|
|
1362
|
-
<span className="conv-note-title">{note.title}</span>
|
|
1363
|
-
<span className="note-meta-value" title={note.file_path}>
|
|
1364
|
-
📁 {note.file_path.split('/').slice(-2).join('/')}
|
|
1365
|
-
</span>
|
|
1366
|
-
<button
|
|
1367
|
-
className="conv-remove-btn"
|
|
1368
|
-
onClick={() => handleRemoveNote(note.id)}
|
|
1369
|
-
title="Xoá"
|
|
1370
|
-
>
|
|
1371
|
-
✕
|
|
1372
|
-
</button>
|
|
1373
|
-
</div>
|
|
1374
|
-
))}
|
|
1375
|
-
{/* Add conversation input */}
|
|
1376
|
-
<div className="conv-add-row">
|
|
1377
|
-
<input
|
|
1378
|
-
className="conv-add-input"
|
|
1379
|
-
value={convInput}
|
|
1380
|
-
onChange={(e) => setConvInput(e.target.value)}
|
|
1381
|
-
onKeyDown={(e) => e.key === 'Enter' && handleAddConversation()}
|
|
1382
|
-
placeholder="Dán ID hội thoại…"
|
|
1383
|
-
/>
|
|
1384
|
-
<button
|
|
1385
|
-
className="conv-add-btn"
|
|
1386
|
-
onClick={handleAddConversation}
|
|
1387
|
-
disabled={!convInput.trim()}
|
|
1388
|
-
>
|
|
1389
|
-
+
|
|
1390
|
-
</button>
|
|
1391
|
-
</div>
|
|
1392
|
-
</div>
|
|
1393
|
-
</div>
|
|
1394
|
-
</div>
|
|
1395
|
-
|
|
1396
|
-
<div className="modal-actions">
|
|
1397
|
-
<div className="modal-actions-left">
|
|
1398
|
-
{isEditable && (
|
|
1399
|
-
confirmDelete ? (
|
|
1400
|
-
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
|
1401
|
-
<span style={{ fontSize: 12, color: '#ef4444' }}>Xoá task này?</span>
|
|
1402
|
-
<button className="btn-danger" onClick={() => onDelete(task.id)} style={{ padding: '4px 12px', fontSize: 12 }}>
|
|
1403
|
-
Có, Xoá
|
|
1404
|
-
</button>
|
|
1405
|
-
<button className="btn-cancel" onClick={() => setConfirmDelete(false)} style={{ padding: '4px 12px', fontSize: 12 }}>
|
|
1406
|
-
Huỷ
|
|
1407
|
-
</button>
|
|
1408
|
-
</div>
|
|
1409
|
-
) : (
|
|
1410
|
-
<button className="btn-danger" onClick={() => setConfirmDelete(true)}>
|
|
1411
|
-
🗑️ Xoá
|
|
1412
|
-
</button>
|
|
1413
|
-
)
|
|
1414
|
-
)}
|
|
1415
|
-
{task.status === "done" && (
|
|
1416
|
-
<button className="btn-reopen" onClick={() => onReopen(task.id)}>
|
|
1417
|
-
🔄 Mở lại
|
|
1418
|
-
</button>
|
|
1419
|
-
)}
|
|
1420
|
-
</div>
|
|
1421
|
-
<div className="modal-actions-right">
|
|
1422
|
-
<button className="btn-cancel" onClick={onClose}>
|
|
1423
|
-
{isEditable ? "Huỷ" : "Đóng"}
|
|
1424
|
-
</button>
|
|
1425
|
-
{task.status === "draft" && (
|
|
1426
|
-
<button className="btn-approve" onClick={() => onApprove(task.id)}>
|
|
1427
|
-
✅ Duyệt
|
|
1428
|
-
</button>
|
|
1429
|
-
)}
|
|
1430
|
-
{isEditable && (
|
|
1431
|
-
<button className="create-btn" onClick={handleSave} disabled={saving}>
|
|
1432
|
-
{saving ? "Đang lưu..." : "💾 Lưu"}
|
|
1433
|
-
</button>
|
|
1434
|
-
)}
|
|
1435
|
-
</div>
|
|
1436
|
-
</div>
|
|
1437
|
-
</div>
|
|
1438
|
-
</div>
|
|
1439
|
-
);
|
|
1440
|
-
}
|
|
1441
|
-
|
|
1442
|
-
// ─── Create Task Modal ───────────────────────────────────────────────────────
|
|
1443
|
-
|
|
1444
|
-
function CreateTaskModal({ onClose, onSubmit, agents }) {
|
|
1445
|
-
const [form, setForm] = useState({
|
|
1446
|
-
title: "",
|
|
1447
|
-
description: "",
|
|
1448
|
-
priority: 2,
|
|
1449
|
-
acceptance: "",
|
|
1450
|
-
phase: "",
|
|
1451
|
-
agent_id: "",
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
const handleSubmit = (e) => {
|
|
1455
|
-
e.preventDefault();
|
|
1456
|
-
if (!form.title.trim()) return;
|
|
1457
|
-
onSubmit({ ...form, agent_id: form.agent_id || undefined });
|
|
1458
|
-
};
|
|
1459
|
-
|
|
1460
|
-
return (
|
|
1461
|
-
<div className="modal-overlay" onClick={onClose}>
|
|
1462
|
-
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
1463
|
-
<h2 className="modal-title">Tạo Task Nháp</h2>
|
|
1464
|
-
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
|
|
1465
|
-
Task bắt đầu là nháp. Duyệt khi sẵn sàng cho agent.
|
|
1466
|
-
</p>
|
|
1467
|
-
<form onSubmit={handleSubmit}>
|
|
1468
|
-
<div className="form-group">
|
|
1469
|
-
<label className="form-label">Tiêu đề *</label>
|
|
1470
|
-
<input
|
|
1471
|
-
className="form-input"
|
|
1472
|
-
type="text"
|
|
1473
|
-
value={form.title}
|
|
1474
|
-
onChange={(e) => setForm({ ...form, title: e.target.value })}
|
|
1475
|
-
placeholder="Tiêu đề task..."
|
|
1476
|
-
autoFocus
|
|
1477
|
-
/>
|
|
1478
|
-
</div>
|
|
1479
|
-
<div className="form-group">
|
|
1480
|
-
<label className="form-label">Mô tả</label>
|
|
1481
|
-
<textarea
|
|
1482
|
-
className="form-textarea"
|
|
1483
|
-
value={form.description}
|
|
1484
|
-
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
|
1485
|
-
placeholder="Cần làm gì..."
|
|
1486
|
-
/>
|
|
1487
|
-
</div>
|
|
1488
|
-
<div className="form-row">
|
|
1489
|
-
<div className="form-group half">
|
|
1490
|
-
<label className="form-label">Ʈu tiên</label>
|
|
1491
|
-
<select
|
|
1492
|
-
className="form-select"
|
|
1493
|
-
value={form.priority}
|
|
1494
|
-
onChange={(e) => setForm({ ...form, priority: parseInt(e.target.value) })}
|
|
1495
|
-
>
|
|
1496
|
-
<option value={1}>P1 — Cao</option>
|
|
1497
|
-
<option value={2}>P2 — Trung bình</option>
|
|
1498
|
-
<option value={3}>P3 — Thấp</option>
|
|
1499
|
-
</select>
|
|
1500
|
-
</div>
|
|
1501
|
-
<div className="form-group half">
|
|
1502
|
-
<label className="form-label">Giai đoạn / Nhóm</label>
|
|
1503
|
-
<input
|
|
1504
|
-
className="form-input"
|
|
1505
|
-
type="text"
|
|
1506
|
-
value={form.phase}
|
|
1507
|
-
onChange={(e) => setForm({ ...form, phase: e.target.value })}
|
|
1508
|
-
placeholder="iOS, Android, Backend..."
|
|
1509
|
-
/>
|
|
1510
|
-
</div>
|
|
1511
|
-
</div>
|
|
1512
|
-
<div className="form-group">
|
|
1513
|
-
<label className="form-label">Gán Agent</label>
|
|
1514
|
-
<select
|
|
1515
|
-
className="form-select"
|
|
1516
|
-
value={form.agent_id}
|
|
1517
|
-
onChange={(e) => setForm({ ...form, agent_id: e.target.value })}
|
|
1518
|
-
>
|
|
1519
|
-
<option value="">— Chưa gán —</option>
|
|
1520
|
-
{agents?.map(a => (
|
|
1521
|
-
<option key={a.id} value={a.id}>
|
|
1522
|
-
🤖 {a.name || a.id}{a.specialties?.length > 0 ? ` (${a.specialties.join(', ')})` : ''}
|
|
1523
|
-
</option>
|
|
1524
|
-
))}
|
|
1525
|
-
</select>
|
|
1526
|
-
</div>
|
|
1527
|
-
<div className="form-group">
|
|
1528
|
-
<label className="form-label">Tiêu chí nghiệm thu</label>
|
|
1529
|
-
<textarea
|
|
1530
|
-
className="form-textarea"
|
|
1531
|
-
value={form.acceptance}
|
|
1532
|
-
onChange={(e) => setForm({ ...form, acceptance: e.target.value })}
|
|
1533
|
-
placeholder="Cách xác nhận task hoàn thành..."
|
|
1534
|
-
/>
|
|
1535
|
-
</div>
|
|
1536
|
-
<div className="modal-actions">
|
|
1537
|
-
<button type="button" className="btn-cancel" onClick={onClose}>
|
|
1538
|
-
Huỷ
|
|
1539
|
-
</button>
|
|
1540
|
-
<button type="submit" className="create-btn">
|
|
1541
|
-
📝 Tạo Nháp
|
|
1542
|
-
</button>
|
|
1543
|
-
</div>
|
|
1544
|
-
</form>
|
|
1545
|
-
</div>
|
|
1546
|
-
</div>
|
|
1547
|
-
);
|
|
1548
|
-
}
|
|
1549
|
-
|
|
1550
|
-
// ─── Tag Input Component ─────────────────────────────────────────────────────
|
|
1551
|
-
|
|
1552
|
-
function TagInput({ tags, onChange, placeholder }) {
|
|
1553
|
-
const [input, setInput] = useState("");
|
|
1554
|
-
|
|
1555
|
-
const handleKeyDown = (e) => {
|
|
1556
|
-
if (e.key === "Enter" || e.key === ",") {
|
|
1557
|
-
e.preventDefault();
|
|
1558
|
-
const val = input.trim().replace(/,$/, "");
|
|
1559
|
-
if (val && !tags.includes(val)) {
|
|
1560
|
-
onChange([...tags, val]);
|
|
1561
|
-
}
|
|
1562
|
-
setInput("");
|
|
1563
|
-
} else if (e.key === "Backspace" && !input && tags.length > 0) {
|
|
1564
|
-
onChange(tags.slice(0, -1));
|
|
1565
|
-
}
|
|
1566
|
-
};
|
|
1567
|
-
|
|
1568
|
-
const removeTag = (idx) => onChange(tags.filter((_, i) => i !== idx));
|
|
1569
|
-
|
|
1570
|
-
return (
|
|
1571
|
-
<div className="tag-input-container">
|
|
1572
|
-
{tags.map((tag, i) => (
|
|
1573
|
-
<span className="tag-input-tag" key={i}>
|
|
1574
|
-
{tag}
|
|
1575
|
-
<button type="button" className="tag-remove" onClick={() => removeTag(i)}>✕</button>
|
|
1576
|
-
</span>
|
|
1577
|
-
))}
|
|
1578
|
-
<input
|
|
1579
|
-
className="tag-input-field"
|
|
1580
|
-
value={input}
|
|
1581
|
-
onChange={(e) => setInput(e.target.value)}
|
|
1582
|
-
onKeyDown={handleKeyDown}
|
|
1583
|
-
placeholder={tags.length === 0 ? placeholder : ""}
|
|
1584
|
-
/>
|
|
1585
|
-
</div>
|
|
1586
|
-
);
|
|
1587
|
-
}
|
|
1588
|
-
|
|
1589
|
-
// ─── Role Modal ──────────────────────────────────────────────────────────────
|
|
1590
|
-
|
|
1591
|
-
function RoleModal({ role, onClose, onSubmit }) {
|
|
1592
|
-
const [form, setForm] = useState({
|
|
1593
|
-
key: role?.key || "",
|
|
1594
|
-
name: role?.name || "",
|
|
1595
|
-
icon: role?.icon || "🎭",
|
|
1596
|
-
color: role?.color || "#4f7cff",
|
|
1597
|
-
skills: role?.skills || [],
|
|
1598
|
-
workflows: role?.workflows || [],
|
|
1599
|
-
phases: role?.match?.phases || [],
|
|
1600
|
-
keywords: role?.match?.keywords || [],
|
|
1601
|
-
});
|
|
1602
|
-
|
|
1603
|
-
const handleSubmit = (e) => {
|
|
1604
|
-
e.preventDefault();
|
|
1605
|
-
if (!role && !form.key.trim()) return;
|
|
1606
|
-
if (!form.name.trim()) return;
|
|
1607
|
-
onSubmit({
|
|
1608
|
-
key: role?.key || form.key,
|
|
1609
|
-
name: form.name,
|
|
1610
|
-
icon: form.icon,
|
|
1611
|
-
color: form.color,
|
|
1612
|
-
skills: form.skills,
|
|
1613
|
-
workflows: form.workflows,
|
|
1614
|
-
match: {
|
|
1615
|
-
phases: form.phases,
|
|
1616
|
-
keywords: form.keywords,
|
|
1617
|
-
},
|
|
1618
|
-
});
|
|
1619
|
-
};
|
|
1620
|
-
|
|
1621
|
-
const colorPresets = ["#ef4444", "#f59e0b", "#22c55e", "#4f7cff", "#7c5cff", "#ec4899", "#8888a0", "#34d399"];
|
|
1622
|
-
const iconPresets = ["🍎", "🤖", "🌐", "📈", "🎨", "🔧", "🎭", "📱", "☁️", "🧪", "📊", "🛡️"];
|
|
1623
|
-
|
|
1624
|
-
return (
|
|
1625
|
-
<div className="modal-overlay" onClick={onClose}>
|
|
1626
|
-
<div className="modal modal-wide" onClick={(e) => e.stopPropagation()}>
|
|
1627
|
-
<h2 className="modal-title">{role ? "Sửa Vai trò" : "Thêm Vai trò"}</h2>
|
|
1628
|
-
<form onSubmit={handleSubmit}>
|
|
1629
|
-
{!role && (
|
|
1630
|
-
<div className="form-group">
|
|
1631
|
-
<label className="form-label">ID Vai trò * <span className="form-hint">(vd: ios, android, web)</span></label>
|
|
1632
|
-
<input
|
|
1633
|
-
className="form-input"
|
|
1634
|
-
value={form.key}
|
|
1635
|
-
onChange={(e) => setForm({ ...form, key: e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, '') })}
|
|
1636
|
-
placeholder="ios"
|
|
1637
|
-
autoFocus
|
|
1638
|
-
style={{ fontFamily: 'monospace' }}
|
|
1639
|
-
/>
|
|
1640
|
-
</div>
|
|
1641
|
-
)}
|
|
1642
|
-
<div className="form-row">
|
|
1643
|
-
<div className="form-group half">
|
|
1644
|
-
<label className="form-label">Tên vai trò *</label>
|
|
1645
|
-
<input
|
|
1646
|
-
className="form-input"
|
|
1647
|
-
value={form.name}
|
|
1648
|
-
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
1649
|
-
placeholder="iOS Engineer"
|
|
1650
|
-
autoFocus={!!role}
|
|
1651
|
-
/>
|
|
1652
|
-
</div>
|
|
1653
|
-
<div className="form-group half">
|
|
1654
|
-
<label className="form-label">Biểu tượng</label>
|
|
1655
|
-
<div className="color-presets">
|
|
1656
|
-
{iconPresets.map((ic) => (
|
|
1657
|
-
<button
|
|
1658
|
-
key={ic}
|
|
1659
|
-
type="button"
|
|
1660
|
-
className={`icon-preset ${form.icon === ic ? "active" : ""}`}
|
|
1661
|
-
onClick={() => setForm({ ...form, icon: ic })}
|
|
1662
|
-
>
|
|
1663
|
-
{ic}
|
|
1664
|
-
</button>
|
|
1665
|
-
))}
|
|
1666
|
-
</div>
|
|
1667
|
-
</div>
|
|
1668
|
-
</div>
|
|
1669
|
-
<div className="form-group">
|
|
1670
|
-
<label className="form-label">Màu sắc</label>
|
|
1671
|
-
<div className="color-presets">
|
|
1672
|
-
{colorPresets.map((c) => (
|
|
1673
|
-
<button
|
|
1674
|
-
key={c}
|
|
1675
|
-
type="button"
|
|
1676
|
-
className={`color-preset ${form.color === c ? "active" : ""}`}
|
|
1677
|
-
style={{ background: c }}
|
|
1678
|
-
onClick={() => setForm({ ...form, color: c })}
|
|
1679
|
-
/>
|
|
1680
|
-
))}
|
|
1681
|
-
</div>
|
|
1682
|
-
</div>
|
|
1683
|
-
<div className="form-group">
|
|
1684
|
-
<label className="form-label">Kỹ năng (Skills)</label>
|
|
1685
|
-
<TagInput
|
|
1686
|
-
tags={form.skills}
|
|
1687
|
-
onChange={(skills) => setForm({ ...form, skills })}
|
|
1688
|
-
placeholder="Nhập kỹ năng, nhấn Enter…"
|
|
1689
|
-
/>
|
|
1690
|
-
</div>
|
|
1691
|
-
<div className="form-group">
|
|
1692
|
-
<label className="form-label">Quy trình (Workflows)</label>
|
|
1693
|
-
<TagInput
|
|
1694
|
-
tags={form.workflows}
|
|
1695
|
-
onChange={(workflows) => setForm({ ...form, workflows })}
|
|
1696
|
-
placeholder="/code, /debug, /test…"
|
|
1697
|
-
/>
|
|
1698
|
-
</div>
|
|
1699
|
-
<div className="form-row">
|
|
1700
|
-
<div className="form-group half">
|
|
1701
|
-
<label className="form-label">Phase matching</label>
|
|
1702
|
-
<TagInput
|
|
1703
|
-
tags={form.phases}
|
|
1704
|
-
onChange={(phases) => setForm({ ...form, phases })}
|
|
1705
|
-
placeholder="iOS, SwiftUI…"
|
|
1706
|
-
/>
|
|
1707
|
-
</div>
|
|
1708
|
-
<div className="form-group half">
|
|
1709
|
-
<label className="form-label">Keyword matching</label>
|
|
1710
|
-
<TagInput
|
|
1711
|
-
tags={form.keywords}
|
|
1712
|
-
onChange={(keywords) => setForm({ ...form, keywords })}
|
|
1713
|
-
placeholder="swift, xcode…"
|
|
1714
|
-
/>
|
|
1715
|
-
</div>
|
|
1716
|
-
</div>
|
|
1717
|
-
<div className="modal-actions">
|
|
1718
|
-
<button type="button" className="btn-cancel" onClick={onClose}>Huỷ</button>
|
|
1719
|
-
<button type="submit" className="create-btn">{role ? "💾 Lưu" : "+ Thêm Vai trò"}</button>
|
|
1720
|
-
</div>
|
|
1721
|
-
</form>
|
|
1722
|
-
</div>
|
|
1723
|
-
</div>
|
|
1724
|
-
);
|
|
1725
|
-
}
|
|
1726
|
-
|
|
1727
|
-
// ─── Add Project Modal ───────────────────────────────────────────────────────
|
|
1728
|
-
|
|
1729
|
-
function AddProjectModal({ onClose, onSubmit }) {
|
|
1730
|
-
const [form, setForm] = useState({
|
|
1731
|
-
id: "",
|
|
1732
|
-
name: "",
|
|
1733
|
-
path: "",
|
|
1734
|
-
icon: "📁",
|
|
1735
|
-
color: "#4f7cff",
|
|
1736
|
-
});
|
|
1737
|
-
|
|
1738
|
-
const iconPresets = ["📁", "🍎", "🤖", "🌐", "📷", "🪷", "🎮", "💊", "📊", "🛒"];
|
|
1739
|
-
const colorPresets = ["#ef4444", "#f59e0b", "#22c55e", "#4f7cff", "#7c5cff", "#ec4899", "#8888a0"];
|
|
1740
|
-
|
|
1741
|
-
const handleSubmit = (e) => {
|
|
1742
|
-
e.preventDefault();
|
|
1743
|
-
if (!form.id.trim() || !form.name.trim()) return;
|
|
1744
|
-
onSubmit(form);
|
|
1745
|
-
};
|
|
1746
|
-
|
|
1747
|
-
const autoId = (name) => name.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
1748
|
-
|
|
1749
|
-
return (
|
|
1750
|
-
<div className="modal-overlay" onClick={onClose}>
|
|
1751
|
-
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
|
1752
|
-
<h2 className="modal-title">Đăng ký Dự án</h2>
|
|
1753
|
-
<p style={{ fontSize: 12, color: "var(--text-muted)", marginTop: -8, marginBottom: 16 }}>
|
|
1754
|
-
Đăng ký dự án để phân nhóm task và sự kiện.
|
|
1755
|
-
</p>
|
|
1756
|
-
<form onSubmit={handleSubmit}>
|
|
1757
|
-
<div className="form-group">
|
|
1758
|
-
<label className="form-label">Tên dự án *</label>
|
|
1759
|
-
<input
|
|
1760
|
-
className="form-input"
|
|
1761
|
-
value={form.name}
|
|
1762
|
-
onChange={(e) => setForm({ ...form, name: e.target.value, id: form.id || autoId(e.target.value) })}
|
|
1763
|
-
placeholder="FitBite Pro, FilmCam..."
|
|
1764
|
-
autoFocus
|
|
1765
|
-
/>
|
|
1766
|
-
</div>
|
|
1767
|
-
<div className="form-group">
|
|
1768
|
-
<label className="form-label">ID dự án *</label>
|
|
1769
|
-
<input
|
|
1770
|
-
className="form-input"
|
|
1771
|
-
value={form.id}
|
|
1772
|
-
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
|
1773
|
-
placeholder="fitbite-pro"
|
|
1774
|
-
style={{ fontFamily: 'monospace' }}
|
|
1775
|
-
/>
|
|
1776
|
-
</div>
|
|
1777
|
-
<div className="form-group">
|
|
1778
|
-
<label className="form-label">Đường dẫn (tuỳ chọn)</label>
|
|
1779
|
-
<input
|
|
1780
|
-
className="form-input"
|
|
1781
|
-
value={form.path}
|
|
1782
|
-
onChange={(e) => setForm({ ...form, path: e.target.value })}
|
|
1783
|
-
placeholder="/Users/.../Dev/iOS/FitBitePro"
|
|
1784
|
-
/>
|
|
1785
|
-
</div>
|
|
1786
|
-
<div className="form-group">
|
|
1787
|
-
<label className="form-label">Biểu tượng</label>
|
|
1788
|
-
<div className="color-presets">
|
|
1789
|
-
{iconPresets.map((ic) => (
|
|
1790
|
-
<button
|
|
1791
|
-
key={ic}
|
|
1792
|
-
type="button"
|
|
1793
|
-
className={`icon-preset ${form.icon === ic ? "active" : ""}`}
|
|
1794
|
-
onClick={() => setForm({ ...form, icon: ic })}
|
|
1795
|
-
>
|
|
1796
|
-
{ic}
|
|
1797
|
-
</button>
|
|
1798
|
-
))}
|
|
1799
|
-
</div>
|
|
1800
|
-
</div>
|
|
1801
|
-
<div className="form-group">
|
|
1802
|
-
<label className="form-label">Màu sắc</label>
|
|
1803
|
-
<div className="color-presets">
|
|
1804
|
-
{colorPresets.map((c) => (
|
|
1805
|
-
<button
|
|
1806
|
-
key={c}
|
|
1807
|
-
type="button"
|
|
1808
|
-
className={`color-preset ${form.color === c ? "active" : ""}`}
|
|
1809
|
-
style={{ background: c }}
|
|
1810
|
-
onClick={() => setForm({ ...form, color: c })}
|
|
1811
|
-
/>
|
|
1812
|
-
))}
|
|
1813
|
-
</div>
|
|
1814
|
-
</div>
|
|
1815
|
-
<div className="modal-actions">
|
|
1816
|
-
<button type="button" className="btn-cancel" onClick={onClose}>Huỷ</button>
|
|
1817
|
-
<button type="submit" className="create-btn">📁 Đăng ký</button>
|
|
1818
|
-
</div>
|
|
1819
|
-
</form>
|
|
1820
|
-
</div>
|
|
1821
|
-
</div>
|
|
1822
|
-
);
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// ─── Knowledge Panel ─────────────────────────────────────────────────────────
|
|
1826
|
-
|
|
1827
|
-
const PROJECT_PATTERNS = [
|
|
1828
|
-
{ prefix: 'dr_blood_pressure', label: 'Dr. Blood Pressure', icon: '🩺' },
|
|
1829
|
-
{ prefix: 'fitbite_pro', label: 'FitBite Pro', icon: '🍎' },
|
|
1830
|
-
{ prefix: 'giacngo', label: 'Giác Ngộ', icon: '🧘' },
|
|
1831
|
-
{ prefix: 'vintage_camera', label: 'Vintage Camera', icon: '📷' },
|
|
1832
|
-
{ prefix: 'wink', label: 'Wink', icon: '✨' },
|
|
1833
|
-
];
|
|
1834
|
-
|
|
1835
|
-
function detectProject(kiId) {
|
|
1836
|
-
for (const p of PROJECT_PATTERNS) {
|
|
1837
|
-
if (kiId.startsWith(p.prefix)) return p;
|
|
1838
|
-
}
|
|
1839
|
-
return { prefix: '', label: 'Other', icon: '📂' };
|
|
1840
|
-
}
|
|
1841
|
-
|
|
1842
|
-
function KnowledgePanel({ items, onOpenEditor, onRefresh }) {
|
|
1843
|
-
const [search, setSearch] = useState('');
|
|
1844
|
-
const [showCreate, setShowCreate] = useState(false);
|
|
1845
|
-
const [createForm, setCreateForm] = useState({ id: '', title: '', summary: '' });
|
|
1846
|
-
const [creating, setCreating] = useState(false);
|
|
1847
|
-
|
|
1848
|
-
const filtered = items.filter(item =>
|
|
1849
|
-
!search || item.title.toLowerCase().includes(search.toLowerCase()) ||
|
|
1850
|
-
item.id.toLowerCase().includes(search.toLowerCase())
|
|
1851
|
-
);
|
|
1852
|
-
|
|
1853
|
-
const grouped = {};
|
|
1854
|
-
for (const item of filtered) {
|
|
1855
|
-
const proj = detectProject(item.id);
|
|
1856
|
-
if (!grouped[proj.label]) grouped[proj.label] = { ...proj, items: [] };
|
|
1857
|
-
grouped[proj.label].items.push(item);
|
|
1858
|
-
}
|
|
1859
|
-
|
|
1860
|
-
const handleCreate = async (e) => {
|
|
1861
|
-
e.preventDefault();
|
|
1862
|
-
setCreating(true);
|
|
1863
|
-
try {
|
|
1864
|
-
await fetch('/api/knowledge', {
|
|
1865
|
-
method: 'POST',
|
|
1866
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1867
|
-
body: JSON.stringify(createForm),
|
|
1868
|
-
});
|
|
1869
|
-
setShowCreate(false);
|
|
1870
|
-
setCreateForm({ id: '', title: '', summary: '' });
|
|
1871
|
-
onRefresh();
|
|
1872
|
-
} catch (err) {
|
|
1873
|
-
console.error('Failed to create KI:', err);
|
|
1874
|
-
}
|
|
1875
|
-
setCreating(false);
|
|
1876
|
-
};
|
|
1877
|
-
|
|
1878
|
-
if (items.length === 0) {
|
|
1879
|
-
return (
|
|
1880
|
-
<div className="empty-state">
|
|
1881
|
-
<div className="empty-icon">📚</div>
|
|
1882
|
-
<div className="empty-text">Chưa có Knowledge Item nào.</div>
|
|
1883
|
-
</div>
|
|
1884
|
-
);
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
|
-
return (
|
|
1888
|
-
<div className="knowledge-panel">
|
|
1889
|
-
<div className="ki-search-bar">
|
|
1890
|
-
<input type="text" placeholder="Tìm knowledge..." value={search} onChange={(e) => setSearch(e.target.value)} className="ki-search-input" />
|
|
1891
|
-
<button className="ki-create-btn" onClick={() => setShowCreate(!showCreate)} title="Tạo KI mới">+</button>
|
|
1892
|
-
</div>
|
|
1893
|
-
{showCreate && (
|
|
1894
|
-
<form className="ki-create-form" onSubmit={handleCreate}>
|
|
1895
|
-
<input type="text" placeholder="ID (vd: wink_architecture)" value={createForm.id} onChange={(e) => setCreateForm({ ...createForm, id: e.target.value })} className="ki-create-input" required />
|
|
1896
|
-
<input type="text" placeholder="Title" value={createForm.title} onChange={(e) => setCreateForm({ ...createForm, title: e.target.value })} className="ki-create-input" required />
|
|
1897
|
-
<textarea placeholder="Summary" value={createForm.summary} onChange={(e) => setCreateForm({ ...createForm, summary: e.target.value })} className="ki-create-input" rows={2} />
|
|
1898
|
-
<div style={{ display: 'flex', gap: 6 }}>
|
|
1899
|
-
<button type="submit" className="ki-create-submit" disabled={creating}>{creating ? '...' : '✅ Tạo'}</button>
|
|
1900
|
-
<button type="button" className="ki-create-cancel" onClick={() => setShowCreate(false)}>Huỷ</button>
|
|
1901
|
-
</div>
|
|
1902
|
-
</form>
|
|
1903
|
-
)}
|
|
1904
|
-
{Object.entries(grouped).map(([label, group]) => (
|
|
1905
|
-
<div key={label} className="ki-project-group">
|
|
1906
|
-
<div className="ki-group-header">
|
|
1907
|
-
<span>{group.icon} {label}</span>
|
|
1908
|
-
<span className="ki-group-count">{group.items.length}</span>
|
|
1909
|
-
</div>
|
|
1910
|
-
{group.items.map(item => (
|
|
1911
|
-
<div key={item.id} className="ki-card" onClick={() => onOpenEditor(item)}>
|
|
1912
|
-
<div className="ki-card-title">{item.title}</div>
|
|
1913
|
-
<div className="ki-card-meta">
|
|
1914
|
-
<span>📄 {item.artifactCount} files</span>
|
|
1915
|
-
<span>🔗 {item.referenceCount} refs</span>
|
|
1916
|
-
</div>
|
|
1917
|
-
</div>
|
|
1918
|
-
))}
|
|
1919
|
-
</div>
|
|
1920
|
-
))}
|
|
1921
|
-
</div>
|
|
1922
|
-
);
|
|
1923
|
-
}
|
|
1924
|
-
|
|
1925
|
-
// ─── Knowledge Editor Modal ──────────────────────────────────────────────────
|
|
1926
|
-
|
|
1927
|
-
function KnowledgeEditorModal({ item, onClose, onRefresh }) {
|
|
1928
|
-
const [detail, setDetail] = useState(null);
|
|
1929
|
-
const [selectedFile, setSelectedFile] = useState(null);
|
|
1930
|
-
const [fileContent, setFileContent] = useState('');
|
|
1931
|
-
const [originalContent, setOriginalContent] = useState('');
|
|
1932
|
-
const [saving, setSaving] = useState(false);
|
|
1933
|
-
const [editingMeta, setEditingMeta] = useState(false);
|
|
1934
|
-
const [metaForm, setMetaForm] = useState({ title: '', summary: '' });
|
|
1935
|
-
const [newFileName, setNewFileName] = useState('');
|
|
1936
|
-
const [showNewFile, setShowNewFile] = useState(false);
|
|
1937
|
-
const contentRef = useRef(null);
|
|
1938
|
-
|
|
1939
|
-
useEffect(() => {
|
|
1940
|
-
fetch(`/api/knowledge?id=${item.id}`)
|
|
1941
|
-
.then(r => r.json())
|
|
1942
|
-
.then(d => {
|
|
1943
|
-
setDetail(d.item);
|
|
1944
|
-
setMetaForm({ title: d.item.title || '', summary: d.item.summary || '' });
|
|
1945
|
-
if (d.item.artifacts?.length > 0) loadFile(d.item.artifacts[0].path);
|
|
1946
|
-
})
|
|
1947
|
-
.catch(err => console.error('Failed to load KI:', err));
|
|
1948
|
-
}, [item.id]);
|
|
1949
|
-
|
|
1950
|
-
const loadFile = async (filePath) => {
|
|
1951
|
-
setSelectedFile(filePath);
|
|
1952
|
-
try {
|
|
1953
|
-
const res = await fetch(`/api/knowledge?id=${item.id}&file=${encodeURIComponent(filePath)}`);
|
|
1954
|
-
const data = await res.json();
|
|
1955
|
-
setFileContent(data.content || '');
|
|
1956
|
-
setOriginalContent(data.content || '');
|
|
1957
|
-
} catch (err) {
|
|
1958
|
-
setFileContent('Error loading file');
|
|
1959
|
-
setOriginalContent('');
|
|
1960
|
-
}
|
|
1961
|
-
};
|
|
1962
|
-
|
|
1963
|
-
const handleSave = async () => {
|
|
1964
|
-
if (!selectedFile || fileContent === originalContent) return;
|
|
1965
|
-
setSaving(true);
|
|
1966
|
-
try {
|
|
1967
|
-
await fetch('/api/knowledge', {
|
|
1968
|
-
method: 'PATCH',
|
|
1969
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1970
|
-
body: JSON.stringify({ id: item.id, file: selectedFile, content: fileContent }),
|
|
1971
|
-
});
|
|
1972
|
-
setOriginalContent(fileContent);
|
|
1973
|
-
} catch (err) {
|
|
1974
|
-
console.error('Failed to save:', err);
|
|
1975
|
-
}
|
|
1976
|
-
setSaving(false);
|
|
1977
|
-
};
|
|
1978
|
-
|
|
1979
|
-
const handleSaveMeta = async () => {
|
|
1980
|
-
try {
|
|
1981
|
-
await fetch('/api/knowledge', {
|
|
1982
|
-
method: 'PATCH',
|
|
1983
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1984
|
-
body: JSON.stringify({ id: item.id, metadata: metaForm }),
|
|
1985
|
-
});
|
|
1986
|
-
setEditingMeta(false);
|
|
1987
|
-
onRefresh();
|
|
1988
|
-
} catch (err) {
|
|
1989
|
-
console.error('Failed to save metadata:', err);
|
|
1990
|
-
}
|
|
1991
|
-
};
|
|
1992
|
-
|
|
1993
|
-
const handleCreateFile = async (e) => {
|
|
1994
|
-
e.preventDefault();
|
|
1995
|
-
if (!newFileName) return;
|
|
1996
|
-
const filePath = newFileName.startsWith('artifacts/') ? newFileName : `artifacts/${newFileName}`;
|
|
1997
|
-
try {
|
|
1998
|
-
await fetch('/api/knowledge', {
|
|
1999
|
-
method: 'PATCH',
|
|
2000
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2001
|
-
body: JSON.stringify({ id: item.id, file: filePath, content: `# ${newFileName.replace(/\.md$/, '').split('/').pop()}\n\nTODO: Add content\n` }),
|
|
2002
|
-
});
|
|
2003
|
-
const res = await fetch(`/api/knowledge?id=${item.id}`);
|
|
2004
|
-
const d = await res.json();
|
|
2005
|
-
setDetail(d.item);
|
|
2006
|
-
setShowNewFile(false);
|
|
2007
|
-
setNewFileName('');
|
|
2008
|
-
loadFile(filePath);
|
|
2009
|
-
} catch (err) {
|
|
2010
|
-
console.error('Failed to create file:', err);
|
|
2011
|
-
}
|
|
2012
|
-
};
|
|
2013
|
-
|
|
2014
|
-
const handleDeleteFile = async (filePath) => {
|
|
2015
|
-
if (!confirm(`Xoá file ${filePath}?`)) return;
|
|
2016
|
-
try {
|
|
2017
|
-
await fetch('/api/knowledge', {
|
|
2018
|
-
method: 'DELETE',
|
|
2019
|
-
headers: { 'Content-Type': 'application/json' },
|
|
2020
|
-
body: JSON.stringify({ id: item.id, file: filePath }),
|
|
2021
|
-
});
|
|
2022
|
-
const res = await fetch(`/api/knowledge?id=${item.id}`);
|
|
2023
|
-
const d = await res.json();
|
|
2024
|
-
setDetail(d.item);
|
|
2025
|
-
if (selectedFile === filePath) { setSelectedFile(null); setFileContent(''); }
|
|
2026
|
-
} catch (err) {
|
|
2027
|
-
console.error('Failed to delete file:', err);
|
|
2028
|
-
}
|
|
2029
|
-
};
|
|
2030
|
-
|
|
2031
|
-
const hasChanges = fileContent !== originalContent;
|
|
2032
|
-
|
|
2033
|
-
useEffect(() => {
|
|
2034
|
-
const handler = (e) => {
|
|
2035
|
-
if ((e.metaKey || e.ctrlKey) && e.key === 's') { e.preventDefault(); handleSave(); }
|
|
2036
|
-
};
|
|
2037
|
-
window.addEventListener('keydown', handler);
|
|
2038
|
-
return () => window.removeEventListener('keydown', handler);
|
|
2039
|
-
});
|
|
2040
|
-
|
|
2041
|
-
return (
|
|
2042
|
-
<div className="modal-overlay" onClick={onClose}>
|
|
2043
|
-
<div className="ke-modal" onClick={(e) => e.stopPropagation()}>
|
|
2044
|
-
<div className="ke-header">
|
|
2045
|
-
<div className="ke-header-left">
|
|
2046
|
-
{editingMeta ? (
|
|
2047
|
-
<div className="ke-meta-edit">
|
|
2048
|
-
<input className="ke-title-input" value={metaForm.title} onChange={(e) => setMetaForm({ ...metaForm, title: e.target.value })} placeholder="Title" />
|
|
2049
|
-
<textarea className="ke-summary-input" value={metaForm.summary} onChange={(e) => setMetaForm({ ...metaForm, summary: e.target.value })} placeholder="Summary" rows={2} />
|
|
2050
|
-
<div style={{ display: 'flex', gap: 6 }}>
|
|
2051
|
-
<button className="ki-create-submit" onClick={handleSaveMeta}>💾 Lưu</button>
|
|
2052
|
-
<button className="ki-create-cancel" onClick={() => setEditingMeta(false)}>Huỷ</button>
|
|
2053
|
-
</div>
|
|
2054
|
-
</div>
|
|
2055
|
-
) : (
|
|
2056
|
-
<>
|
|
2057
|
-
<h2 className="ke-title" onClick={() => setEditingMeta(true)} title="Click để sửa">📚 {detail?.title || item.title}</h2>
|
|
2058
|
-
<div className="ke-summary">{detail?.summary || item.summary}</div>
|
|
2059
|
-
</>
|
|
2060
|
-
)}
|
|
2061
|
-
</div>
|
|
2062
|
-
<button className="ke-close" onClick={onClose}>✕</button>
|
|
2063
|
-
</div>
|
|
2064
|
-
<div className="ke-body">
|
|
2065
|
-
<div className="ke-sidebar">
|
|
2066
|
-
<div className="ke-sidebar-header">
|
|
2067
|
-
<span>📁 Files</span>
|
|
2068
|
-
<button className="ki-create-btn small" onClick={() => setShowNewFile(!showNewFile)} title="Tạo file mới">+</button>
|
|
2069
|
-
</div>
|
|
2070
|
-
{showNewFile && (
|
|
2071
|
-
<form className="ke-new-file" onSubmit={handleCreateFile}>
|
|
2072
|
-
<input type="text" placeholder="path/filename.md" value={newFileName} onChange={(e) => setNewFileName(e.target.value)} className="ki-create-input small" required />
|
|
2073
|
-
<button type="submit" className="ki-create-submit small">✅</button>
|
|
2074
|
-
</form>
|
|
2075
|
-
)}
|
|
2076
|
-
<div className="ke-file-list">
|
|
2077
|
-
<div className={`ke-file-item ${selectedFile === 'metadata.json' ? 'active' : ''}`} onClick={() => loadFile('metadata.json')}>
|
|
2078
|
-
<span>⚙️ metadata.json</span>
|
|
2079
|
-
</div>
|
|
2080
|
-
{detail?.artifacts?.map(a => (
|
|
2081
|
-
<div key={a.path} className={`ke-file-item ${selectedFile === a.path ? 'active' : ''}`} onClick={() => loadFile(a.path)}>
|
|
2082
|
-
<span title={a.path}>{a.path.endsWith('.md') ? '📝' : '📄'} {a.name}</span>
|
|
2083
|
-
<button className="ke-file-delete" onClick={(e) => { e.stopPropagation(); handleDeleteFile(a.path); }} title="Xoá">✕</button>
|
|
2084
|
-
</div>
|
|
2085
|
-
))}
|
|
2086
|
-
</div>
|
|
2087
|
-
{detail?.references?.length > 0 && (
|
|
2088
|
-
<div className="ke-refs">
|
|
2089
|
-
<div className="ke-sidebar-header">🔗 References</div>
|
|
2090
|
-
{detail.references.map((ref, i) => (
|
|
2091
|
-
<div key={i} className="ke-ref-item">
|
|
2092
|
-
<span className="ke-ref-type">{ref.type === 'conversation_id' ? '💬' : '📁'}</span>
|
|
2093
|
-
<span className="ke-ref-value" title={ref.value}>{(ref.value || '').slice(0, 28)}{ref.value?.length > 28 ? '…' : ''}</span>
|
|
2094
|
-
</div>
|
|
2095
|
-
))}
|
|
2096
|
-
</div>
|
|
2097
|
-
)}
|
|
2098
|
-
</div>
|
|
2099
|
-
<div className="ke-editor">
|
|
2100
|
-
{selectedFile ? (
|
|
2101
|
-
<>
|
|
2102
|
-
<div className="ke-editor-header">
|
|
2103
|
-
<span className="ke-editor-path">{selectedFile}</span>
|
|
2104
|
-
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
|
2105
|
-
{hasChanges && <span className="ke-unsaved">●</span>}
|
|
2106
|
-
<button className={`ke-save-btn ${hasChanges ? 'active' : ''}`} onClick={handleSave} disabled={!hasChanges || saving}>
|
|
2107
|
-
{saving ? 'Đang lưu...' : '💾 Lưu'}
|
|
2108
|
-
</button>
|
|
2109
|
-
</div>
|
|
2110
|
-
</div>
|
|
2111
|
-
<textarea className="ke-textarea" value={fileContent} onChange={(e) => setFileContent(e.target.value)} spellCheck={false} ref={contentRef} />
|
|
2112
|
-
</>
|
|
2113
|
-
) : (
|
|
2114
|
-
<div className="ke-empty"><div className="empty-icon">📝</div><div className="empty-text">Chọn file để xem và chỉnh sửa</div></div>
|
|
2115
|
-
)}
|
|
2116
|
-
</div>
|
|
2117
|
-
</div>
|
|
2118
|
-
</div>
|
|
2119
|
-
</div>
|
|
2120
|
-
);
|
|
2121
|
-
}
|
|
2122
|
-
|