Commit 01a94bb3 authored by Admin's avatar Admin

feat: Add Autonomous Company Org Chart UI and Plugin Sync

parent 483c835b
......@@ -180,46 +180,11 @@ async def get_plugins_registry(
if not membership:
raise HTTPException(status_code=403, detail="No access to company")
# Define standard plugins
STANDARD_PLUGINS = {
"trello": "Trello integration for project management",
"notion": "Notion integration for document syncing",
"linear": "Linear integration for engineering task tracking",
"clickup": "ClickUp integration for project management",
"github_projects": "GitHub Projects integration for task management",
"facebook_pages": "Facebook Page posting integration",
"nocobase_erp": "NocoBase ERP connection for structured data",
"slack": "Slack realtime notifications integration",
"langfuse": "Langfuse tracing and observability integration",
"google_drive": "Google Drive cloud storage integration",
"gemini_content": "Gemini API content generation",
"image_gen": "AI image generation for product photos"
}
# Retrieve existing plugins from DB
existing_plugins_stmt = select(Plugin)
existing_plugins_res = await db.execute(existing_plugins_stmt)
existing_plugins = {p.name: p for p in existing_plugins_res.scalars().all()}
# Auto-seed standard plugins if missing
db_changed = False
for slug, desc in STANDARD_PLUGINS.items():
if slug not in existing_plugins:
new_p = Plugin(
id=f"plugin_{slug}",
name=slug,
version="1.0.0",
description=desc,
enabled=True,
config_schema={}
)
db.add(new_p)
existing_plugins[slug] = new_p
db_changed = True
if db_changed:
await db.flush()
# Get active configs for this company
configs_stmt = select(PluginConfig).where(
PluginConfig.company_id == company_id,
......@@ -240,8 +205,5 @@ async def get_plugins_registry(
"description": p.description
})
if db_changed:
await db.commit()
return registry_list
......@@ -42,6 +42,9 @@ if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
logger.info("Static dir resolved: %s", STATIC_DIR)
# UI Prototype directory (ui-prototype/ at project root)
UI_PROTO_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "ui-prototype")
# =====================================================================
# LIFESPAN — Graceful startup & shutdown
......@@ -64,6 +67,16 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"❌ Failed to start MCP clients: {e}", exc_info=True)
# Sync Local Plugins
try:
from services.plugin_manager import PluginManager
from database import get_db_session
async with get_db_session() as session:
logger.info("📦 Syncing plugins from local marketplace...")
await PluginManager.sync_plugins(session)
except Exception as e:
logger.error(f"❌ Failed to sync plugins: {e}", exc_info=True)
# Start Heartbeat Scheduler Loop if enabled (fallback/primary scheduler when Celery Beat is not active)
if settings.heartbeat_scheduler_enabled:
async def scheduler_loop():
......@@ -344,6 +357,27 @@ async def serve_root_files_fallback(file_name: str):
return FileResponse(full_path)
return JSONResponse({"detail": "Not Found"}, status_code=404)
@app.get("/proto")
@app.get("/proto/")
async def serve_proto_index():
"""Serve the HTML/CSS/JS UI prototype."""
idx = os.path.join(UI_PROTO_DIR, "index.html")
if os.path.isfile(idx):
return FileResponse(idx)
return JSONResponse({"detail": "ui-prototype/index.html not found"}, status_code=404)
@app.get("/proto/{file_path:path}")
async def serve_proto_files(file_path: str):
"""Serve static assets for the UI prototype."""
full_path = os.path.realpath(os.path.join(UI_PROTO_DIR, file_path))
# Security: must stay within UI_PROTO_DIR
if not full_path.startswith(os.path.realpath(UI_PROTO_DIR)):
return JSONResponse({"detail": "Forbidden"}, status_code=403)
if os.path.isfile(full_path):
return FileResponse(full_path)
return JSONResponse({"detail": "Not Found"}, status_code=404)
@app.get("/home/{file_path:path}")
async def serve_home_static(file_path: str):
"""Serve home dashboard explicitly under /home namespace."""
......
"""
Plugin Manager Service
Scans the local knowledge-work-plugins directory to sync available plugins to the database.
"""
import os
import json
import logging
from pathlib import Path
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from models import Plugin
logger = logging.getLogger(__name__)
# Default path to the anthropic plugins repo
PLUGINS_DIR = Path(__file__).resolve().parent.parent.parent / "outsource" / "knowledge-work-plugins"
class PluginManager:
@staticmethod
async def sync_plugins(db: AsyncSession):
"""Scan the PLUGINS_DIR and upsert all plugins into the database."""
if not PLUGINS_DIR.exists():
logger.warning(f"Plugins directory not found at {PLUGINS_DIR}. Skipping sync.")
return
plugin_dirs = [d for d in PLUGINS_DIR.iterdir() if d.is_dir() and not d.name.startswith('.')]
synced_count = 0
for pdir in plugin_dirs:
manifest_path = pdir / ".claude-plugin" / "plugin.json"
if not manifest_path.exists():
continue
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
manifest = json.load(f)
name = manifest.get('name', pdir.name)
version = manifest.get('version', '1.0.0')
description = manifest.get('description', '')
# Check if exists
stmt = select(Plugin).where(Plugin.name == name)
result = await db.execute(stmt)
existing = result.scalar_one_or_none()
if existing:
existing.version = version
existing.description = description
else:
new_plugin = Plugin(
name=name,
version=version,
description=description,
enabled=True
)
db.add(new_plugin)
synced_count += 1
except Exception as e:
logger.error(f"Failed to sync plugin {pdir.name}: {e}")
await db.commit()
logger.info(f"Successfully synced {synced_count} plugins from local marketplace.")
@staticmethod
def get_plugin_skills(plugin_name: str) -> list[dict]:
"""Read all markdown files in the skills/ directory of a plugin."""
skills_dir = PLUGINS_DIR / plugin_name / "skills"
if not skills_dir.exists():
return []
skills = []
for file in skills_dir.glob("*.md"):
try:
with open(file, 'r', encoding='utf-8') as f:
content = f.read()
skills.append({
"name": file.stem,
"content": content
})
except Exception as e:
logger.error(f"Error reading skill {file.name}: {e}")
return skills
{
"template_name": "E-Commerce SME Base",
"version": "1.0",
"departments": {
"executive": {
"role": "CEO",
"plugin": "core-ceo-agent",
"permissions": ["goal_setting", "budget_allocation"]
},
"marketing": {
"role": "Marketing Manager",
"plugin": "marketing-agent",
"tools": ["facebook_ads", "hubspot_email"]
},
"legal": {
"role": "Legal Compliance",
"plugin": "legal-agent",
"tools": ["document_reviewer"]
}
},
"workflows": [
{
"name": "Content Approval Flow",
"trigger": {
"agent": "marketing",
"event": "content_draft_completed"
},
"action": {
"type": "create_task",
"assignee": "legal",
"task_name": "Review Marketing Content",
"context_payload": "$trigger.payload.content",
"instructions": "Kiểm tra xem nội dung này có vi phạm luật quảng cáo hay không. Nếu có, reject lại cho marketing."
}
},
{
"name": "Legal Review Result",
"trigger": {
"agent": "legal",
"event": "review_completed"
},
"conditions": [
{ "field": "$trigger.payload.status", "operator": "==", "value": "rejected" }
],
"action": {
"type": "create_task",
"assignee": "marketing",
"task_name": "Revise Content",
"context_payload": "$trigger.payload.feedback"
}
}
]
}
# PHÂN TÍCH NGHIỆP VỤ: CÁCH CÁC AGENT GIAO TIẾP & SỬ DỤNG TOOL
Sếp bảo "từ từ thảo luận nghiệp vụ" là quá chuẩn! Đọc cái file README của bộ `productivity` plugin mà sếp vừa gửi, tao nhận ra bọn Anthropic có một triết lý thiết kế **CỰC KỲ KHÁC BIỆT** so với cách code truyền thống.
Dưới đây là lời giải cho 2 câu hỏi của sếp:
## 1. Bọn nó có dùng Logic Hook / JSON Flow không?
**Câu trả lời là: KHÔNG!**
Bọn Anthropic không code cứng một cái Workflow JSON (như kiểu Zapier hay Make.com) để ép thằng Marketing làm xong thì phải chuyển cho thằng Legal. Vì làm thế thì rất gò bó và "bớt thông minh" đi.
**Cách bọn nó làm (Cơ chế Shared Workspace):**
- Bọn nó dùng **File Text (TASKS.md)** làm không gian làm việc chung.
- Sếp ra lệnh: *"Tháng này chạy chiến dịch Mùa Hè"*. Thằng CEO Agent tự động phân tích và viết vào file `TASKS.md`:
`- [ ] @marketing Viết bài content cho chiến dịch`
- **Daily Heartbeat (Cronjob):** Cứ 8h sáng, con Agent Marketing được đánh thức bằng lệnh `/update`. Nó nhìn vào file `TASKS.md`, thấy có task assign cho `@marketing`. Thế là nó đi làm.
- Làm xong, nó tick `[x]` vào task của nó, và tự động viết thêm 1 dòng mới:
`- [ ] @legal Check hộ bài content này xem có vi phạm luật không`.
- Đến lượt con Legal Agent thức dậy, nó lại thấy task của nó và tự đi làm.
👉 **Kết luận nghiệp vụ:** Thay vì mình code một cái Hook Engine bằng JSON rườm rà, mình chỉ cần xây dựng một **Hệ thống Task Management dùng chung (bảng Tasks)**. Các Agent giao tiếp với nhau bằng cách *Assign Task và @Tag tên nhau* giống hệt như con người dùng Trello/Jira!
---
## 2. Các phòng ban (Sub-agents) dùng nền tảng riêng hay tích hợp bên thứ 3?
Đọc phần `Connectors` của sếp gửi, đáp án là: **CHỈ TÍCH HỢP BÊN THỨ 3 (Dùng MCP).**
Canifa AI KHÔNG CẦN đi code lại một cái CRM cho thằng Sales, hay một cái phần mềm kế toán riêng. Điều đó quá tốn nguồn lực mà khách hàng lại không thích vì họ phải bỏ phần mềm cũ.
**Mô hình vận hành của Canifa AI Company sẽ là:**
- Nền tảng của mình cung cấp **Não bộ (Memory + Task Tracking + AI Engine)**.
- Các phòng ban sẽ "mọc tay mọc chân" bằng cách kết nối với Tool bên ngoài qua chuẩn **MCP (Model Context Protocol)**.
**Ví dụ thiết kế phòng ban cho SME:**
* **Phòng Sales (Agent Sales):** Cắm dây (MCP Connector) vào HubSpot/Salesforce đang có sẵn của công ty + Cắm dây Gmail (Microsoft 365). Thằng Agent tự đọc email khách, tự lên HubSpot update trạng thái.
* **Phòng Marketing (Agent Marketing):** Cắm dây vào Facebook Pages MCP + Notion MCP. Nó tự viết content trên Notion, sếp duyệt xong nó tự đẩy post lên Fanpage.
* **Phòng Hành chính (HR Agent):** Cắm dây vào Slack MCP + Google Calendar MCP. Ai xin nghỉ phép nó tự động check lịch sếp và báo lại qua Slack.
👉 **Kết luận nghiệp vụ:** Nền tảng của mình là một cái **Trạm trung chuyển (Hub)**. Sếp chỉ việc cài các "Plugin" là các đầu nối MCP. Con Agent nào được cấp quyền dùng Plugin nào, thì nó có sức mạnh của tool đó. Mình đứng trên vai những thằng khổng lồ (HubSpot, Slack, Microsoft) chứ không đi phát minh lại cái bánh xe!
This diff is collapsed.
......@@ -400,3 +400,161 @@ h1, h2, h3 { font-weight: 600; letter-spacing: -0.02em; }
justify-content: flex-end;
gap: 12px;
}
/* Org Chart UI */
.org-chart-container {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 0;
}
.org-level {
display: flex;
justify-content: center;
gap: 40px;
position: relative;
width: 100%;
}
.level-ceo {
margin-bottom: 20px;
}
.level-departments {
flex-wrap: wrap;
padding: 0 40px;
}
.org-node {
display: flex;
flex-direction: column;
align-items: center;
width: 280px;
}
.org-card {
width: 100%;
padding: 20px;
display: flex;
flex-direction: column;
gap: 12px;
transition: all 0.3s ease;
position: relative;
z-index: 2;
}
.ceo-card {
width: 320px;
border-color: var(--primary);
box-shadow: 0 0 20px var(--primary-glow);
}
.org-card-header {
display: flex;
align-items: center;
gap: 12px;
}
.org-avatar {
width: 48px;
height: 48px;
border-radius: 12px;
background: rgba(255,255,255,0.05);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: var(--primary);
}
.org-info {
flex-grow: 1;
}
.org-info h3 {
font-size: 16px;
margin-bottom: 4px;
}
.org-card-body p {
font-size: 13px;
color: var(--text-muted);
line-height: 1.4;
margin-bottom: 12px;
}
.mcp-connectors {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.mcp-badge {
font-size: 11px;
padding: 4px 8px;
background: rgba(255,255,255,0.05);
border: 1px solid var(--panel-border);
border-radius: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.org-actions {
display: flex;
gap: 8px;
margin-top: auto;
}
.btn-sm {
padding: 8px 16px;
font-size: 12px;
width: 100%;
justify-content: center;
}
/* Connectors */
.org-connector-vertical {
width: 2px;
height: 40px;
background: var(--panel-border);
}
.org-connector-horizontal {
width: calc(100% - 280px); /* rough estimate to connect children */
height: 2px;
background: var(--panel-border);
margin-bottom: -1px; /* overlap with vertical short */
}
.org-connector-vertical-short {
width: 2px;
height: 20px;
background: var(--panel-border);
}
/* Hire Card */
.hire-card {
border-style: dashed;
cursor: pointer;
align-items: center;
justify-content: center;
text-align: center;
min-height: 150px;
}
.hire-card:hover {
background: rgba(255,255,255,0.05);
border-color: var(--primary);
}
.hire-icon {
font-size: 32px;
color: var(--text-muted);
margin-bottom: 8px;
}
.hire-card:hover .hire-icon {
color: var(--primary);
}
This diff is collapsed.
This diff is collapsed.
// Main app entry — shared state + sidebar wiring + route init
import { registerRoute, initRouter, navigate } from "./router.js";
import { companiesPage } from "./pages/companies.js";
import { dashboardPage } from "./pages/dashboard.js";
import { agentsPage } from "./pages/agents.js";
import { issuesPage } from "./pages/issues.js";
import { pipelinePage } from "./pages/pipeline.js";
import { routinesPage } from "./pages/routines.js";
import { companiesApi } from "./api.js";
// ===== Shared state =====
let _selectedCompanyId = localStorage.getItem("selectedCompanyId") ?? null;
let _companies = [];
export function getSelectedCompanyId() { return _selectedCompanyId; }
export function setSelectedCompanyId(id) {
_selectedCompanyId = id;
localStorage.setItem("selectedCompanyId", id);
updateCompanySelect(id);
}
export function setCompanySelectOptions(companies) {
_companies = companies;
const sel = document.getElementById("company-select");
if (!sel) return;
sel.innerHTML = companies.length === 0
? `<option value="">-- Chưa có phòng ban --</option>`
: companies.map(c => `<option value="${c.id}" ${c.id === _selectedCompanyId ? "selected" : ""}>${c.name}</option>`).join("");
}
function updateCompanySelect(id) {
const sel = document.getElementById("company-select");
if (sel) sel.value = id ?? "";
}
// ===== Route registration =====
registerRoute("companies", companiesPage);
registerRoute("dashboard", dashboardPage);
registerRoute("agents", agentsPage);
registerRoute("issues", issuesPage);
registerRoute("pipeline", pipelinePage);
registerRoute("routines", routinesPage);
registerRoute("404", (_p, container) => {
container.innerHTML = `<div class="empty-state"><h3>404 — Không tìm thấy trang</h3><a href="#dashboard" style="color:hsl(var(--primary))">← Về Dashboard</a></div>`;
});
// ===== Breadcrumb update on navigate =====
const PAGE_LABELS = {
companies: ["Phòng ban"],
dashboard: ["Bảng điều khiển"],
agents: ["Agents"],
issues: ["Issues"],
pipeline: ["Pipeline Runs"],
routines: ["Routines"],
};
window.addEventListener("hashchange", () => {
const route = window.location.hash.replace("#", "").split("/")[0] || "dashboard";
const labels = PAGE_LABELS[route] ?? [route];
const bc = document.getElementById("breadcrumbs");
if (bc) {
bc.innerHTML = labels.map((l, i) =>
i === labels.length - 1
? `<span class="breadcrumb-item active">${l}</span>`
: `<span class="breadcrumb-item">${l}</span><span class="breadcrumb-sep"> / </span>`
).join("");
}
});
// ===== Company switcher =====
document.getElementById("company-select")?.addEventListener("change", (e) => {
setSelectedCompanyId(e.target.value);
const route = window.location.hash.replace("#", "").split("/")[0] || "dashboard";
// Re-render current page
window.dispatchEvent(new HashChangeEvent("hashchange"));
});
// ===== Load companies for switcher on boot =====
companiesApi.list().then(companies => {
setCompanySelectOptions(companies);
_companies = companies;
// Auto-select first if saved ID gone
if (_selectedCompanyId && !companies.find(c => c.id === _selectedCompanyId)) {
if (companies.length) setSelectedCompanyId(companies[0].id);
else _selectedCompanyId = null;
}
if (!_selectedCompanyId && companies.length) {
setSelectedCompanyId(companies[0].id);
}
updateCompanySelect(_selectedCompanyId);
}).catch(() => {});
// ===== Start router =====
initRouter();
import { agentsApi } from "../api.js";
import { relativeTime, statusBadge, statusDot, escHtml, toast, confirmDialog } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function agentsPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const agents = await agentsApi.list(cid);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Agents (${agents.length})</h1>
<button class="btn btn-primary btn-sm" id="btn-new-agent">+ Thêm Agent</button>
</div>
<!-- Filter tabs -->
<div class="flex gap-1" style="border-bottom:1px solid hsl(var(--border));padding-bottom:0">
${["all","active","paused","error"].map(t => `
<button class="btn btn-ghost btn-sm tab-filter" data-tab="${t}" style="border-radius:6px 6px 0 0;padding-bottom:8px">
${t === "all" ? "Tất cả" : t === "active" ? "Hoạt động" : t === "paused" ? "Tạm dừng" : "Lỗi"}
</button>`).join("")}
</div>
<div id="agents-table" class="card"></div>
</div>`;
let currentTab = "all";
const render = () => renderTable(container.querySelector("#agents-table"), agents, currentTab, cid);
render();
container.querySelectorAll(".tab-filter").forEach(btn => {
btn.onclick = () => { currentTab = btn.dataset.tab; render(); };
});
container.querySelector("#btn-new-agent").onclick = () => showNewAgentModal(cid, agents);
}
function renderTable(el, agents, tab, cid) {
const filtered = agents.filter(a => {
if (a.status === "terminated") return false;
if (tab === "all") return true;
if (tab === "active") return ["active","running","idle"].includes(a.status);
return a.status === tab;
});
if (!filtered.length) {
el.innerHTML = `<div class="empty-state" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>
<h3>Không có agent nào</h3><p>Thêm agent mới để bắt đầu.</p>
</div>`;
return;
}
el.innerHTML = `<div class="table-wrap"><table>
<thead><tr>
<th>Tên</th><th>Vai trò</th><th>Trạng thái</th><th>Model</th><th>Cập nhật</th><th></th>
</tr></thead>
<tbody>
${filtered.map(a => `<tr>
<td>
<div class="flex items-center gap-2">
${statusDot(a.status)}
<span class="font-semibold">${escHtml(a.name)}</span>
</div>
</td>
<td class="text-muted text-xs">${escHtml(a.role ?? "—")}</td>
<td>${statusBadge(a.status)}</td>
<td class="text-xs font-mono text-muted">${escHtml(a.adapterConfig?.model ?? "—")}</td>
<td class="text-xs text-muted">${relativeTime(a.updatedAt)}</td>
<td>
<div class="flex gap-1">
${a.status === "paused"
? `<button class="btn btn-outline btn-sm" data-action="resume" data-id="${a.id}">▶ Resume</button>`
: `<button class="btn btn-outline btn-sm" data-action="pause" data-id="${a.id}">⏸ Pause</button>`}
<button class="btn btn-outline btn-sm" data-action="wakeup" data-id="${a.id}">⚡ Wake</button>
<button class="btn btn-ghost btn-sm" data-action="delete" data-id="${a.id}" title="Xoá">🗑</button>
</div>
</td>
</tr>`).join("")}
</tbody>
</table></div>`;
el.querySelectorAll("[data-action]").forEach(btn => {
btn.onclick = async () => {
const { action, id } = btn.dataset;
try {
if (action === "pause") { await agentsApi.pause(id); toast("Đã tạm dừng agent"); }
else if (action === "resume") { await agentsApi.resume(id); toast("Đã tiếp tục agent"); }
else if (action === "wakeup") { await agentsApi.wakeup(id, { source: "on_demand", triggerDetail: "manual" }); toast("Đã gửi wake-up"); }
else if (action === "delete") {
const ok = await confirmDialog("Xoá agent này?");
if (!ok) return;
await agentsApi.remove(id);
toast("Đã xoá agent");
}
// Refresh
const newAgents = await agentsApi.list(cid);
renderTable(el, newAgents, "all", cid);
} catch (err) { toast(err.message, "error"); }
};
});
}
function showNewAgentModal(cid, agents) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Thêm Agent mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên Agent *</label><input class="input" id="a-name" placeholder="CEO, Copywriter..."></div>
<div class="form-group"><label class="form-label">Vai trò</label><input class="input" id="a-role" placeholder="ceo, copywriter, developer..."></div>
<div class="form-group"><label class="form-label">Hệ thống prompt</label><textarea class="input" id="a-prompt" rows="3" placeholder="Bạn là..."></textarea></div>
<div class="form-group">
<label class="form-label">Báo cáo cho (Reports to)</label>
<select class="input" id="a-reports">
<option value="">-- Không --</option>
${agents.map(a => `<option value="${a.id}">${escHtml(a.name)}</option>`).join("")}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo Agent</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#a-name").value.trim();
if (!name) return toast("Cần nhập tên agent", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await agentsApi.create(cid, {
name,
role: backdrop.querySelector("#a-role").value || undefined,
promptTemplate: backdrop.querySelector("#a-prompt").value || undefined,
reportsTo: backdrop.querySelector("#a-reports").value || undefined,
});
toast("Đã tạo agent");
close();
location.hash = "agents";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state">
<h3>Chưa chọn phòng ban</h3>
<p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p>
</div>`;
}
import { companiesApi, blueprintsApi } from "../api.js";
import { formatCents, relativeTime, statusBadge, escHtml, toast, confirmDialog } from "../utils.js";
import { navigate } from "../router.js";
import { getSelectedCompanyId, setSelectedCompanyId, setCompanySelectOptions } from "../app.js";
export async function companiesPage(_params, container) {
const [companies, stats, blueprints] = await Promise.all([
companiesApi.list(),
companiesApi.stats().catch(() => ({})),
blueprintsApi.list().catch(() => []),
]);
setCompanySelectOptions(companies);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Phòng ban (${companies.length})</h1>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm" id="btn-create-manual">+ Tạo thủ công</button>
<button class="btn btn-primary btn-sm" id="btn-deploy-blueprint">+ Deploy Blueprint</button>
</div>
</div>
<div id="companies-list" class="space-y-4"></div>
</div>`;
renderList(container.querySelector("#companies-list"), companies, stats);
container.querySelector("#btn-create-manual").onclick = () => showCreateModal(container, blueprints, companies);
container.querySelector("#btn-deploy-blueprint").onclick = () => showDeployModal(container, blueprints, companies);
}
function renderList(listEl, companies, stats) {
if (!companies.length) {
listEl.innerHTML = `<div class="empty-state card">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<h3>Chưa có phòng ban nào</h3>
<p>Tạo phòng ban hoặc deploy từ blueprint để bắt đầu.</p>
</div>`;
return;
}
const selectedId = getSelectedCompanyId();
listEl.innerHTML = companies.map((c) => {
const s = stats[c.id] ?? {};
const agentCount = s.agentCount ?? 0;
const issueCount = s.issueCount ?? 0;
const budgetPct = c.budgetMonthlyCents > 0
? Math.round((c.spentMonthlyCents / c.budgetMonthlyCents) * 100) : 0;
const selected = c.id === selectedId;
return `<div class="card card-hover p-5" data-id="${c.id}" style="cursor:pointer;${selected ? "border-color:hsl(var(--primary));box-shadow:0 0 0 1px hsl(var(--primary))" : ""}">
<div class="flex items-center justify-between gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold" style="font-size:15px">${escHtml(c.name)}</h3>
${statusBadge(c.status)}
${c.blueprint_name ? `<span class="badge badge-blue">${escHtml(c.blueprint_name)}</span>` : ""}
${selected ? `<span class="badge badge-active" style="font-size:9px">✓ Đang chọn</span>` : ""}
</div>
${c.description ? `<p class="text-xs text-muted mt-1 line-clamp-2">${escHtml(c.description)}</p>` : ""}
</div>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm btn-select" data-id="${c.id}">Chọn</button>
<button class="btn btn-ghost btn-sm btn-delete" data-id="${c.id}" title="Xoá">🗑</button>
</div>
</div>
<div class="flex gap-4 mt-3 text-xs text-muted flex-wrap">
<span>👥 ${agentCount} agent</span>
<span>🔵 ${issueCount} issue</span>
<span>💵 ${formatCents(c.spentMonthlyCents)}${c.budgetMonthlyCents > 0 ? ` / ${formatCents(c.budgetMonthlyCents)} (${budgetPct}%)` : " (Không giới hạn)"}</span>
<span class="ml-auto">Tạo ${relativeTime(c.createdAt)}</span>
</div>
</div>`;
}).join("");
listEl.querySelectorAll(".btn-select").forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
setSelectedCompanyId(btn.dataset.id);
navigate("dashboard");
toast("Đã chọn phòng ban");
};
});
listEl.querySelectorAll("[data-id]").forEach((el) => {
if (!el.classList.contains("btn")) {
el.onclick = () => { setSelectedCompanyId(el.dataset.id); navigate("dashboard"); };
}
});
listEl.querySelectorAll(".btn-delete").forEach((btn) => {
btn.onclick = async (e) => {
e.stopPropagation();
const ok = await confirmDialog("Xoá phòng ban này? Không thể hoàn tác.");
if (!ok) return;
try {
await companiesApi.remove(btn.dataset.id);
toast("Đã xoá phòng ban");
navigate("companies");
} catch (err) { toast(err.message, "error"); }
};
});
}
function showCreateModal(container, blueprints, companies) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo phòng ban</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên *</label><input class="input" id="c-name" placeholder="AI Marketing Agency"></div>
<div class="form-group"><label class="form-label">Mô tả</label><input class="input" id="c-desc" placeholder="Mô tả ngắn..."></div>
<div class="form-group"><label class="form-label">Ngân sách/tháng (USD, 0 = không giới hạn)</label><input class="input" id="c-budget" type="number" min="0" value="0" placeholder="0"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#c-name").value.trim();
if (!name) return toast("Cần nhập tên", "error");
const budget = parseInt(backdrop.querySelector("#c-budget").value) || 0;
const desc = backdrop.querySelector("#c-desc").value.trim();
try {
await companiesApi.create({ name, description: desc || null, budgetMonthlyCents: budget * 100 });
toast("Đã tạo phòng ban");
close();
navigate("companies");
} catch (err) { toast(err.message, "error"); }
};
}
function showDeployModal(container, blueprints, companies) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Deploy từ Blueprint</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Blueprint *</label>
<select class="input" id="bp-select">
<option value="">-- Chọn blueprint --</option>
${blueprints.map(b => `<option value="${escHtml(b.name)}">${escHtml(b.name)}</option>`).join("")}
</select>
</div>
<div class="form-group"><label class="form-label">Tên công ty *</label><input class="input" id="bp-name" placeholder="Tên của công ty mới"></div>
<div class="form-group"><label class="form-label">Model AI</label><input class="input" id="bp-model" value="claude-3-5-haiku-20241022"></div>
<div class="form-group"><label class="form-label">OpenAI-compat API Key</label><input class="input" id="bp-key" type="password" placeholder="sk-..."></div>
<div class="form-group"><label class="form-label">Base URL (tuỳ chọn)</label><input class="input" id="bp-url" placeholder="https://api.anthropic.com/v1"></div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Deploy</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const blueprint_name = backdrop.querySelector("#bp-select").value;
const company_name = backdrop.querySelector("#bp-name").value.trim();
if (!blueprint_name || !company_name) return toast("Cần chọn blueprint và nhập tên", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true; btn.textContent = "Đang deploy...";
try {
const result = await blueprintsApi.deploy({
blueprint_name, company_name,
model_name: backdrop.querySelector("#bp-model").value || undefined,
openai_api_key: backdrop.querySelector("#bp-key").value || undefined,
base_url: backdrop.querySelector("#bp-url").value || undefined,
});
toast(`Deploy thành công: ${result.company?.name ?? company_name}`);
if (result.company?.id) setSelectedCompanyId(result.company.id);
close();
navigate("companies");
} catch (err) { toast(err.message, "error"); btn.disabled = false; btn.textContent = "Deploy"; }
};
}
import { dashboardApi, issuesApi, agentsApi } from "../api.js";
import { formatCents, relativeTime, statusBadge, statusDot, escHtml, toast } from "../utils.js";
import { navigate } from "../router.js";
import { getSelectedCompanyId } from "../app.js";
export async function dashboardPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) {
container.innerHTML = `<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>
<h3>Chọn phòng ban</h3>
<p>Vào mục <a href="#companies" style="color:hsl(var(--primary))">Phòng ban</a> để chọn công ty.</p>
</div>`;
return;
}
const [summary, agents, issues] = await Promise.all([
dashboardApi.summary(cid).catch(() => ({})),
agentsApi.list(cid).catch(() => []),
issuesApi.list(cid, { limit: "10" }).catch(() => []),
]);
const activeAgents = agents.filter(a => a.status === "active" || a.status === "running" || a.status === "idle");
const openIssues = issues.filter(i => i.status !== "done" && i.status !== "cancelled");
container.innerHTML = `
<div class="space-y-6">
<!-- Metrics -->
<div class="metric-grid">
${metricCard("🤖", "Agents", activeAgents.length, `${agents.length} tổng`, "agents")}
${metricCard("🔵", "Issues đang mở", openIssues.length, `${issues.length} tổng`, "issues")}
${metricCard("💵", "Chi phí tháng này", formatCents(summary.totalSpentCents ?? 0), summary.budgetMonthlyCents > 0 ? `/ ${formatCents(summary.budgetMonthlyCents)}` : "Không giới hạn", null)}
${metricCard("📋", "Pipeline chạy", summary.pipelineRunCount ?? "—", "tháng này", "pipeline")}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">
<!-- Active agents -->
<div class="card">
<div class="border-b p-4 flex items-center justify-between">
<h2 style="font-size:13px;font-weight:600">Agents đang hoạt động</h2>
<button class="btn btn-ghost btn-sm" onclick="location.hash='agents'">Xem tất cả →</button>
</div>
<div id="active-agents-list"></div>
</div>
<!-- Recent issues -->
<div class="card">
<div class="border-b p-4 flex items-center justify-between">
<h2 style="font-size:13px;font-weight:600">Issues gần đây</h2>
<button class="btn btn-ghost btn-sm" onclick="location.hash='issues'">Xem tất cả →</button>
</div>
<div id="recent-issues-list"></div>
</div>
</div>
</div>`;
// Active agents
const agentsList = container.querySelector("#active-agents-list");
if (!activeAgents.length) {
agentsList.innerHTML = `<div class="empty-state" style="padding:32px"><p>Chưa có agent nào hoạt động</p></div>`;
} else {
agentsList.innerHTML = activeAgents.slice(0, 6).map(a => `
<div class="flex items-center gap-3 p-3 border-b" style="border-color:hsl(var(--border)/0.5)">
${statusDot(a.status)}
<div class="flex-1 min-w-0">
<p class="font-semibold text-sm truncate">${escHtml(a.name)}</p>
<p class="text-xs text-muted">${escHtml(a.role ?? "—")}</p>
</div>
${statusBadge(a.status)}
</div>`).join("");
}
// Recent issues
const issuesList = container.querySelector("#recent-issues-list");
if (!issues.length) {
issuesList.innerHTML = `<div class="empty-state" style="padding:32px"><p>Chưa có issue nào</p></div>`;
} else {
issuesList.innerHTML = issues.slice(0, 8).map(i => `
<div class="flex items-center gap-3 p-3 border-b" style="border-color:hsl(var(--border)/0.5);cursor:pointer" onclick="location.hash='issues'">
<div class="flex-1 min-w-0">
<p class="text-sm truncate">${escHtml(i.title)}</p>
<p class="text-xs text-muted">${relativeTime(i.updatedAt)}</p>
</div>
${statusBadge(i.status)}
</div>`).join("");
}
}
function metricCard(icon, label, value, sub, route) {
const click = route ? `onclick="location.hash='${route}'" style="cursor:pointer"` : "";
return `<div class="card metric-card" ${click}>
<div class="metric-label">${icon} ${escHtml(label)}</div>
<div class="metric-value">${escHtml(String(value))}</div>
<div class="text-xs text-muted mt-1">${escHtml(String(sub))}</div>
</div>`;
}
import { issuesApi, agentsApi } from "../api.js";
import { relativeTime, statusBadge, escHtml, toast } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function issuesPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const [issues, agents] = await Promise.all([
issuesApi.list(cid, { limit: "200" }),
agentsApi.list(cid).catch(() => []),
]);
const agentMap = Object.fromEntries(agents.map(a => [a.id, a]));
container.innerHTML = `
<div class="space-y-4">
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="font-semibold" style="font-size:16px">Issues (${issues.length})</h1>
<div class="flex gap-2 items-center">
<input class="input" id="issue-search" placeholder="🔍 Tìm kiếm..." style="width:220px">
<select class="input" id="status-filter" style="width:140px">
<option value="">Tất cả trạng thái</option>
<option value="todo">Cần làm</option>
<option value="in_progress">Đang làm</option>
<option value="done">Xong</option>
<option value="cancelled">Huỷ</option>
</select>
<button class="btn btn-primary btn-sm" id="btn-new-issue">+ Tạo Issue</button>
</div>
</div>
<div id="issues-table" class="card"></div>
</div>`;
let search = "";
let statusFilter = "";
const render = () => {
const filtered = issues.filter(i => {
const matchSearch = !search || i.title.toLowerCase().includes(search.toLowerCase());
const matchStatus = !statusFilter || i.status === statusFilter;
return matchSearch && matchStatus;
});
renderTable(container.querySelector("#issues-table"), filtered, agentMap);
};
render();
container.querySelector("#issue-search").oninput = (e) => { search = e.target.value; render(); };
container.querySelector("#status-filter").onchange = (e) => { statusFilter = e.target.value; render(); };
container.querySelector("#btn-new-issue").onclick = () => showNewIssueModal(cid, agents, issues, render);
}
function renderTable(el, issues, agentMap) {
if (!issues.length) {
el.innerHTML = `<div class="empty-state" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
<h3>Không có issue nào</h3><p>Tạo issue để agent bắt đầu làm việc.</p>
</div>`;
return;
}
el.innerHTML = `<div class="table-wrap"><table>
<thead><tr>
<th style="width:80px">ID</th><th>Tiêu đề</th><th>Trạng thái</th><th>Agent</th><th>Cập nhật</th>
</tr></thead>
<tbody>
${issues.map(i => {
const agent = i.assigneeAgentId ? agentMap[i.assigneeAgentId] : null;
return `<tr style="cursor:pointer">
<td class="font-mono text-xs text-muted">${escHtml(i.identifier ?? i.id.slice(0, 8))}</td>
<td>
<p class="font-semibold text-sm truncate" style="max-width:400px">${escHtml(i.title)}</p>
${i.description ? `<p class="text-xs text-muted line-clamp-2" style="max-width:400px">${escHtml(i.description)}</p>` : ""}
</td>
<td>${statusBadge(i.status)}</td>
<td class="text-xs text-muted">${agent ? escHtml(agent.name) : "—"}</td>
<td class="text-xs text-muted">${relativeTime(i.updatedAt)}</td>
</tr>`;
}).join("")}
</tbody>
</table></div>`;
}
function showNewIssueModal(cid, agents, issues, onSuccess) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo Issue mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tiêu đề *</label><input class="input" id="i-title" placeholder="Làm gì đó..."></div>
<div class="form-group"><label class="form-label">Mô tả</label><textarea class="input" id="i-desc" rows="3" placeholder="Chi tiết..."></textarea></div>
<div class="form-group">
<label class="form-label">Giao cho Agent</label>
<select class="input" id="i-agent">
<option value="">-- Chưa giao --</option>
${agents.map(a => `<option value="${a.id}">${escHtml(a.name)}</option>`).join("")}
</select>
</div>
<div class="form-group">
<label class="form-label">Issue cha</label>
<select class="input" id="i-parent">
<option value="">-- Không --</option>
${issues.slice(0, 50).map(i => `<option value="${i.id}">${escHtml(i.identifier ?? i.id.slice(0,8))} — ${escHtml(i.title.slice(0,40))}</option>`).join("")}
</select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const title = backdrop.querySelector("#i-title").value.trim();
if (!title) return toast("Cần nhập tiêu đề", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await issuesApi.create(cid, {
title,
description: backdrop.querySelector("#i-desc").value || undefined,
assigneeAgentId: backdrop.querySelector("#i-agent").value || undefined,
parentId: backdrop.querySelector("#i-parent").value || undefined,
});
toast("Đã tạo issue");
close();
location.hash = "issues";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state"><h3>Chưa chọn phòng ban</h3><p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p></div>`;
}
This diff is collapsed.
import { routinesApi } from "../api.js";
import { relativeTime, escHtml, toast, confirmDialog } from "../utils.js";
import { getSelectedCompanyId } from "../app.js";
export async function routinesPage(_params, container) {
const cid = getSelectedCompanyId();
if (!cid) { container.innerHTML = noCompany(); return; }
const routines = await routinesApi.list(cid).catch(() => []);
container.innerHTML = `
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="font-semibold" style="font-size:16px">Routines / Auto-Schedule (${routines.length})</h1>
<button class="btn btn-primary btn-sm" id="btn-new-routine">+ Tạo Routine</button>
</div>
<div id="routines-list" class="space-y-4"></div>
</div>`;
renderRoutines(container.querySelector("#routines-list"), routines, cid);
container.querySelector("#btn-new-routine").onclick = () => showCreateModal(cid, routines);
}
function renderRoutines(el, routines, cid) {
if (!routines.length) {
el.innerHTML = `<div class="empty-state card" style="padding:48px">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<h3>Chưa có routine nào</h3>
<p>Tạo routine để pipeline tự chạy theo lịch.</p>
</div>`;
return;
}
el.innerHTML = routines.map(r => `
<div class="card p-5" data-id="${r.id}">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold">${escHtml(r.name ?? r.id.slice(0,8))}</h3>
<span class="badge ${r.isEnabled ? "badge-active" : "badge-default"}">${r.isEnabled ? "✓ Bật" : "Tắt"}</span>
${r.cron ? `<span class="badge badge-blue font-mono">${escHtml(r.cron)}</span>` : ""}
</div>
${r.query ? `<p class="text-xs text-muted mt-1 line-clamp-2">${escHtml(r.query)}</p>` : ""}
<div class="flex gap-4 mt-2 text-xs text-muted flex-wrap">
${r.lastRunAt ? `<span>Chạy lần cuối: ${relativeTime(r.lastRunAt)}</span>` : "<span>Chưa chạy lần nào</span>"}
${r.nextRunAt ? `<span>Lần tiếp: ${relativeTime(r.nextRunAt)}</span>` : ""}
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-outline btn-sm btn-run-now" data-id="${r.id}">▶ Chạy ngay</button>
<button class="btn btn-outline btn-sm btn-toggle" data-id="${r.id}" data-enabled="${r.isEnabled}">${r.isEnabled ? "⏸ Tắt" : "▶ Bật"}</button>
</div>
</div>
</div>`).join("");
el.querySelectorAll(".btn-run-now").forEach(btn => {
btn.onclick = async () => {
btn.disabled = true;
try {
await routinesApi.run(btn.dataset.id);
toast("Đã kích hoạt routine!");
} catch (err) { toast(err.message, "error"); }
btn.disabled = false;
};
});
el.querySelectorAll(".btn-toggle").forEach(btn => {
btn.onclick = async () => {
const enable = btn.dataset.enabled === "true" ? false : true;
try {
await routinesApi.update(btn.dataset.id, { isEnabled: enable });
toast(enable ? "Đã bật routine" : "Đã tắt routine");
location.hash = "routines";
} catch (err) { toast(err.message, "error"); }
};
});
}
function showCreateModal(cid, routines) {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<div class="modal-header flex items-center justify-between">
<h2 style="font-size:15px;font-weight:600">Tạo Routine mới</h2>
<button class="btn btn-ghost btn-icon" id="modal-close">✕</button>
</div>
<div class="modal-body">
<div class="form-group"><label class="form-label">Tên *</label><input class="input" id="r-name" placeholder="Weekly content"></div>
<div class="form-group">
<label class="form-label">Cron Expression *</label>
<input class="input" id="r-cron" placeholder="0 9 * * 1 (thứ 2 hàng tuần lúc 9am)">
<p class="form-hint">Format: phút giờ ngày tháng ngày-trong-tuần. Ví dụ: <code>0 9 * * 1</code> = thứ 2 lúc 9am</p>
</div>
<div class="form-group"><label class="form-label">Timezone</label><input class="input" id="r-tz" value="Asia/Ho_Chi_Minh"></div>
<div class="form-group"><label class="form-label">Project Brief (nội dung task)</label>
<textarea class="input" id="r-query" rows="3" placeholder="Viết 1 bài blog về xu hướng AI tuần này..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" id="modal-cancel">Huỷ</button>
<button class="btn btn-primary" id="modal-submit">Tạo</button>
</div>
</div>`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.querySelector("#modal-close").onclick = close;
backdrop.querySelector("#modal-cancel").onclick = close;
backdrop.querySelector("#modal-submit").onclick = async () => {
const name = backdrop.querySelector("#r-name").value.trim();
const cron = backdrop.querySelector("#r-cron").value.trim();
if (!name || !cron) return toast("Cần nhập tên và cron", "error");
const btn = backdrop.querySelector("#modal-submit");
btn.disabled = true;
try {
await routinesApi.create(cid, {
name,
cron,
timezone: backdrop.querySelector("#r-tz").value || "UTC",
query: backdrop.querySelector("#r-query").value || undefined,
isEnabled: true,
});
toast("Đã tạo routine");
close();
location.hash = "routines";
} catch (err) { toast(err.message, "error"); btn.disabled = false; }
};
}
function noCompany() {
return `<div class="empty-state"><h3>Chưa chọn phòng ban</h3><p><a href="#companies" style="color:hsl(var(--primary))">Chọn phòng ban</a> trước.</p></div>`;
}
// Hash-based SPA router
const routes = new Map();
let currentRoute = null;
export function registerRoute(hash, handler) {
routes.set(hash, handler);
}
export function navigate(hash) {
window.location.hash = hash;
}
export function getCurrentRoute() { return currentRoute; }
async function handleRoute() {
const hash = window.location.hash.replace("#", "") || "companies";
const [routeName, ...params] = hash.split("/");
const handler = routes.get(routeName) ?? routes.get("404");
if (!handler) return;
currentRoute = routeName;
// Update active nav item
document.querySelectorAll(".nav-item").forEach((el) => {
el.classList.toggle("active", el.dataset.route === routeName);
});
// Render into #content
const content = document.getElementById("content");
content.innerHTML = `<div class="flex items-center gap-3" style="padding:40px;color:hsl(240 5% 64.9%)">
<div class="spinner"></div> Đang tải...
</div>`;
try {
await handler(params, content);
} catch (err) {
content.innerHTML = `<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
<h3>Lỗi tải trang</h3>
<p>${err.message}</p>
</div>`;
console.error(err);
}
}
export function initRouter() {
window.addEventListener("hashchange", handleRoute);
handleRoute();
}
// Utility helpers — mirrors frontend/src/lib/utils.ts + timeAgo.ts
export function formatCents(cents) {
if (!cents && cents !== 0) return "$0.00";
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
}
export function relativeTime(dateStr) {
if (!dateStr) return "—";
const d = new Date(dateStr);
const now = Date.now();
const diff = now - d.getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s trước`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}ph trước`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h trước`;
const day = Math.floor(h / 24);
if (day < 30) return `${day}n trước`;
return d.toLocaleDateString("vi-VN");
}
export function timeAgo(dateStr) { return relativeTime(dateStr); }
export function truncate(str, n = 60) {
if (!str) return "";
return str.length > n ? str.slice(0, n) + "…" : str;
}
export function escHtml(str) {
if (!str) return "";
return String(str)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
export function statusBadge(status) {
const cls = {
active: "badge-active", running: "badge-running", idle: "badge-idle",
paused: "badge-paused", error: "badge-error", terminated: "badge-default",
done: "badge-done", in_progress: "badge-in_progress", todo: "badge-todo",
cancelled: "badge-cancelled", completed: "badge-success", failed: "badge-failed",
}[status] ?? "badge-default";
const label = {
active: "Hoạt động", running: "Đang chạy", idle: "Rảnh", paused: "Tạm dừng",
error: "Lỗi", terminated: "Đã kết thúc", done: "Xong", in_progress: "Đang làm",
todo: "Cần làm", cancelled: "Huỷ", completed: "Hoàn thành", failed: "Thất bại",
}[status] ?? status;
return `<span class="badge ${cls}">${escHtml(label)}</span>`;
}
export function statusDot(status) {
const cls = { active: "dot-active", running: "dot-running pulse", error: "dot-error",
paused: "dot-paused", idle: "dot-idle", terminated: "dot-terminated" }[status] ?? "dot-default";
return `<span class="dot ${cls}"></span>`;
}
// Toast notification
export function toast(msg, type = "success") {
const container = document.getElementById("toast-container") ?? createToastContainer();
const el = document.createElement("div");
el.className = `toast toast-${type}`;
el.textContent = msg;
container.appendChild(el);
setTimeout(() => el.remove(), 3500);
}
function createToastContainer() {
const el = document.createElement("div");
el.id = "toast-container";
document.body.appendChild(el);
return el;
}
// Generic confirm modal
export function confirmDialog(msg) {
return new Promise((resolve) => {
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal" style="max-width:380px">
<div class="modal-body" style="padding:24px">
<p style="font-size:14px;margin-bottom:16px">${escHtml(msg)}</p>
<div class="flex gap-2" style="justify-content:flex-end">
<button class="btn btn-outline" id="conf-cancel">Huỷ</button>
<button class="btn btn-destructive" id="conf-ok">Xác nhận</button>
</div>
</div>
</div>`;
document.body.appendChild(backdrop);
backdrop.querySelector("#conf-cancel").onclick = () => { backdrop.remove(); resolve(false); };
backdrop.querySelector("#conf-ok").onclick = () => { backdrop.remove(); resolve(true); };
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) { backdrop.remove(); resolve(false); } });
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment