510 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|