180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
|
|
interface Group {
|
|
id: string;
|
|
name: string;
|
|
platform: string;
|
|
isActive: boolean;
|
|
}
|
|
|
|
interface Route {
|
|
id: string;
|
|
sourceGroupId: string;
|
|
targetGroupId: string;
|
|
sourceGroup: { name: string };
|
|
targetGroup: { name: string };
|
|
}
|
|
|
|
export function RouteManager({
|
|
groups,
|
|
initialRoutes,
|
|
}: {
|
|
groups: Group[];
|
|
initialRoutes: Route[];
|
|
}) {
|
|
const [routes, setRoutes] = useState<Route[]>(initialRoutes);
|
|
const [sourceId, setSourceId] = useState('');
|
|
const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | 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;
|
|
if (!grouped.has(key)) grouped.set(key, { sourceId: r.sourceGroupId, targets: [] });
|
|
grouped.get(key)!.targets.push(r);
|
|
}
|
|
|
|
function toggleTarget(id: string) {
|
|
setTargetIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
setError(null);
|
|
}
|
|
|
|
async function addRoutes() {
|
|
if (!sourceId || targetIds.size === 0) return;
|
|
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;
|
|
}
|
|
const created: Route[] = await res.json();
|
|
setRoutes((prev) => [...created, ...prev]);
|
|
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));
|
|
}
|
|
|
|
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6">
|
|
<section>
|
|
<h2 className="text-base font-semibold mb-3">Active sync routes</h2>
|
|
{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>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<h2 className="text-base font-semibold mb-3">Add routes</h2>
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex gap-2 items-center">
|
|
<select
|
|
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) => (
|
|
<option key={g.id} value={g.id}>{g.name}</option>
|
|
))}
|
|
</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>
|
|
</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>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{error}</p>
|
|
)}
|
|
|
|
<button
|
|
onClick={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"
|
|
>
|
|
{busy ? 'Creating…' : `Create ${targetIds.size} route${targetIds.size !== 1 ? 's' : ''}`}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|