feat: route-centric rules, approve popup, emoji picker, 5-column sync table
- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule, ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST match type - Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE rules block it, fallback to approvalMode - API: PATCH /routes/:id, rule assign/unassign endpoints, approve now accepts targetGroupIds, new GET /messages/:id/routes for popup data - Web: 5-column route table with settings + rules dialogs, ForwardTarget popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown (👍/👎 only) + INTEREST type in rules manager Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AssignedRule {
|
||||
id: string;
|
||||
matchType: string;
|
||||
matchValue: string;
|
||||
action: string;
|
||||
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||
isActive: boolean;
|
||||
assignedAt: string;
|
||||
}
|
||||
|
||||
interface TenantRule {
|
||||
id: string;
|
||||
matchType: string;
|
||||
matchValue: string;
|
||||
action: string;
|
||||
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function RouteRulesDialog({
|
||||
routeId,
|
||||
targetGroupName,
|
||||
onClose,
|
||||
}: {
|
||||
routeId: string;
|
||||
targetGroupName: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
|
||||
const [allRules, setAllRules] = useState<TenantRule[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
|
||||
fetch('/api/rules').then((r) => r.json()),
|
||||
]).then(([a, all]) => {
|
||||
setAssigned(Array.isArray(a) ? a : []);
|
||||
setAllRules(Array.isArray(all) ? all : []);
|
||||
}).catch(() => {}).finally(() => setLoading(false));
|
||||
}, [routeId]);
|
||||
|
||||
const assignedIds = new Set(assigned.map((r) => r.id));
|
||||
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
|
||||
|
||||
async function assign(tenantRuleId: string) {
|
||||
setBusy(tenantRuleId);
|
||||
try {
|
||||
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
|
||||
if (res.ok) {
|
||||
const rule = allRules.find((r) => r.id === tenantRuleId)!;
|
||||
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
|
||||
}
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function unassign(tenantRuleId: string) {
|
||||
setBusy(tenantRuleId);
|
||||
try {
|
||||
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
|
||||
if (res.ok || res.status === 204) {
|
||||
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
|
||||
}
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Rules for route</h2>
|
||||
<p className="text-xs text-gray-500">→ {targetGroupName}</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
||||
Assigned ({assigned.length})
|
||||
</h3>
|
||||
{assigned.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No rules assigned — route uses approval mode only.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{assigned.map((rule) => (
|
||||
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
|
||||
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{rule.ruleIntent}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{rule.matchValue}</span>
|
||||
<span className="text-xs text-gray-400">{rule.matchType}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void unassign(rule.id)}
|
||||
disabled={busy === rule.id}
|
||||
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{available.length > 0 && (
|
||||
<section>
|
||||
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
|
||||
Add from your rules
|
||||
</h3>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{available.map((rule) => (
|
||||
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
|
||||
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{rule.ruleIntent}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{rule.matchValue}</span>
|
||||
<span className="text-xs text-gray-400">{rule.matchType}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void assign(rule.id)}
|
||||
disabled={busy === rule.id}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{available.length === 0 && assigned.length > 0 && (
|
||||
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user