refactor(01-02): decompose execution.tsx and automation.tsx into focused sub-components
- execution.tsx (1749 lines) -> 16 focused files under 300 lines each: TaskQueue, TaskInspector, TaskCreator, TaskComments, ApprovalList, ApprovalInspector, ActivityList, CostList, BudgetSummary, WorkspaceList, WorkspaceInspector, WorkspaceCreator, SecretCreator, SettingsPane, PendingReviewList, execution-utils - automation.tsx (1697 lines) -> 6 focused files under 300 lines each: AutomationPanel, RuleCard, RuleEditor, WorkflowList, WorkflowEditor, automation-config - Both original files replaced with backward-compatible re-exports - All sub-components under 300 lines, TypeScript compilation passes
This commit is contained in:
1 parent
586593f0e4
commit
7ff8dc70a2
24 files changed
+1075
-3446
No files matched your search
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowRight, ArrowsClockwise, Lightning, Plus, Robot, Trash, X } from "@phosphor-icons/react"
|
||||
import type { AutomationAction, AutomationRuleRecord, AutomationStatus, AutomationTrigger, WorkflowPipelineRecord } from "@shared/types"
|
||||
import { ActionButton, Select } from "../ui"
|
||||
import { useT } from "../../i18n"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, statusColors, type AutomationLogEntry } from "./automation-config"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { RuleCard } from "./RuleCard"
|
||||
import { RuleFormModal } from "./RuleEditor"
|
||||
import { WorkflowCard } from "./WorkflowList"
|
||||
import { WorkflowFormModal } from "./WorkflowEditor"
|
||||
|
||||
function DeleteConfirmDialog({ open, entityName, onConfirm, onCancel }: { open: boolean; entityName: string; onConfirm: () => void; onCancel: () => void }) {
|
||||
if (!open) return null
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onCancel}>
|
||||
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.15 }} className="w-full max-w-[380px] rounded-[14px] border bg-[color:var(--panel)] p-6 shadow-xl" style={{ borderColor: LINE }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mb-1 flex items-center gap-2"><div className="flex h-8 w-8 items-center justify-center rounded-full bg-[color:var(--danger-soft)]"><Trash size={16} className="text-[color:var(--danger)]" /></div><h3 className="text-[15px] font-bold text-[color:var(--text)]">Delete</h3></div>
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-[color:var(--muted)]">Are you sure you want to delete <strong className="text-[color:var(--text)]">{entityName}</strong>? This action cannot be undone.</p>
|
||||
<div className="mt-5 flex justify-end gap-3"><button type="button" onClick={onCancel} className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)]">Cancel</button><ActionButton label="Delete" onClick={onConfirm} icon={Trash} tone="danger" /></div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function AutomationActivityLog({ entries }: { entries: AutomationLogEntry[] }) {
|
||||
if (entries.length === 0) return <div className="py-6 text-center text-[13px] text-[color:var(--muted)]">No automation activity yet. Rules will log executions here when they fire.</div>
|
||||
return (
|
||||
<div className="space-y-1">{entries.map((entry, idx) => {
|
||||
const ti = triggerConfig[entry.trigger as AutomationTrigger]; const ai = actionConfig[entry.action as AutomationAction]; const TIcon = ti?.icon ?? Lightning
|
||||
return <motion.div key={`${entry.ruleId}-${entry.executedAt}-${idx}`} initial={{ opacity: 0, x: -6 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: idx * 0.03 }} className="flex items-center gap-3 rounded-[8px] border bg-[color:var(--panel)] px-4 py-2.5 text-[12px]" style={{ borderColor: LINE }}><TIcon size={14} style={{ color: ACCENT }} /><div className="min-w-0 flex-1"><div className="flex items-center gap-2"><span className="truncate font-medium text-[color:var(--text)]">{entry.ruleName}</span></div><div className="mt-0.5 flex items-center gap-1.5 text-[10px] text-[color:var(--muted)]"><span>{ti?.label ?? entry.trigger}</span><ArrowRight size={8} /><span>{ai?.label ?? entry.action}</span></div></div><span className="shrink-0 text-[10px] text-[color:var(--muted)]">{timeAgo(entry.executedAt)}</span></motion.div>
|
||||
})}</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RulesPanel({ rules, companyId, onRefresh }: { rules: AutomationRuleRecord[]; companyId: string; onRefresh: () => void }) {
|
||||
const [showCreate, setShowCreate] = useState(false); const [editRule, setEditRule] = useState<AutomationRuleRecord | null>(null); const [delTarget, setDelTarget] = useState<AutomationRuleRecord | null>(null)
|
||||
const [actLog, setActLog] = useState<AutomationLogEntry[]>([]); const [activeTab, setActiveTab] = useState<"rules"|"activity">("rules"); const [filterStatus, setFilterStatus] = useState<AutomationStatus|"all">("all")
|
||||
const [actionError, setActionError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => { void window.agentCompany.getAutomationLog(companyId).then((r) => { if (r.ok) setActLog(r.data) }) }, [companyId, rules])
|
||||
const filtered = useMemo(() => filterStatus === "all" ? rules : rules.filter((r) => r.status === filterStatus), [rules, filterStatus])
|
||||
const counts = useMemo(() => ({ total: rules.length, active: rules.filter((r) => r.status === "active").length, paused: rules.filter((r) => r.status === "paused").length, error: rules.filter((r) => r.status === "error").length }), [rules])
|
||||
const handleToggle = useCallback(async (rule: AutomationRuleRecord) => { setActionError(null); try { const r = await window.agentCompany.toggleAutomationRule({ id: rule.id, companyId, status: (rule.status === "active" ? "paused" : "active") as "active"|"paused" }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } }, [companyId, onRefresh])
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteAutomationRule({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{actionError ? <div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"><X size={12} /></button></div> : null}
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-[color:var(--text)]">Automation Rules</h2><p className="text-[12px] text-[color:var(--muted)]">{counts.active} active, {counts.paused} paused{counts.error > 0 ? `, ${counts.error} errored` : ""}</p></div></div><ActionButton label="New Rule" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
|
||||
{rules.length > 0 ? <div className="grid grid-cols-3 gap-2">{[{ l: "Total Rules", v: counts.total, c: undefined }, { l: "Total Executions", v: rules.reduce((s, r) => s + r.executionCount, 0), c: ACCENT }, { l: "Recent Activity", v: actLog.length, c: "var(--success)" }].map((s) => <div key={s.l} className="rounded-[10px] border bg-[color:var(--panel)] px-4 py-3" style={{ borderColor: LINE }}><div className="text-[10px] font-semibold uppercase tracking-[0.08em] text-[color:var(--muted)]">{s.l}</div><div className="text-[22px] font-bold" style={s.c ? { color: s.c } : undefined}>{s.v}</div></div>)}</div> : null}
|
||||
<div className="flex gap-1 rounded-[8px] bg-[color:var(--panel-soft)] p-1"><button type="button" onClick={() => setActiveTab("rules")} className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "rules" ? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm" : "text-[color:var(--muted)] hover:text-[color:var(--text)]"}`}>Rules ({rules.length})</button><button type="button" onClick={() => setActiveTab("activity")} className={`flex-1 rounded-[6px] px-3 py-1.5 text-[12px] font-medium transition ${activeTab === "activity" ? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm" : "text-[color:var(--muted)] hover:text-[color:var(--text)]"}`}>Activity Log ({actLog.length})</button></div>
|
||||
{activeTab === "rules" ? <>
|
||||
{rules.length > 0 ? <div className="flex flex-wrap items-center gap-1.5">{(["all","active","paused","error"] as const).map((s) => <button key={s} type="button" onClick={() => setFilterStatus(s)} className={`rounded-full px-3 py-1.5 text-[11px] font-medium transition ${filterStatus === s ? "text-white" : "bg-[color:var(--panel-soft)] text-[color:var(--muted-strong)] hover:bg-[color:var(--line)]"}`} style={filterStatus === s ? { backgroundColor: ACCENT } : undefined}>{s === "all" ? "All" : s.charAt(0).toUpperCase() + s.slice(1)}</button>)}</div> : null}
|
||||
{filtered.length === 0 && rules.length === 0 ? <div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}><Lightning size={36} className="mb-3 text-[color:var(--muted)]" /><div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No automation rules</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-[color:var(--muted)]">Create your first rule to make the company autonomous.</div><div className="mt-4"><ActionButton label="Create First Rule" onClick={() => setShowCreate(true)} icon={Lightning} tone="accent" /></div></div>
|
||||
: filtered.length === 0 ? <div className="py-8 text-center text-[13px] text-[color:var(--muted)]">No rules match the current filter.</div>
|
||||
: <div className="space-y-2"><AnimatePresence>{filtered.map((r) => <RuleCard key={r.id} rule={r} onToggle={() => void handleToggle(r)} onDelete={() => setDelTarget(r)} onEdit={() => setEditRule(r)} />)}</AnimatePresence></div>}
|
||||
</> : <AutomationActivityLog entries={actLog} />}
|
||||
<AnimatePresence>{showCreate ? <RuleFormModal open={showCreate} editingRule={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} /> : null}{editRule ? <RuleFormModal open={!!editRule} editingRule={editRule} companyId={companyId} onClose={() => setEditRule(null)} onSaved={onRefresh} /> : null}</AnimatePresence>
|
||||
<AnimatePresence>{delTarget ? <DeleteConfirmDialog open={!!delTarget} entityName={delTarget.name} onConfirm={() => void handleDelete()} onCancel={() => setDelTarget(null)} /> : null}</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowsPanel({ workflows, companyId, onRefresh }: { workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
|
||||
const [showCreate, setShowCreate] = useState(false); const [editWf, setEditWf] = useState<WorkflowPipelineRecord | null>(null); const [delTarget, setDelTarget] = useState<WorkflowPipelineRecord | null>(null)
|
||||
const [actionError, setActionError] = useState<string | null>(null); const [runningId, setRunningId] = useState<string | null>(null)
|
||||
const handleDelete = useCallback(async () => { if (!delTarget) return; setActionError(null); try { const r = await window.agentCompany.deleteWorkflow({ id: delTarget.id, companyId }); if (!r.ok) { setActionError(r.error.message); setDelTarget(null); return }; setDelTarget(null); onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed"); setDelTarget(null) } }, [delTarget, companyId, onRefresh])
|
||||
const handleRun = useCallback(async (wf: WorkflowPipelineRecord) => { setActionError(null); setRunningId(wf.id); try { const r = await window.agentCompany.runWorkflow({ id: wf.id, companyId }); if (!r.ok) { setActionError(r.error.message); return }; onRefresh() } catch (e) { setActionError(e instanceof Error ? e.message : "Failed") } finally { setRunningId(null) } }, [companyId, onRefresh])
|
||||
const counts = useMemo(() => ({ total: workflows.length, active: workflows.filter((w) => w.status === "active").length, draft: workflows.filter((w) => w.status === "draft").length }), [workflows])
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{actionError ? <div className="flex items-center justify-between rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]"><span>{actionError}</span><button type="button" onClick={() => setActionError(null)} className="ml-2 rounded p-0.5 hover:bg-[color:var(--danger)] hover:text-white"><X size={12} /></button></div> : null}
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-3"><div className="flex h-10 w-10 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><ArrowsClockwise size={20} weight="fill" /></div><div><h2 className="text-[16px] font-bold text-[color:var(--text)]">Workflow Pipelines</h2><p className="text-[12px] text-[color:var(--muted)]">{counts.active} active, {counts.draft} draft{counts.total > counts.active + counts.draft ? `, ${counts.total - counts.active - counts.draft} other` : ""}</p></div></div><ActionButton label="New Workflow" onClick={() => setShowCreate(true)} icon={Plus} tone="accent" /></div>
|
||||
{workflows.length === 0 ? <div className="flex flex-col items-center justify-center rounded-[10px] border border-dashed py-16 text-center" style={{ borderColor: LINE }}><ArrowsClockwise size={36} className="mb-3 text-[color:var(--muted)]" /><div className="text-[14px] font-medium text-[color:var(--muted-strong)]">No workflow pipelines</div><div className="mx-auto mt-1 max-w-[42ch] text-[12px] leading-relaxed text-[color:var(--muted)]">Workflows chain actions into multi-step pipelines.</div><div className="mt-4"><ActionButton label="Create First Workflow" onClick={() => setShowCreate(true)} icon={ArrowsClockwise} tone="accent" /></div></div>
|
||||
: <div className="space-y-2"><AnimatePresence>{workflows.map((wf) => <WorkflowCard key={wf.id} workflow={wf} isRunning={runningId === wf.id} onDelete={() => setDelTarget(wf)} onEdit={() => setEditWf(wf)} onRun={() => void handleRun(wf)} />)}</AnimatePresence></div>}
|
||||
<AnimatePresence>{showCreate ? <WorkflowFormModal open={showCreate} editingWorkflow={null} companyId={companyId} onClose={() => setShowCreate(false)} onSaved={onRefresh} /> : null}{editWf ? <WorkflowFormModal open={!!editWf} editingWorkflow={editWf} companyId={companyId} onClose={() => setEditWf(null)} onSaved={onRefresh} /> : null}</AnimatePresence>
|
||||
<AnimatePresence>{delTarget ? <DeleteConfirmDialog open={!!delTarget} entityName={delTarget.name} onConfirm={() => void handleDelete()} onCancel={() => setDelTarget(null)} /> : null}</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomationPanel({ rules, workflows, companyId, onRefresh }: { rules: AutomationRuleRecord[]; workflows: WorkflowPipelineRecord[]; companyId: string; onRefresh: () => void }) {
|
||||
const t = useT(); const [activeSection, setActiveSection] = useState<"rules"|"workflows">("rules")
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-[12px] border px-5 py-4" style={{ borderColor: `${ACCENT}30`, background: `linear-gradient(135deg, ${ACCENT}08 0%, ${ACCENT}04 100%)` }}><div className="flex items-start gap-3"><div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[8px]" style={{ backgroundColor: `${ACCENT}18`, color: ACCENT }}><Robot size={20} weight="fill" /></div><div><div className="text-[13px] font-semibold text-[color:var(--text)]">{t("auto.automationEngine")}</div><div className="mt-0.5 text-[11px] leading-relaxed text-[color:var(--muted)]">Rules trigger actions based on company events. Workflows chain multiple steps into pipelines. Together, they let your AI company run fully autonomously.</div></div></div></div>
|
||||
<div className="flex gap-1 rounded-[10px] bg-[color:var(--panel-soft)] p-1">
|
||||
<button type="button" onClick={() => setActiveSection("rules")} className={`flex flex-1 items-center justify-center gap-2 rounded-[8px] px-4 py-2 text-[13px] font-medium transition ${activeSection === "rules" ? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm" : "text-[color:var(--muted)] hover:text-[color:var(--text)]"}`}><Lightning size={15} weight={activeSection === "rules" ? "fill" : "regular"} style={activeSection === "rules" ? { color: ACCENT } : undefined} />Automation Rules ({rules.length})</button>
|
||||
<button type="button" onClick={() => setActiveSection("workflows")} className={`flex flex-1 items-center justify-center gap-2 rounded-[8px] px-4 py-2 text-[13px] font-medium transition ${activeSection === "workflows" ? "bg-[color:var(--panel)] text-[color:var(--text)] shadow-sm" : "text-[color:var(--muted)] hover:text-[color:var(--text)]"}`}><ArrowsClockwise size={15} weight={activeSection === "workflows" ? "fill" : "regular"} style={activeSection === "workflows" ? { color: ACCENT } : undefined} />Workflows ({workflows.length})</button>
|
||||
</div>
|
||||
<AnimatePresence mode="wait"><motion.div key={activeSection} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.18, ease: [0.16, 1, 0.3, 1] }}>{activeSection === "rules" ? <RulesPanel rules={rules} companyId={companyId} onRefresh={onRefresh} /> : <WorkflowsPanel workflows={workflows} companyId={companyId} onRefresh={onRefresh} />}</motion.div></AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowRight, CaretRight, Gear, Lightning, Pause, Play, Trash } from "@phosphor-icons/react"
|
||||
import type { AutomationRuleRecord } from "@shared/types"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, statusColors } from "./automation-config"
|
||||
|
||||
export function RuleCard({ rule, onToggle, onDelete, onEdit }: { rule: AutomationRuleRecord; onToggle: () => void; onDelete: () => void; onEdit: () => void }) {
|
||||
const triggerInfo = triggerConfig[rule.trigger]; const actionInfo = actionConfig[rule.action]
|
||||
const TriggerIcon = triggerInfo?.icon ?? Lightning; const colors = statusColors[rule.status]
|
||||
|
||||
return (
|
||||
<motion.div layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 20 }} className="group rounded-[10px] border bg-[color:var(--panel)] transition hover:shadow-sm" style={{ borderColor: LINE }}>
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><TriggerIcon size={20} weight="fill" /></div>
|
||||
<button type="button" onClick={onEdit} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold text-[color:var(--text)]">{rule.name}</span>
|
||||
<span className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: colors.dot }} />{rule.status}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-[color:var(--muted)]">
|
||||
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]"><Lightning size={10} weight="fill" />{triggerInfo?.label ?? rule.trigger}</span>
|
||||
<ArrowRight size={10} className="text-[color:var(--muted)]" />
|
||||
<span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]"><Gear size={10} />{actionInfo?.label ?? rule.action}</span>
|
||||
{rule.sourceDepartment || rule.targetDepartment ? <><span className="text-[color:var(--line-strong)]">|</span><span className="inline-flex items-center gap-1"><span className="font-medium text-[color:var(--muted-strong)]">{rule.sourceDepartment ?? "Any"}</span><CaretRight size={8} /><span className="font-medium text-[color:var(--muted-strong)]">{rule.targetDepartment ?? "Same"}</span></span></> : null}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-[10px] text-[color:var(--muted)]"><span>{rule.executionCount} execution{rule.executionCount !== 1 ? "s" : ""}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>Last run {timeAgo(rule.lastExecutedAt)}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>Priority {rule.priority}</span></div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button type="button" onClick={onToggle} aria-label={rule.status === "active" ? "Pause rule" : "Activate rule"} title={rule.status === "active" ? "Pause rule" : "Activate rule"} className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]">{rule.status === "active" ? <Pause size={15} /> : <Play size={15} />}</button>
|
||||
<button type="button" onClick={onDelete} aria-label="Delete rule" title="Delete rule" className="rounded-[6px] p-2 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"><Trash size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { Gear, Lightning, X } from "@phosphor-icons/react"
|
||||
import type { AutomationAction, AutomationRuleRecord, AutomationTrigger, DepartmentId } from "@shared/types"
|
||||
import { ActionButton, Select } from "../ui"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, departmentOptions, safeJsonStringify } from "./automation-config"
|
||||
|
||||
interface RuleFormState { name: string; description: string; trigger: AutomationTrigger; conditionsJson: string; action: AutomationAction; actionConfigJson: string; sourceDepartment: string; targetDepartment: string; priority: number }
|
||||
const defaultRuleForm: RuleFormState = { name: "", description: "", trigger: "task_status_changed", conditionsJson: "{}", action: "assign_task", actionConfigJson: "{}", sourceDepartment: "", targetDepartment: "", priority: 50 }
|
||||
function ruleToFormState(rule: AutomationRuleRecord): RuleFormState { return { name: rule.name, description: rule.description, trigger: rule.trigger, conditionsJson: safeJsonStringify(rule.conditionsJson), action: rule.action, actionConfigJson: safeJsonStringify(rule.actionConfigJson), sourceDepartment: rule.sourceDepartment ?? "", targetDepartment: rule.targetDepartment ?? "", priority: rule.priority } }
|
||||
|
||||
export function RuleFormModal({ open, editingRule, companyId, onClose, onSaved }: { open: boolean; editingRule: AutomationRuleRecord | null; companyId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const isEditing = editingRule !== null; const [form, setForm] = useState<RuleFormState>(defaultRuleForm); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => { if (open) { setForm(editingRule ? ruleToFormState(editingRule) : defaultRuleForm); setError(null) } }, [open, editingRule])
|
||||
const updateField = useCallback(<K extends keyof RuleFormState>(key: K, value: RuleFormState[K]) => { setForm((prev) => ({ ...prev, [key]: value })) }, [])
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim()) { setError("Rule name is required"); return }
|
||||
try { JSON.parse(form.conditionsJson) } catch { setError("Conditions must be valid JSON"); return }
|
||||
try { JSON.parse(form.actionConfigJson) } catch { setError("Action config must be valid JSON"); return }
|
||||
setSaving(true); setError(null)
|
||||
try { const result = await window.agentCompany.saveAutomationRule({ id: editingRule?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), trigger: form.trigger, conditionsJson: form.conditionsJson, action: form.action, actionConfigJson: form.actionConfigJson, sourceDepartment: (form.sourceDepartment || null) as DepartmentId | null, targetDepartment: (form.targetDepartment || null) as DepartmentId | null, priority: form.priority, status: editingRule?.status ?? "active" }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save rule") } finally { setSaving(false) }
|
||||
}, [form, editingRule, companyId, onSaved, onClose])
|
||||
if (!open) return null
|
||||
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label })); const actionOptions = Object.entries(actionConfig).map(([key, c]) => ({ value: key, label: c.label }))
|
||||
const selectedTriggerInfo = triggerConfig[form.trigger]; const selectedActionInfo = actionConfig[form.action]
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[580px] flex-col rounded-[14px] border bg-[color:var(--panel)] shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}>
|
||||
<div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-[8px]" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><Lightning size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-[color:var(--text)]">{isEditing ? "Edit Automation Rule" : "New Automation Rule"}</h2></div>
|
||||
<button type="button" onClick={onClose} className="rounded-[6px] p-1.5 text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]" aria-label="Close"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
|
||||
{error ? <div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">{error}</div> : null}
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Rule Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Auto-assign blocked tasks" className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" style={{ borderColor: LINE }} /></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="What does this rule do?" rows={2} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" style={{ borderColor: LINE }} /></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Trigger</label><Select value={form.trigger} onChange={(v) => updateField("trigger", v as AutomationTrigger)} options={triggerOptions} />{selectedTriggerInfo ? <div className="mt-1.5 text-[11px] text-[color:var(--muted)]">{selectedTriggerInfo.description}</div> : null}</div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Conditions (JSON)</label><textarea value={form.conditionsJson} onChange={(e) => updateField("conditionsJson", e.target.value)} rows={3} spellCheck={false} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 font-mono text-[12px] text-[color:var(--text)]" style={{ borderColor: LINE }} placeholder='{"status": "blocked"}' /></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Action</label><Select value={form.action} onChange={(v) => updateField("action", v as AutomationAction)} options={actionOptions} />{selectedActionInfo ? <div className="mt-1.5 text-[11px] text-[color:var(--muted)]">{selectedActionInfo.description}</div> : null}</div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Action Configuration (JSON)</label><textarea value={form.actionConfigJson} onChange={(e) => updateField("actionConfigJson", e.target.value)} rows={3} spellCheck={false} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 font-mono text-[12px] text-[color:var(--text)]" style={{ borderColor: LINE }} placeholder='{"targetAgentId": "..."}' /></div>
|
||||
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Source Department</label><Select value={form.sourceDepartment} onChange={(v) => updateField("sourceDepartment", v)} options={departmentOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Target Department</label><Select value={form.targetDepartment} onChange={(v) => updateField("targetDepartment", v)} options={departmentOptions} /></div></div>
|
||||
<div><div className="mb-1.5 flex items-center justify-between"><label className="text-[12px] font-medium text-[color:var(--muted-strong)]">Priority</label><span className="text-[12px] font-semibold" style={{ color: ACCENT }}>{form.priority}</span></div><input type="range" min={0} max={100} value={form.priority} onChange={(e) => updateField("priority", parseInt(e.target.value, 10))} className="w-full accent-[#d97745]" /><div className="mt-1 flex justify-between text-[10px] text-[color:var(--muted)]"><span>Low priority</span><span>High priority</span></div></div>
|
||||
</div></div>
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
||||
<button type="button" onClick={onClose} className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]">Cancel</button>
|
||||
<ActionButton label={isEditing ? "Save Changes" : "Create Rule"} onClick={() => void handleSave()} icon={isEditing ? Gear : Lightning} tone="accent" loading={saving} disabled={!form.name.trim()} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { motion } from "framer-motion"
|
||||
import { ArrowsClockwise, Gear, X } from "@phosphor-icons/react"
|
||||
import type { AutomationTrigger, WorkflowPipelineRecord, WorkflowStatus } from "@shared/types"
|
||||
import { ActionButton, Select } from "../ui"
|
||||
import { ACCENT, LINE, triggerConfig, safeJsonStringify } from "./automation-config"
|
||||
|
||||
interface WorkflowFormState { name: string; description: string; triggerType: AutomationTrigger; triggerConfigJson: string; status: WorkflowStatus; stepsJson: string }
|
||||
const defaultWorkflowForm: WorkflowFormState = { name: "", description: "", triggerType: "task_created", triggerConfigJson: "{}", status: "draft", stepsJson: "[]" }
|
||||
function workflowToFormState(wf: WorkflowPipelineRecord): WorkflowFormState { return { name: wf.name, description: wf.description, triggerType: wf.triggerType, triggerConfigJson: safeJsonStringify(wf.triggerConfigJson), status: wf.status, stepsJson: safeJsonStringify(wf.stepsJson) } }
|
||||
|
||||
export function WorkflowFormModal({ open, editingWorkflow, companyId, onClose, onSaved }: { open: boolean; editingWorkflow: WorkflowPipelineRecord | null; companyId: string; onClose: () => void; onSaved: () => void }) {
|
||||
const isEditing = editingWorkflow !== null; const [form, setForm] = useState<WorkflowFormState>(defaultWorkflowForm); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null)
|
||||
useEffect(() => { if (open) { setForm(editingWorkflow ? workflowToFormState(editingWorkflow) : defaultWorkflowForm); setError(null) } }, [open, editingWorkflow])
|
||||
const updateField = useCallback(<K extends keyof WorkflowFormState>(key: K, value: WorkflowFormState[K]) => { setForm((p) => ({ ...p, [key]: value })) }, [])
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!form.name.trim()) { setError("Workflow name is required"); return }
|
||||
try { JSON.parse(form.triggerConfigJson) } catch { setError("Trigger config must be valid JSON"); return }
|
||||
try { JSON.parse(form.stepsJson) } catch { setError("Steps must be valid JSON array"); return }
|
||||
setSaving(true); setError(null)
|
||||
try { const result = await window.agentCompany.saveWorkflow({ id: editingWorkflow?.id ?? null, companyId, name: form.name.trim(), description: form.description.trim(), triggerType: form.triggerType, triggerConfigJson: form.triggerConfigJson, status: form.status, stepsJson: form.stepsJson }); if (!result.ok) { setError(result.error.message); return }; onSaved(); onClose() } catch (err) { setError(err instanceof Error ? err.message : "Failed to save workflow") } finally { setSaving(false) }
|
||||
}, [form, editingWorkflow, companyId, onSaved, onClose])
|
||||
if (!open) return null
|
||||
const triggerOptions = Object.entries(triggerConfig).map(([key, c]) => ({ value: key, label: c.label }))
|
||||
const statusOptions = [{ value: "draft", label: "Draft" }, { value: "active", label: "Active" }, { value: "paused", label: "Paused" }]
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={onClose}>
|
||||
<motion.div initial={{ scale: 0.95, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.95, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="flex w-full max-w-[540px] flex-col rounded-[14px] border bg-[color:var(--panel)] shadow-xl" style={{ borderColor: LINE, maxHeight: "85vh" }} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex shrink-0 items-center justify-between border-b px-6 py-4" style={{ borderColor: LINE }}><div className="flex items-center gap-3"><div className="flex h-8 w-8 items-center justify-center rounded-[8px]" style={{ backgroundColor: `${ACCENT}15`, color: ACCENT }}><ArrowsClockwise size={18} weight="fill" /></div><h2 className="text-[16px] font-bold text-[color:var(--text)]">{isEditing ? "Edit Workflow" : "New Workflow"}</h2></div><button type="button" onClick={onClose} className="rounded-[6px] p-1.5 text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]" aria-label="Close"><X size={16} /></button></div>
|
||||
<div className="overflow-y-auto px-6 py-5"><div className="space-y-5">
|
||||
{error ? <div className="rounded-[8px] bg-[color:var(--danger-soft)] px-3.5 py-2.5 text-[12px] font-medium text-[color:var(--danger)]">{error}</div> : null}
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Workflow Name</label><input type="text" value={form.name} onChange={(e) => updateField("name", e.target.value)} placeholder="e.g., Bug Fix Pipeline" className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" style={{ borderColor: LINE }} /></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Description</label><textarea value={form.description} onChange={(e) => updateField("description", e.target.value)} placeholder="Describe this workflow pipeline" rows={2} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 text-[14px] text-[color:var(--text)] placeholder:text-[color:var(--muted)]" style={{ borderColor: LINE }} /></div>
|
||||
<div className="grid grid-cols-2 gap-3"><div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Trigger</label><Select value={form.triggerType} onChange={(v) => updateField("triggerType", v as AutomationTrigger)} options={triggerOptions} /></div><div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Status</label><Select value={form.status} onChange={(v) => updateField("status", v as WorkflowStatus)} options={statusOptions} /></div></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Trigger Configuration (JSON)</label><textarea value={form.triggerConfigJson} onChange={(e) => updateField("triggerConfigJson", e.target.value)} rows={2} spellCheck={false} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 font-mono text-[12px] text-[color:var(--text)]" style={{ borderColor: LINE }} placeholder="{}" /></div>
|
||||
<div><label className="mb-1.5 block text-[12px] font-medium text-[color:var(--muted-strong)]">Pipeline Steps (JSON Array)</label><textarea value={form.stepsJson} onChange={(e) => updateField("stepsJson", e.target.value)} rows={6} spellCheck={false} className="focus-ring w-full rounded-[8px] border bg-[color:var(--panel)] px-3.5 py-2.5 font-mono text-[12px] text-[color:var(--text)]" style={{ borderColor: LINE }} placeholder='[{"id":"step-1","name":"Assign","action":"assign_task","configJson":"{}","assigneeDepartment":"engineering","assigneeAgentId":null,"requiresApproval":false,"timeoutSec":0,"status":"pending"}]' /></div>
|
||||
</div></div>
|
||||
<div className="flex shrink-0 justify-end gap-3 border-t px-6 py-4" style={{ borderColor: LINE }}>
|
||||
<button type="button" onClick={onClose} className="rounded-[8px] px-4 py-2 text-[12px] font-medium text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]">Cancel</button>
|
||||
<ActionButton label={isEditing ? "Save Changes" : "Create Workflow"} onClick={() => void handleSave()} icon={isEditing ? Gear : ArrowsClockwise} tone="accent" loading={saving} disabled={!form.name.trim()} />
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { AnimatePresence, motion } from "framer-motion"
|
||||
import { ArrowsClockwise, CaretRight, Gear, Lightning, Play, Trash, X } from "@phosphor-icons/react"
|
||||
import type { WorkflowPipelineRecord, WorkflowStep } from "@shared/types"
|
||||
import { timeAgo } from "../../lib/formatters"
|
||||
import { ActionButton } from "../ui"
|
||||
import { ACCENT, LINE, triggerConfig, actionConfig, workflowStatusColors, stepStatusConfig, safeJsonParse } from "./automation-config"
|
||||
|
||||
function StepTimeline({ steps }: { steps: WorkflowStep[] }) {
|
||||
if (steps.length === 0) return <div className="py-4 text-center text-[11px] text-[color:var(--muted)]">No steps defined</div>
|
||||
return (
|
||||
<div className="relative pl-5">
|
||||
<div className="absolute left-[9px] top-2 w-px" style={{ height: `calc(100% - 16px)`, backgroundColor: "var(--line)" }} />
|
||||
<div className="space-y-3">
|
||||
{steps.map((step, idx) => {
|
||||
const colors = stepStatusConfig[step.status]; const info = actionConfig[step.action]
|
||||
return (
|
||||
<div key={step.id} className="relative flex items-start gap-3">
|
||||
<div className="absolute -left-5 top-1.5 z-10 h-[10px] w-[10px] rounded-full border-2" style={{ backgroundColor: colors.bg, borderColor: colors.border }} />
|
||||
<div className="flex-1 rounded-[8px] border px-3 py-2.5" style={{ borderColor: step.status === "running" ? ACCENT : LINE }}>
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-2"><span className="text-[11px] font-bold" style={{ color: colors.text }}>{idx + 1}</span><span className="text-[12px] font-medium text-[color:var(--text)]">{step.name}</span></div><span className="rounded-full px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}>{step.status}</span></div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[10px] text-[color:var(--muted)]"><span>{info?.label ?? step.action}</span>{step.assigneeDepartment ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>{step.assigneeDepartment}</span></> : null}{step.requiresApproval ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span className="text-[color:var(--warn)]">Requires approval</span></> : null}{step.timeoutSec > 0 ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>{step.timeoutSec}s timeout</span></> : null}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowCard({ workflow, isRunning, onDelete, onEdit, onRun }: { workflow: WorkflowPipelineRecord; isRunning: boolean; onDelete: () => void; onEdit: () => void; onRun: () => void }) {
|
||||
const [expanded, setExpanded] = useState(false); const colors = workflowStatusColors[workflow.status]
|
||||
const triggerInfo = triggerConfig[workflow.triggerType]; const TriggerIcon = triggerInfo?.icon ?? Lightning
|
||||
const steps: WorkflowStep[] = useMemo(() => { const parsed = safeJsonParse(workflow.stepsJson); return Array.isArray(parsed) ? (parsed as WorkflowStep[]) : [] }, [workflow.stepsJson])
|
||||
|
||||
return (
|
||||
<motion.div layout initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, x: 20 }} className="group rounded-[10px] border bg-[color:var(--panel)] transition hover:shadow-sm" style={{ borderColor: LINE }}>
|
||||
<div className="flex items-center gap-3 px-4 py-3.5">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[10px]" style={{ backgroundColor: `${ACCENT}12`, color: ACCENT }}><TriggerIcon size={20} weight="fill" /></div>
|
||||
<button type="button" onClick={() => setExpanded((p) => !p)} className="min-w-0 flex-1 text-left">
|
||||
<div className="flex items-center gap-2"><span className="truncate text-[13px] font-semibold text-[color:var(--text)]">{workflow.name}</span><span className="flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-[0.1em]" style={{ backgroundColor: colors.bg, color: colors.text }}><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: colors.dot }} />{workflow.status}</span></div>
|
||||
<div className="mt-1 flex items-center gap-2 text-[11px] text-[color:var(--muted)]"><span className="inline-flex items-center gap-1 rounded-[4px] bg-[color:var(--panel-soft)] px-1.5 py-0.5 font-medium text-[color:var(--muted-strong)]"><Lightning size={10} weight="fill" />{triggerInfo?.label ?? workflow.triggerType}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>{steps.length} step{steps.length !== 1 ? "s" : ""}</span><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>{workflow.runCount} run{workflow.runCount !== 1 ? "s" : ""}</span>{workflow.lastRunAt ? <><span className="h-0.5 w-0.5 rounded-full bg-[color:var(--muted)]" /><span>Last run {timeAgo(workflow.lastRunAt)}</span></> : null}</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<motion.div animate={{ rotate: expanded ? 90 : 0 }} transition={{ duration: 0.15 }}><button type="button" onClick={() => setExpanded((p) => !p)} aria-label={expanded ? "Collapse" : "Expand"} className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"><CaretRight size={14} /></button></motion.div>
|
||||
<button type="button" onClick={onRun} aria-label="Run workflow" disabled={isRunning} className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--success-soft)] hover:text-[color:var(--success)] disabled:pointer-events-none disabled:opacity-50">{isRunning ? <ArrowsClockwise size={15} className="animate-spin" /> : <Play size={15} />}</button>
|
||||
<button type="button" onClick={onEdit} aria-label="Edit workflow" className="rounded-[6px] p-2 text-[color:var(--muted)] transition hover:bg-[color:var(--panel-soft)] hover:text-[color:var(--text)]"><Gear size={15} /></button>
|
||||
<button type="button" onClick={onDelete} aria-label="Delete workflow" className="rounded-[6px] p-2 text-[color:var(--muted)] opacity-0 transition hover:bg-[color:var(--danger-soft)] hover:text-[color:var(--danger)] group-hover:opacity-100"><Trash size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{expanded ? <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className="overflow-hidden"><div className="border-t px-4 py-4" style={{ borderColor: LINE }}>{workflow.description ? <p className="mb-3 text-[12px] leading-relaxed text-[color:var(--muted)]">{workflow.description}</p> : null}<StepTimeline steps={steps} /></div></motion.div> : null}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export { WorkflowCard, StepTimeline }
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { AutomationAction, AutomationStatus, AutomationTrigger, WorkflowStatus, WorkflowStep } from "@shared/types"
|
||||
import { ArrowsClockwise, Eye, Funnel, Lightning, Pause, Play, Plus, Robot, Timer, Trash } from "@phosphor-icons/react"
|
||||
import { DEPARTMENT_OPTIONS } from "@shared/constants"
|
||||
|
||||
export const ACCENT = "var(--accent)"
|
||||
export const LINE = "var(--line)"
|
||||
|
||||
export const triggerConfig: Record<AutomationTrigger, { label: string; description: string; icon: typeof Lightning }> = {
|
||||
task_status_changed: { label: "Task Status Changed", description: "When a task transitions to a new status", icon: ArrowsClockwise },
|
||||
task_created: { label: "Task Created", description: "When a new task is added to the system", icon: Plus },
|
||||
task_blocked: { label: "Task Blocked", description: "When a task becomes blocked by a dependency", icon: Pause },
|
||||
task_reassigned: { label: "Task Reassigned", description: "When a task is reassigned to a different agent", icon: ArrowsClockwise },
|
||||
run_completed: { label: "Run Completed", description: "When an agent run finishes successfully", icon: Play },
|
||||
run_failed: { label: "Run Failed", description: "When an agent run encounters an error", icon: Trash },
|
||||
approval_created: { label: "Approval Created", description: "When a new approval request needs review", icon: Eye },
|
||||
approval_resolved: { label: "Approval Resolved", description: "When a pending approval is decided", icon: Eye },
|
||||
budget_threshold: { label: "Budget Threshold", description: "When spending exceeds a configured limit", icon: Funnel },
|
||||
agent_idle: { label: "Agent Idle", description: "When an agent has no tasks and becomes idle", icon: Robot },
|
||||
comment_posted: { label: "Comment Posted", description: "When a new comment is added to a task", icon: Lightning },
|
||||
document_created: { label: "Document Created", description: "When a new document is generated", icon: Lightning },
|
||||
schedule: { label: "Scheduled", description: "Runs on a cron-like schedule automatically", icon: Timer },
|
||||
hire_approved: { label: "Hire Approved", description: "When a new agent hire is approved", icon: Robot },
|
||||
sprint_started: { label: "Sprint Started", description: "When a sprint transitions to active", icon: Play },
|
||||
sprint_ended: { label: "Sprint Ended", description: "When a sprint reaches completion", icon: ArrowsClockwise },
|
||||
}
|
||||
|
||||
export const actionConfig: Record<AutomationAction, { label: string; description: string }> = {
|
||||
assign_task: { label: "Assign Task", description: "Auto-assign a task to the best available agent" },
|
||||
create_task: { label: "Create Task", description: "Generate a new task from a template" },
|
||||
notify_agent: { label: "Notify Agent", description: "Send a notification to a specific agent" },
|
||||
send_message: { label: "Send Message", description: "Deliver a message via inter-agent messaging" },
|
||||
trigger_heartbeat: { label: "Trigger Heartbeat", description: "Wake an agent and trigger their heartbeat loop" },
|
||||
create_approval: { label: "Create Approval", description: "Request human approval before proceeding" },
|
||||
update_task_status: { label: "Update Task Status", description: "Move a task to a new status column" },
|
||||
escalate_to_manager: { label: "Escalate to Manager", description: "Route to the responsible manager for review" },
|
||||
cross_department_notify: { label: "Cross-Dept Notify", description: "Alert another department about this event" },
|
||||
schedule_meeting: { label: "Schedule Meeting", description: "Create a meeting with relevant participants" },
|
||||
create_document: { label: "Create Document", description: "Generate a document from a template" },
|
||||
auto_approve: { label: "Auto Approve", description: "Automatically approve matching requests" },
|
||||
reassign_task: { label: "Reassign Task", description: "Move task to a different agent" },
|
||||
run_task: { label: "Run Task", description: "Immediately execute the task with an agent" },
|
||||
}
|
||||
|
||||
export const departmentOptions = DEPARTMENT_OPTIONS
|
||||
|
||||
export const statusColors: Record<AutomationStatus, { bg: string; text: string; dot: string }> = {
|
||||
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
|
||||
paused: { bg: "var(--warn-soft)", text: "var(--warn)", dot: "var(--warn)" },
|
||||
disabled: { bg: "var(--panel-soft)", text: "var(--muted)", dot: "var(--muted)" },
|
||||
error: { bg: "var(--danger-soft)", text: "var(--danger)", dot: "var(--danger)" },
|
||||
}
|
||||
|
||||
export const workflowStatusColors: Record<WorkflowStatus, { bg: string; text: string; dot: string }> = {
|
||||
draft: { bg: "var(--panel-soft)", text: "var(--muted-strong)", dot: "var(--muted)" },
|
||||
active: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
|
||||
paused: { bg: "var(--warn-soft)", text: "var(--warn)", dot: "var(--warn)" },
|
||||
completed: { bg: "var(--success-soft)", text: "var(--success)", dot: "var(--success)" },
|
||||
failed: { bg: "var(--danger-soft)", text: "var(--danger)", dot: "var(--danger)" },
|
||||
}
|
||||
|
||||
export const stepStatusConfig: Record<WorkflowStep["status"], { bg: string; border: string; text: string }> = {
|
||||
pending: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
|
||||
running: { bg: "var(--accent-soft)", border: ACCENT, text: ACCENT },
|
||||
completed: { bg: "var(--success-soft)", border: "var(--success)", text: "var(--success)" },
|
||||
failed: { bg: "var(--danger-soft)", border: "var(--danger)", text: "var(--danger)" },
|
||||
skipped: { bg: "var(--panel-soft)", border: "var(--line)", text: "var(--muted)" },
|
||||
}
|
||||
|
||||
export interface AutomationLogEntry { ruleId: string; ruleName: string; trigger: string; action: string; executedAt: string }
|
||||
|
||||
export function safeJsonParse(json: string): unknown { try { return JSON.parse(json) } catch { return {} } }
|
||||
export function safeJsonStringify(value: string): string { try { const parsed = JSON.parse(value); return JSON.stringify(parsed, null, 2) } catch { return value } }
|
||||
Reference in new issue
Block a user