365 lines
15 KiB
TypeScript
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 "skip" 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>
|
|
);
|
|
}
|