@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.
Files changed (84) hide show
  1. package/README.md +51 -1
  2. package/bin/awk.js +2 -2
  3. package/core/GEMINI.md +45 -7
  4. package/package.json +8 -5
  5. package/skill-packs/neural-memory/skills/nm-memory-sync/SKILL.md +14 -1
  6. package/skills/ab-test-store-listing/SKILL.md +220 -0
  7. package/skills/android-aso/SKILL.md +197 -0
  8. package/skills/app-analytics/SKILL.md +210 -0
  9. package/skills/app-clips/SKILL.md +163 -0
  10. package/skills/app-icon-optimization/SKILL.md +170 -0
  11. package/skills/app-launch/SKILL.md +153 -0
  12. package/skills/app-marketing-context/SKILL.md +129 -0
  13. package/skills/app-store-featured/SKILL.md +213 -0
  14. package/skills/apple-search-ads/SKILL.md +205 -0
  15. package/skills/asc-metrics/SKILL.md +157 -0
  16. package/skills/aso-audit/SKILL.md +179 -0
  17. package/skills/competitor-analysis/SKILL.md +163 -0
  18. package/skills/competitor-tracking/SKILL.md +185 -0
  19. package/skills/crash-analytics/SKILL.md +181 -0
  20. package/skills/gitnexus-intelligence/SKILL.md +224 -0
  21. package/skills/in-app-events/SKILL.md +176 -0
  22. package/skills/keyword-research/SKILL.md +141 -0
  23. package/skills/localization/SKILL.md +165 -0
  24. package/skills/market-movers/SKILL.md +137 -0
  25. package/skills/market-pulse/SKILL.md +170 -0
  26. package/skills/metadata-optimization/SKILL.md +170 -0
  27. package/skills/monetization-strategy/SKILL.md +175 -0
  28. package/skills/onboarding-optimization/SKILL.md +194 -0
  29. package/skills/orchestrator/SKILL.md +306 -25
  30. package/skills/press-and-pr/SKILL.md +204 -0
  31. package/skills/rating-prompt-strategy/SKILL.md +184 -0
  32. package/skills/retention-optimization/SKILL.md +165 -0
  33. package/skills/review-management/SKILL.md +154 -0
  34. package/skills/screenshot-optimization/SKILL.md +167 -0
  35. package/skills/seasonal-aso/SKILL.md +141 -0
  36. package/skills/spec-gate/SKILL.md +312 -0
  37. package/skills/subscription-lifecycle/SKILL.md +206 -0
  38. package/skills/swiftui-pro/references/design.md +44 -0
  39. package/skills/symphony-enforcer/SKILL.md +92 -11
  40. package/skills/symphony-orchestrator/SKILL.md +9 -7
  41. package/skills/systematic-debugging/SKILL.md +32 -7
  42. package/skills/ua-campaign/SKILL.md +207 -0
  43. package/skills/verification-gate/SKILL.md +23 -2
  44. package/workflows/gitnexus.md +123 -0
  45. package/symphony/LICENSE +0 -21
  46. package/symphony/README.md +0 -178
  47. package/symphony/app/api/agents/route.js +0 -152
  48. package/symphony/app/api/events/route.js +0 -22
  49. package/symphony/app/api/knowledge/route.js +0 -253
  50. package/symphony/app/api/locks/route.js +0 -29
  51. package/symphony/app/api/notes/route.js +0 -125
  52. package/symphony/app/api/preflight/route.js +0 -23
  53. package/symphony/app/api/projects/route.js +0 -116
  54. package/symphony/app/api/roles/route.js +0 -134
  55. package/symphony/app/api/skills/route.js +0 -82
  56. package/symphony/app/api/status/route.js +0 -18
  57. package/symphony/app/api/tasks/route.js +0 -157
  58. package/symphony/app/api/workflows/route.js +0 -61
  59. package/symphony/app/api/workspaces/route.js +0 -15
  60. package/symphony/app/globals.css +0 -2605
  61. package/symphony/app/layout.js +0 -20
  62. package/symphony/app/page.js +0 -2122
  63. package/symphony/cli/index.js +0 -1060
  64. package/symphony/core/agent-manager.js +0 -357
  65. package/symphony/core/context-bus.js +0 -100
  66. package/symphony/core/db.js +0 -223
  67. package/symphony/core/file-lock-manager.js +0 -154
  68. package/symphony/core/merge-pipeline.js +0 -234
  69. package/symphony/core/orchestrator.js +0 -236
  70. package/symphony/core/task-manager.js +0 -335
  71. package/symphony/core/workspace-manager.js +0 -168
  72. package/symphony/jsconfig.json +0 -7
  73. package/symphony/lib/core.mjs +0 -1034
  74. package/symphony/mcp/index.js +0 -29
  75. package/symphony/mcp/server.js +0 -110
  76. package/symphony/mcp/tools/context.js +0 -80
  77. package/symphony/mcp/tools/locks.js +0 -99
  78. package/symphony/mcp/tools/status.js +0 -82
  79. package/symphony/mcp/tools/tasks.js +0 -216
  80. package/symphony/mcp/tools/workspace.js +0 -143
  81. package/symphony/next.config.mjs +0 -7
  82. package/symphony/package.json +0 -53
  83. package/symphony/scripts/postinstall.js +0 -49
  84. package/symphony/symphony.config.js +0 -41
@@ -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
-