wulf-pulse/app/admin/workflow/rules/page.tsx

365 lines
15 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
ArrowLeft,
ListFilter,
Plus,
Pencil,
Trash2,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
import { WorkflowRuleWithDetails, ConditionOperator } from '@/lib/types/workflow';
const CONDITION_FIELDS = [
'person_id', 'creator_resource_id', 'ticket_category', 'company_id',
'title', 'description', 'priority', 'ticket_type', 'status', 'queue_id',
];
const CONDITION_OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: 'equals', label: 'Equals' },
{ value: 'not_equals', label: 'Not Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Not Contains' },
{ value: 'is_null', label: 'Is Null' },
{ value: 'is_not_null', label: 'Is Not Null' },
];
export default function WorkflowRulesPage() {
const [rules, setRules] = useState<WorkflowRuleWithDetails[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingRule, setEditingRule] = useState<any | null>(null);
const [isSaving, setIsSaving] = useState(false);
useEffect(() => { loadRules(); }, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/rules');
if (res.ok) setRules(await res.json());
} catch { toast.error('Failed to load rules'); }
finally { setIsLoading(false); }
};
const handleCreate = () => {
setEditingRule({
name: '',
description: '',
trigger_event: 'ticket.created',
is_active: true,
sort_order: 0,
stop_processing: true,
conditions: [{ condition_group: 0, field: 'ticket_category', operator: 'in', value: [] }],
actions: [{ sort_order: 0, action_type: 'skip', config: { reason: '' } }],
});
};
const handleEdit = (rule: WorkflowRuleWithDetails) => {
setEditingRule({
...rule,
conditions: rule.conditions.map(c => ({ ...c, value: c.value })),
actions: rule.actions.map(a => ({ ...a })),
});
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
const isUpdate = editingRule.id;
const url = isUpdate ? `/api/workflow/rules/${editingRule.id}` : '/api/workflow/rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editingRule),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch { toast.error('Failed to save rule'); }
finally { setIsSaving(false); }
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this workflow rule and all its conditions?')) return;
try {
const res = await fetch(`/api/workflow/rules/${id}`, { method: 'DELETE' });
if (res.ok) { toast.success('Rule deleted'); loadRules(); }
} catch { toast.error('Failed to delete rule'); }
};
const handleToggle = async (rule: WorkflowRuleWithDetails) => {
try {
await fetch(`/api/workflow/rules/${rule.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: !rule.is_active }),
});
loadRules();
} catch { toast.error('Failed to toggle rule'); }
};
const summarizeConditions = (rule: WorkflowRuleWithDetails): string => {
return rule.conditions.map(c => `${c.field} ${c.operator} ${JSON.stringify(c.value)}`).join(' AND ');
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/admin/workflow">
<Button variant="ghost" size="sm">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<ListFilter className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Filter Rules</h1>
<p className="text-sm text-muted-foreground">Exclusion/inclusion filters for ticket processing</p>
</div>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Active Filter Rules</CardTitle>
<CardDescription>Rules are evaluated in sort order. Matching a &quot;skip&quot; action stops processing.</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8"><Loader2 className="w-6 h-6 animate-spin" /></div>
) : rules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">No filter rules configured.</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Trigger</TableHead>
<TableHead>Conditions</TableHead>
<TableHead>Action</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell><Badge variant="outline">{rule.trigger_event}</Badge></TableCell>
<TableCell>
<div className="text-xs text-muted-foreground max-w-xs truncate" title={summarizeConditions(rule)}>
{summarizeConditions(rule)}
</div>
</TableCell>
<TableCell>
{rule.actions.map(a => (
<Badge key={a.id} variant="secondary" className="text-xs">{a.action_type}</Badge>
))}
</TableCell>
<TableCell>
<Switch checked={rule.is_active} onCheckedChange={() => handleToggle(rule)} />
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={() => handleEdit(rule)}>
<Pencil className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(rule.id)}>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Edit Dialog */}
<Dialog open={!!editingRule} onOpenChange={(open) => !open && setEditingRule(null)}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editingRule?.id ? 'Edit' : 'New'} Filter Rule</DialogTitle>
<DialogDescription>Configure conditions that determine which tickets to process or skip</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Name</Label>
<Input value={editingRule.name} onChange={(e) => setEditingRule({ ...editingRule, name: e.target.value })} />
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input type="number" value={editingRule.sort_order} onChange={(e) => setEditingRule({ ...editingRule, sort_order: parseInt(e.target.value) })} />
</div>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input value={editingRule.description || ''} onChange={(e) => setEditingRule({ ...editingRule, description: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Trigger Event</Label>
<Select value={editingRule.trigger_event} onValueChange={(v) => setEditingRule({ ...editingRule, trigger_event: v })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="ticket.created">Ticket Created</SelectItem>
<SelectItem value="ticket.updated">Ticket Updated</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch checked={editingRule.stop_processing} onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_processing: v })} />
<Label>Stop processing on match</Label>
</div>
</div>
{/* Conditions */}
<div className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h4 className="font-medium">Conditions</h4>
<Button variant="outline" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: [...editingRule.conditions, { condition_group: 0, field: 'ticket_category', operator: 'equals', value: '' }],
});
}}>
<Plus className="w-3 h-3 mr-1" /> Add
</Button>
</div>
{editingRule.conditions.map((cond: any, i: number) => (
<div key={i} className="grid grid-cols-4 gap-2 items-end">
<Select value={cond.field} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], field: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_FIELDS.map(f => <SelectItem key={f} value={f}>{f}</SelectItem>)}
</SelectContent>
</Select>
<Select value={cond.operator} onValueChange={(v) => {
const updated = [...editingRule.conditions];
updated[i] = { ...updated[i], operator: v };
setEditingRule({ ...editingRule, conditions: updated });
}}>
<SelectTrigger className="text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CONDITION_OPERATORS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
<Input
className="text-xs"
value={typeof cond.value === 'string' ? cond.value : JSON.stringify(cond.value)}
onChange={(e) => {
const updated = [...editingRule.conditions];
let val: any = e.target.value;
try { val = JSON.parse(val); } catch {}
updated[i] = { ...updated[i], value: val };
setEditingRule({ ...editingRule, conditions: updated });
}}
placeholder="value or [1,2,3]"
/>
<Button variant="ghost" size="sm" onClick={() => {
setEditingRule({
...editingRule,
conditions: editingRule.conditions.filter((_: any, j: number) => j !== i),
});
}}>
<Trash2 className="w-3 h-3 text-red-500" />
</Button>
</div>
))}
</div>
{/* Action */}
<div className="border rounded-lg p-4 space-y-3">
<h4 className="font-medium">Action</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Action Type</Label>
<Select value={editingRule.actions[0]?.action_type || 'skip'} onValueChange={(v) => {
const actions = [{ ...editingRule.actions[0], action_type: v }];
setEditingRule({ ...editingRule, actions });
}}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="skip">Skip (exclude from triage)</SelectItem>
<SelectItem value="classify">Classify</SelectItem>
<SelectItem value="set_field">Set Field</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Reason / Config</Label>
<Input
value={editingRule.actions[0]?.config?.reason || ''}
onChange={(e) => {
const actions = [{ ...editingRule.actions[0], config: { reason: e.target.value } }];
setEditingRule({ ...editingRule, actions });
}}
placeholder="Skip reason"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setEditingRule(null)}>Cancel</Button>
<Button onClick={handleSave} disabled={isSaving || !editingRule.name}>
{isSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
{editingRule.id ? 'Update' : 'Create'} Rule
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
);
}