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

510 lines
20 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
ArrowLeft,
Bot,
Plus,
Pencil,
Trash2,
Loader2,
GitBranch,
Tag,
AlertTriangle,
Layers,
Route,
} from 'lucide-react';
import { toast } from 'sonner';
import { ClassificationRule, RuleType, MatchField, MatchOperator, ConfidenceLevel } from '@/lib/types/workflow';
const RULE_TYPES: { value: RuleType; label: string; icon: any; description: string }[] = [
{ value: 'branch_routing', label: 'Branch Routing', icon: GitBranch, description: 'Route tickets to NOC, SOC, or Service Desk' },
{ value: 'ticket_type', label: 'Ticket Type', icon: Tag, description: 'Classify as Incident or Service Request' },
{ value: 'issue_classification', label: 'Issue Classification', icon: Layers, description: 'Assign Issue Type and Sub-Issue Type' },
{ value: 'priority', label: 'Priority', icon: AlertTriangle, description: 'Set ticket priority level' },
{ value: 'queue_routing', label: 'Queue Routing', icon: Route, description: 'Route to the appropriate queue' },
];
const MATCH_FIELDS: { value: MatchField; label: string }[] = [
{ value: 'title', label: 'Title' },
{ value: 'description', label: 'Description' },
{ value: 'title_or_description', label: 'Title or Description' },
{ value: 'ticket_category', label: 'Ticket Category' },
{ value: 'ticket_type', label: 'Ticket Type' },
{ value: 'priority', label: 'Priority' },
{ value: 'policy_name', label: 'Policy Name' },
{ value: 'device_name', label: 'Device Name' },
{ value: 'creator_resource_id', label: 'Creator Resource ID' },
{ value: 'person_id', label: 'Person ID' },
{ value: 'company_id', label: 'Company ID' },
];
const MATCH_OPERATORS: { value: MatchOperator; label: string }[] = [
{ value: 'contains', label: 'Contains (any of)' },
{ value: 'starts_with', label: 'Starts With' },
{ value: 'regex', label: 'Regex' },
{ value: 'equals', label: 'Equals' },
{ value: 'in', label: 'In List' },
{ value: 'not_in', label: 'Not In List' },
];
const defaultRule: Partial<ClassificationRule> = {
name: '',
description: '',
rule_type: 'branch_routing',
sort_order: 0,
is_active: true,
match_field: 'title_or_description',
match_operator: 'contains',
match_value: [],
match_case_sensitive: false,
result_field: 'branch',
result_value: '',
result_field_2: null,
result_value_2: null,
confidence: 'high',
stop_on_match: true,
};
export default function ClassificationRulesPage() {
const [rules, setRules] = useState<ClassificationRule[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RuleType>('branch_routing');
const [editingRule, setEditingRule] = useState<Partial<ClassificationRule> | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [matchValueText, setMatchValueText] = useState('');
useEffect(() => {
loadRules();
}, []);
const loadRules = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/workflow/classification-rules');
if (res.ok) {
setRules(await res.json());
}
} catch (error) {
toast.error('Failed to load classification rules');
} finally {
setIsLoading(false);
}
};
const handleCreate = () => {
const newRule = { ...defaultRule, rule_type: activeTab };
// Set default result field based on rule type
switch (activeTab) {
case 'branch_routing': newRule.result_field = 'branch'; break;
case 'ticket_type': newRule.result_field = 'ticket_type'; break;
case 'issue_classification': newRule.result_field = 'issue_type'; newRule.result_field_2 = 'sub_issue_type'; break;
case 'priority': newRule.result_field = 'priority'; break;
case 'queue_routing': newRule.result_field = 'queue_id'; break;
}
setEditingRule(newRule);
setMatchValueText(Array.isArray(newRule.match_value) ? newRule.match_value.join('\n') : String(newRule.match_value || ''));
};
const handleEdit = (rule: ClassificationRule) => {
setEditingRule({ ...rule });
const mv = rule.match_value;
setMatchValueText(Array.isArray(mv) ? mv.join('\n') : String(mv || ''));
};
const handleSave = async () => {
if (!editingRule) return;
setIsSaving(true);
try {
// Parse match value based on operator
let parsedMatchValue: any = matchValueText;
if (['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')) {
parsedMatchValue = matchValueText.split('\n').map(s => s.trim()).filter(Boolean);
}
const payload = {
...editingRule,
match_value: parsedMatchValue,
};
const isUpdate = 'id' in editingRule && editingRule.id;
const url = isUpdate
? `/api/workflow/classification-rules/${editingRule.id}`
: '/api/workflow/classification-rules';
const res = await fetch(url, {
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
toast.success(isUpdate ? 'Rule updated' : 'Rule created');
setEditingRule(null);
loadRules();
} else {
toast.error('Failed to save rule');
}
} catch (error) {
toast.error('Failed to save rule');
} finally {
setIsSaving(false);
}
};
const handleDelete = async (id: number) => {
if (!confirm('Delete this classification rule?')) return;
try {
const res = await fetch(`/api/workflow/classification-rules/${id}`, { method: 'DELETE' });
if (res.ok) {
toast.success('Rule deleted');
loadRules();
}
} catch {
toast.error('Failed to delete rule');
}
};
const handleToggleActive = async (rule: ClassificationRule) => {
try {
await fetch(`/api/workflow/classification-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 filteredRules = rules.filter(r => r.rule_type === activeTab);
const formatMatchValue = (value: any): string => {
if (Array.isArray(value)) return value.join(', ');
return String(value);
};
return (
<div className="container mx-auto p-6 space-y-6">
{/* Header */}
<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>
<Bot className="w-6 h-6" />
<div>
<h1 className="text-2xl font-bold">Classification Rules</h1>
<p className="text-sm text-muted-foreground">DB-driven keyword classification for ticket triage</p>
</div>
</div>
</div>
{/* Tabs by rule type */}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as RuleType)}>
<TabsList className="grid grid-cols-5 w-full">
{RULE_TYPES.map((rt) => (
<TabsTrigger key={rt.value} value={rt.value} className="text-xs sm:text-sm">
<rt.icon className="w-4 h-4 mr-1 hidden sm:inline" />
{rt.label}
</TabsTrigger>
))}
</TabsList>
{RULE_TYPES.map((rt) => (
<TabsContent key={rt.value} value={rt.value}>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<rt.icon className="w-5 h-5" />
{rt.label} Rules
</CardTitle>
<CardDescription>{rt.description}</CardDescription>
</div>
<Button onClick={handleCreate} size="sm">
<Plus className="w-4 h-4 mr-2" />
New Rule
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : filteredRules.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No {rt.label.toLowerCase()} rules configured.
</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-16">Order</TableHead>
<TableHead>Name</TableHead>
<TableHead>Pattern</TableHead>
<TableHead>Result</TableHead>
<TableHead className="w-24">Confidence</TableHead>
<TableHead className="w-20">Active</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRules.map((rule) => (
<TableRow key={rule.id}>
<TableCell className="font-mono text-sm">{rule.sort_order}</TableCell>
<TableCell className="font-medium">{rule.name}</TableCell>
<TableCell>
<div className="text-xs space-y-1">
<Badge variant="outline" className="text-xs">{rule.match_field}</Badge>
<Badge variant="secondary" className="text-xs ml-1">{rule.match_operator}</Badge>
<div className="text-muted-foreground truncate max-w-xs" title={formatMatchValue(rule.match_value)}>
{formatMatchValue(rule.match_value)}
</div>
</div>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="font-medium">{rule.result_field}:</span> {String(rule.result_value)}
{rule.result_field_2 && (
<div className="text-xs text-muted-foreground">
{rule.result_field_2}: {String(rule.result_value_2)}
</div>
)}
</div>
</TableCell>
<TableCell>
<Badge variant={
rule.confidence === 'high' ? 'default' :
rule.confidence === 'medium' ? 'secondary' : 'outline'
}>
{rule.confidence}
</Badge>
</TableCell>
<TableCell>
<Switch
checked={rule.is_active}
onCheckedChange={() => handleToggleActive(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>
</TabsContent>
))}
</Tabs>
{/* Edit/Create 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'} Classification Rule</DialogTitle>
<DialogDescription>Configure pattern matching and classification result</DialogDescription>
</DialogHeader>
{editingRule && (
<div className="space-y-4">
{/* Basic Info */}
<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 })}
placeholder="Rule name"
/>
</div>
<div className="space-y-2">
<Label>Sort Order</Label>
<Input
type="number"
value={editingRule.sort_order ?? 0}
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 })}
placeholder="Optional description"
/>
</div>
{/* Pattern Matching */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Pattern Matching</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Match Field</Label>
<Select
value={editingRule.match_field}
onValueChange={(v) => setEditingRule({ ...editingRule, match_field: v as MatchField })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_FIELDS.map((f) => (
<SelectItem key={f.value} value={f.value}>{f.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Operator</Label>
<Select
value={editingRule.match_operator}
onValueChange={(v) => setEditingRule({ ...editingRule, match_operator: v as MatchOperator })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MATCH_OPERATORS.map((o) => (
<SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>
Match Values
{['contains', 'in', 'not_in'].includes(editingRule.match_operator || '') && (
<span className="text-xs text-muted-foreground ml-2">(one per line)</span>
)}
</Label>
<Textarea
value={matchValueText}
onChange={(e) => setMatchValueText(e.target.value)}
placeholder={['contains', 'in', 'not_in'].includes(editingRule.match_operator || '')
? 'keyword1\nkeyword2\nkeyword3'
: 'value'
}
rows={4}
/>
</div>
</div>
{/* Result */}
<div className="border rounded-lg p-4 space-y-4">
<h4 className="font-medium">Classification Result</h4>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Result Field</Label>
<Input
value={editingRule.result_field || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Result Value</Label>
<Input
value={String(editingRule.result_value || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value: e.target.value })}
/>
</div>
</div>
{editingRule.rule_type === 'issue_classification' && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Secondary Field</Label>
<Input
value={editingRule.result_field_2 || ''}
onChange={(e) => setEditingRule({ ...editingRule, result_field_2: e.target.value })}
placeholder="sub_issue_type"
/>
</div>
<div className="space-y-2">
<Label>Secondary Value</Label>
<Input
value={String(editingRule.result_value_2 || '')}
onChange={(e) => setEditingRule({ ...editingRule, result_value_2: e.target.value })}
/>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Confidence</Label>
<Select
value={editingRule.confidence}
onValueChange={(v) => setEditingRule({ ...editingRule, confidence: v as ConfidenceLevel })}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2 pb-2">
<Switch
checked={editingRule.stop_on_match ?? true}
onCheckedChange={(v) => setEditingRule({ ...editingRule, stop_on_match: v })}
/>
<Label>Stop on match</Label>
</div>
</div>
</div>
{/* Save */}
<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>
);
}