279 lines
10 KiB
JavaScript
279 lines
10 KiB
JavaScript
import { Info, MoreHorizontal, Plus, UserRound } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import {
|
|
createHandoffRule,
|
|
deleteHandoffRule,
|
|
listHandoffRules,
|
|
testHandoffRule,
|
|
updateHandoffRule,
|
|
} from "../api";
|
|
import AgentSpark from "./AgentSpark";
|
|
import HumanHandoffDrawer from "./HumanHandoffDrawer";
|
|
import { buildRuleCardChips, EMAIL_TYPE_OPTIONS } from "../utils/handoffUtils";
|
|
|
|
function typeLabel(id) {
|
|
return EMAIL_TYPE_OPTIONS.find((item) => item.id === id)?.title || id;
|
|
}
|
|
|
|
export default function HandoffSettings() {
|
|
const token = sessionStorage.getItem("agmail_access_token");
|
|
const [rules, setRules] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState("");
|
|
const [notice, setNotice] = useState("");
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [editingRule, setEditingRule] = useState(null);
|
|
const [menuOpenId, setMenuOpenId] = useState(null);
|
|
const [testOpen, setTestOpen] = useState(false);
|
|
const [testDraft, setTestDraft] = useState(null);
|
|
const [testSubject, setTestSubject] = useState("");
|
|
const [testBody, setTestBody] = useState("");
|
|
const [testResult, setTestResult] = useState(null);
|
|
const [testing, setTesting] = useState(false);
|
|
|
|
const loadRules = useCallback(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const data = await listHandoffRules(token);
|
|
setRules(data);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [token]);
|
|
|
|
useEffect(() => {
|
|
loadRules();
|
|
}, [loadRules]);
|
|
|
|
function openCreate() {
|
|
setEditingRule(null);
|
|
setDrawerOpen(true);
|
|
}
|
|
|
|
function openEdit(rule) {
|
|
setEditingRule(rule);
|
|
setDrawerOpen(true);
|
|
setMenuOpenId(null);
|
|
}
|
|
|
|
async function handleSave(payload) {
|
|
setSaving(true);
|
|
setError("");
|
|
try {
|
|
if (editingRule) {
|
|
await updateHandoffRule(editingRule.id, payload, token);
|
|
setNotice("Human Handoff updated.");
|
|
} else {
|
|
await createHandoffRule(payload, token);
|
|
setNotice("Human Handoff created.");
|
|
}
|
|
setDrawerOpen(false);
|
|
setEditingRule(null);
|
|
await loadRules();
|
|
window.setTimeout(() => setNotice(""), 2500);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(rule) {
|
|
if (!window.confirm("Delete this handoff rule?")) return;
|
|
try {
|
|
await deleteHandoffRule(rule.id, token);
|
|
setMenuOpenId(null);
|
|
await loadRules();
|
|
} catch (err) {
|
|
setError(err.message);
|
|
}
|
|
}
|
|
|
|
function openTest(draft) {
|
|
setTestDraft(draft);
|
|
setTestSubject("");
|
|
setTestBody("");
|
|
setTestResult(null);
|
|
setTestOpen(true);
|
|
}
|
|
|
|
async function runTest() {
|
|
if (!testDraft) return;
|
|
setTesting(true);
|
|
setError("");
|
|
try {
|
|
const result = await testHandoffRule({
|
|
rule_id: editingRule?.id,
|
|
owner_email: testDraft.owner_email,
|
|
labels: testDraft.labels,
|
|
email_types: testDraft.email_types,
|
|
instructions: testDraft.instructions,
|
|
email: {
|
|
subject: testSubject,
|
|
snippet: testBody,
|
|
body_text: testBody,
|
|
},
|
|
}, token);
|
|
setTestResult(result);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="handoff-settings">
|
|
<div className="handoff-settings-notice">
|
|
<Info size={14} strokeWidth={2} className="handoff-settings-notice-icon" aria-hidden="true" />
|
|
<div>
|
|
<strong>Configuration only</strong>
|
|
<p>Handoff rules are saved as configuration only. Email routing remains unchanged.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="handoff-settings-toolbar">
|
|
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
|
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
|
</button>
|
|
</div>
|
|
|
|
{error && <p className="form-error" role="alert">{error}</p>}
|
|
{notice && <p className="security-notice" role="status">{notice}</p>}
|
|
|
|
{loading ? (
|
|
<p className="people-table-empty">Loading settings…</p>
|
|
) : rules.length === 0 ? (
|
|
<div className="human-handoff-empty">
|
|
<div className="human-handoff-empty-icon" aria-hidden="true">
|
|
<UserRound size={24} strokeWidth={1.75} />
|
|
</div>
|
|
<h2>No handoff rules yet</h2>
|
|
<p>Configure when your Agent should involve a Human Owner.</p>
|
|
<button type="button" className="handoff-btn primary" onClick={openCreate}>
|
|
<Plus size={16} strokeWidth={1.75} /> Add handoff rule
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="human-handoff-list">
|
|
{rules.map((rule) => (
|
|
<article key={rule.id} className="human-handoff-card">
|
|
<div className="human-handoff-card-body">
|
|
<span className="human-handoff-card-avatar" aria-hidden="true">
|
|
<UserRound size={17} strokeWidth={1.75} />
|
|
</span>
|
|
<strong className="human-handoff-card-name">{rule.owner_name}</strong>
|
|
<span className={`human-handoff-status${rule.active ? " active" : ""}`}>
|
|
{rule.active ? "Active" : "Paused"}
|
|
</span>
|
|
<small className="human-handoff-card-email">{rule.owner_email}</small>
|
|
<div className="human-handoff-card-actions">
|
|
<button type="button" className="handoff-btn subtle" onClick={() => openEdit(rule)}>
|
|
Edit
|
|
</button>
|
|
<div className="handoff-card-menu-wrap">
|
|
<button
|
|
type="button"
|
|
className="handoff-btn icon quiet"
|
|
aria-label="More actions"
|
|
onClick={() => setMenuOpenId((current) => (current === rule.id ? null : rule.id))}
|
|
>
|
|
<MoreHorizontal size={16} strokeWidth={1.75} />
|
|
</button>
|
|
{menuOpenId === rule.id && (
|
|
<div className="handoff-card-menu">
|
|
<button type="button" onClick={() => openTest({
|
|
owner_email: rule.owner_email,
|
|
labels: rule.labels,
|
|
email_types: rule.email_types,
|
|
instructions: rule.instructions,
|
|
})}>Test with an email</button>
|
|
<button type="button" onClick={() => handleDelete(rule)}>Delete</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="human-handoff-card-chips">
|
|
{buildRuleCardChips(rule).map((chip) => (
|
|
<span key={chip} className="handoff-meta-chip">{chip}</span>
|
|
))}
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<HumanHandoffDrawer
|
|
open={drawerOpen}
|
|
rule={editingRule}
|
|
saving={saving}
|
|
onClose={() => { setDrawerOpen(false); setEditingRule(null); }}
|
|
onSave={handleSave}
|
|
onTest={(draft) => openTest(draft)}
|
|
/>
|
|
|
|
{testOpen && (
|
|
<div className="handoff-drawer-backdrop" role="presentation" onClick={() => setTestOpen(false)}>
|
|
<aside className="handoff-test-modal" role="dialog" aria-labelledby="handoff-test-title" onClick={(e) => e.stopPropagation()}>
|
|
<header className="handoff-drawer-head">
|
|
<div>
|
|
<h2 id="handoff-test-title">Test Human Handoff</h2>
|
|
<p>Check whether your current rules would match a particular email.</p>
|
|
</div>
|
|
</header>
|
|
<div className="handoff-drawer-body">
|
|
<label className="handoff-field-label" htmlFor="handoff-test-subject">Subject</label>
|
|
<input
|
|
id="handoff-test-subject"
|
|
className="handoff-text-input"
|
|
value={testSubject}
|
|
onChange={(event) => setTestSubject(event.target.value)}
|
|
placeholder="Payment approval required"
|
|
/>
|
|
<label className="handoff-field-label" htmlFor="handoff-test-body">Email content</label>
|
|
<textarea
|
|
id="handoff-test-body"
|
|
className="handoff-instructions"
|
|
rows={6}
|
|
value={testBody}
|
|
onChange={(event) => setTestBody(event.target.value)}
|
|
placeholder="Paste or describe the email you want to test…"
|
|
/>
|
|
{testResult && (
|
|
<div className="handoff-test-result">
|
|
<h3>Handoff test</h3>
|
|
<ul>
|
|
<li className={testResult.label_matched ? "pass" : "fail"}>
|
|
{testResult.label_matched ? "✓" : "✕"} Label matched
|
|
{testResult.matched_labels?.length ? ` — ${testResult.matched_labels.join(", ")}` : ""}
|
|
</li>
|
|
<li className={testResult.type_matched ? "pass" : "fail"}>
|
|
{testResult.type_matched ? "✓" : "✕"} Type matched
|
|
{testResult.matched_types?.length ? ` — ${testResult.matched_types.map(typeLabel).join(", ")}` : ""}
|
|
</li>
|
|
<li className={testResult.instruction_matched ? "pass" : "fail"}>
|
|
{testResult.instruction_matched ? "✓" : "✕"} Handoff instruction matched
|
|
</li>
|
|
</ul>
|
|
<p><strong>Result:</strong> {testResult.result_path}</p>
|
|
<p><strong>Reason:</strong> {testResult.reason}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<footer className="handoff-drawer-foot">
|
|
<button type="button" className="handoff-btn subtle" onClick={() => setTestOpen(false)}>Close</button>
|
|
<button type="button" className="handoff-btn primary" disabled={testing} onClick={runTest}>
|
|
<AgentSpark /> {testing ? "Testing…" : "Run test"}
|
|
</button>
|
|
</footer>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|