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:
@@ -1,12 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { RouteSettingsDialog } from './RouteSettingsDialog';
|
||||
import { RouteRulesDialog } from './RouteRulesDialog';
|
||||
|
||||
interface Group {
|
||||
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||
type ApprovalMode = 'MANUAL' | 'AUTO';
|
||||
|
||||
interface AssignedRule {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
isActive: boolean;
|
||||
matchType: string;
|
||||
matchValue: string;
|
||||
ruleIntent: 'POSITIVE' | 'NEGATIVE';
|
||||
}
|
||||
|
||||
interface Route {
|
||||
@@ -15,8 +21,29 @@ interface Route {
|
||||
targetGroupId: string;
|
||||
sourceGroup: { name: string };
|
||||
targetGroup: { name: string };
|
||||
forwardFormat: ForwardFormat;
|
||||
withAttachment: boolean;
|
||||
frequency: ForwardFrequency;
|
||||
approvalMode: ApprovalMode;
|
||||
isActive: boolean;
|
||||
assignedRules?: Array<{ tenantRule: AssignedRule }>;
|
||||
}
|
||||
|
||||
interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
platform: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const FREQ_LABELS: Record<ForwardFrequency, string> = {
|
||||
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
|
||||
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
|
||||
};
|
||||
const FORMAT_LABELS: Record<ForwardFormat, string> = {
|
||||
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
|
||||
};
|
||||
|
||||
export function RouteManager({
|
||||
groups,
|
||||
initialRoutes,
|
||||
@@ -29,8 +56,9 @@ export function RouteManager({
|
||||
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
|
||||
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
|
||||
|
||||
// Group routes by source group name
|
||||
const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
|
||||
for (const r of routes) {
|
||||
const key = r.sourceGroup.name;
|
||||
@@ -41,8 +69,7 @@ export function RouteManager({
|
||||
function toggleTarget(id: string) {
|
||||
setTargetIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
setError(null);
|
||||
@@ -50,35 +77,24 @@ export function RouteManager({
|
||||
|
||||
async function addRoutes() {
|
||||
if (!sourceId || targetIds.size === 0) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/routes/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
|
||||
});
|
||||
if (res.status === 409) {
|
||||
const errBody = await res.json();
|
||||
setError(errBody.message ?? 'Some routes already exist');
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setError('Failed to create routes');
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
|
||||
if (!res.ok) { setError('Failed to create routes'); return; }
|
||||
const created: Route[] = await res.json();
|
||||
setRoutes((prev) => [...created, ...prev]);
|
||||
setSourceId('');
|
||||
setTargetIds(new Set());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setSourceId(''); setTargetIds(new Set());
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function deleteRoute(id: string) {
|
||||
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
|
||||
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id));
|
||||
if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
|
||||
}
|
||||
|
||||
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
|
||||
@@ -90,29 +106,72 @@ export function RouteManager({
|
||||
{routes.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">No routes configured.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => (
|
||||
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span>
|
||||
<div className="flex flex-col gap-1.5 flex-1">
|
||||
{targets.map((r) => (
|
||||
<div key={r.id} className="flex items-center justify-between group">
|
||||
<span className="text-sm text-gray-600">{r.targetGroup.name}</span>
|
||||
<button
|
||||
onClick={() => deleteRoute(r.id)}
|
||||
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={`Delete route to ${r.targetGroup.name}`}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
|
||||
<th className="px-4 py-2.5">Source</th>
|
||||
<th className="px-4 py-2.5">Target</th>
|
||||
<th className="px-4 py-2.5">Rules</th>
|
||||
<th className="px-4 py-2.5">Frequency</th>
|
||||
<th className="px-4 py-2.5">Format</th>
|
||||
<th className="px-4 py-2.5">Approval</th>
|
||||
<th className="px-4 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{routes.map((r) => {
|
||||
const ruleCount = r.assignedRules?.length ?? 0;
|
||||
return (
|
||||
<tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
|
||||
{ruleCount} rule{ruleCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
|
||||
<td className="px-4 py-2.5 text-gray-600">
|
||||
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
|
||||
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
|
||||
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setSettingsRoute(r)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600"
|
||||
title="Edit settings"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setRulesRoute(r)}
|
||||
className="text-xs text-gray-400 hover:text-blue-600"
|
||||
title="Manage rules"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void deleteRoute(r.id)}
|
||||
className="text-xs text-gray-400 hover:text-red-600"
|
||||
title="Delete route"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -124,7 +183,6 @@ export function RouteManager({
|
||||
value={sourceId}
|
||||
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
|
||||
className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
|
||||
aria-label="Source group"
|
||||
>
|
||||
<option value="">Source group…</option>
|
||||
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
|
||||
@@ -132,32 +190,27 @@ export function RouteManager({
|
||||
))}
|
||||
</select>
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
|
||||
</div>
|
||||
|
||||
{sourceId && eligibleTargets.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
|
||||
{eligibleTargets.map((g) => {
|
||||
const checked = targetIds.has(g.id);
|
||||
return (
|
||||
<label
|
||||
key={g.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
|
||||
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTarget(g.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{g.name}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{eligibleTargets.map((g) => (
|
||||
<label
|
||||
key={g.id}
|
||||
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
|
||||
targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={targetIds.has(g.id)}
|
||||
onChange={() => toggleTarget(g.id)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
{g.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -166,7 +219,7 @@ export function RouteManager({
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={addRoutes}
|
||||
onClick={() => void addRoutes()}
|
||||
disabled={!sourceId || targetIds.size === 0 || busy}
|
||||
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
@@ -174,6 +227,24 @@ export function RouteManager({
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{settingsRoute && (
|
||||
<RouteSettingsDialog
|
||||
route={settingsRoute}
|
||||
onSaved={(updates) => {
|
||||
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
|
||||
}}
|
||||
onClose={() => setSettingsRoute(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rulesRoute && (
|
||||
<RouteRulesDialog
|
||||
routeId={rulesRoute.id}
|
||||
targetGroupName={rulesRoute.targetGroup.name}
|
||||
onClose={() => setRulesRoute(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||
type ApprovalMode = 'MANUAL' | 'AUTO';
|
||||
|
||||
interface Route {
|
||||
id: string;
|
||||
forwardFormat: ForwardFormat;
|
||||
withAttachment: boolean;
|
||||
frequency: ForwardFrequency;
|
||||
approvalMode: ApprovalMode;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
|
||||
{ value: 'THREADED', label: 'Threaded' },
|
||||
{ value: 'DIGEST', label: 'Digest' },
|
||||
{ value: 'SUMMARY', label: 'Summary' },
|
||||
];
|
||||
|
||||
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
|
||||
{ value: 'INSTANT', label: 'Instant' },
|
||||
{ value: 'FIVE_MIN', label: '5 min' },
|
||||
{ value: 'FIFTEEN_MIN', label: '15 min' },
|
||||
{ value: 'ONE_HOUR', label: '1 hour' },
|
||||
{ value: 'ONE_DAY', label: '1 day' },
|
||||
{ value: 'SEVEN_DAYS', label: '7 days' },
|
||||
];
|
||||
|
||||
export function RouteSettingsDialog({
|
||||
route,
|
||||
onSaved,
|
||||
onClose,
|
||||
}: {
|
||||
route: Route;
|
||||
onSaved: (updated: Partial<Route>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
|
||||
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
|
||||
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
|
||||
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function save() {
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/routes/${route.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
setError(err.message ?? 'Failed to save');
|
||||
return;
|
||||
}
|
||||
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
|
||||
onClose();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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-sm mx-4 p-5 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">Route settings</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Forward format</label>
|
||||
<select
|
||||
value={forwardFormat}
|
||||
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withAttachment}
|
||||
onChange={(e) => setWithAttachment(e.target.checked)}
|
||||
className="rounded border-gray-300"
|
||||
/>
|
||||
Include attachments
|
||||
</label>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Frequency</label>
|
||||
<select
|
||||
value={frequency}
|
||||
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
|
||||
className="border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">Approval mode</label>
|
||||
<div className="flex gap-2">
|
||||
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={() => setApprovalMode(m)}
|
||||
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
|
||||
approvalMode === m
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 text-gray-600 hover:border-gray-400'
|
||||
}`}
|
||||
>
|
||||
{m === 'MANUAL' ? 'Manual' : 'Auto'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void save()}
|
||||
disabled={busy}
|
||||
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,12 @@ interface Route {
|
||||
targetGroupId: string;
|
||||
sourceGroup: { name: string };
|
||||
targetGroup: { name: string };
|
||||
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
|
||||
withAttachment: boolean;
|
||||
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
|
||||
approvalMode: 'MANUAL' | 'AUTO';
|
||||
isActive: boolean;
|
||||
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
|
||||
}
|
||||
|
||||
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
|
||||
|
||||
Reference in New Issue
Block a user