526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, use } 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 { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
ArrowLeft,
|
|
Save,
|
|
Play,
|
|
Trash2,
|
|
Plus,
|
|
GripVertical,
|
|
Settings,
|
|
Zap,
|
|
CheckCircle2,
|
|
XCircle,
|
|
Clock,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
} from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
interface TicketWorkflow {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
is_active: boolean;
|
|
trigger_event: string;
|
|
trigger_conditions: any[];
|
|
sort_order: number;
|
|
}
|
|
|
|
interface TicketWorkflowStep {
|
|
id?: number;
|
|
workflow_id?: number;
|
|
step_order: number;
|
|
step_type: string;
|
|
name: string;
|
|
config: Record<string, any>;
|
|
on_failure: 'continue' | 'stop' | 'skip_to';
|
|
skip_to_step: number | null;
|
|
is_active: boolean;
|
|
condition: any | null;
|
|
}
|
|
|
|
const STEP_TYPES = [
|
|
{ value: 'classify', label: 'Classify', color: 'bg-purple-100 text-purple-900 border-purple-200', description: 'Match keywords to classify tickets (branch, type, issue, priority, queue)' },
|
|
{ value: 'validate', label: 'Validate', color: 'bg-yellow-100 text-yellow-900 border-yellow-200', description: 'Validate classification results against database picklists' },
|
|
{ value: 'ai_classify', label: 'AI Classify', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Use AI to classify fields that robotic classification missed' },
|
|
{ value: 'ai_title', label: 'AI Title', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Clean up messy ticket titles using AI' },
|
|
{ value: 'ai_troubleshooting', label: 'AI Troubleshooting', color: 'bg-blue-100 text-blue-900 border-blue-200', description: 'Generate troubleshooting steps for incidents' },
|
|
{ value: 'delay', label: 'Delay', color: 'bg-gray-100 text-gray-900 border-gray-200', description: 'Wait N milliseconds before continuing' },
|
|
{ value: 'update_ticket', label: 'Update Ticket', color: 'bg-green-100 text-green-900 border-green-200', description: 'Write field changes back to Autotask' },
|
|
];
|
|
|
|
const STEP_HELP: Record<string, { purpose: string; config: string; example: string }> = {
|
|
classify: {
|
|
purpose: 'Uses keyword-based classification rules from the classification_rules table to automatically categorize tickets',
|
|
config: 'rule_type: branch_routing|ticket_type|issue_classification|priority|queue_routing\nresult_field: where to store the result\nresult_field_2: (optional) for sub_issue_type\ndefault_value: fallback if no rules match',
|
|
example: '{"rule_type": "branch_routing", "result_field": "branch", "default_value": "service_desk"}'
|
|
},
|
|
validate: {
|
|
purpose: 'Validates classification results against database picklists to ensure data integrity',
|
|
config: 'required_fields: (optional) array of fields that must be present',
|
|
example: '{"required_fields": []}'
|
|
},
|
|
ai_classify: {
|
|
purpose: 'Uses AI to classify ambiguous fields when robotic classification fails validation',
|
|
config: 'template_purpose: ambiguous_classification\nskip_if_valid: skip if validation passed',
|
|
example: '{"template_purpose": "ambiguous_classification", "skip_if_valid": true}'
|
|
},
|
|
ai_title: {
|
|
purpose: 'Uses AI to clean up messy ticket titles (email subjects, too long, garbled text)',
|
|
config: 'template_purpose: title_cleanup',
|
|
example: '{"template_purpose": "title_cleanup"}'
|
|
},
|
|
ai_troubleshooting: {
|
|
purpose: 'Generates AI-powered troubleshooting steps for incidents',
|
|
config: 'template_purpose: troubleshooting_steps\ncreate_note: whether to create a ticket note',
|
|
example: '{"template_purpose": "troubleshooting_steps", "create_note": true}'
|
|
},
|
|
delay: {
|
|
purpose: 'Waits for a specified duration before continuing to the next step',
|
|
config: 'duration_ms: milliseconds to wait (supports templates like {{settings.autotask_update_delay_ms}})',
|
|
example: '{"duration_ms": "{{settings.autotask_update_delay_ms}}"}'
|
|
},
|
|
update_ticket: {
|
|
purpose: 'Writes all accumulated field_changes back to Autotask and updates local database',
|
|
config: 'use_field_changes: boolean (always true)',
|
|
example: '{"use_field_changes": true}'
|
|
},
|
|
};
|
|
|
|
export default function WorkflowEditorPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const resolvedParams = use(params);
|
|
const workflowId = resolvedParams.id;
|
|
|
|
const [workflow, setWorkflow] = useState<TicketWorkflow | null>(null);
|
|
const [steps, setSteps] = useState<TicketWorkflowStep[]>([]);
|
|
const [expandedStep, setExpandedStep] = useState<number | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadWorkflow();
|
|
}, [workflowId]);
|
|
|
|
const loadWorkflow = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const res = await fetch(`/api/ticket-workflows/${workflowId}`);
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
setWorkflow(data.workflow);
|
|
setSteps(data.steps || []);
|
|
} else {
|
|
toast.error('Failed to load workflow');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load workflow:', error);
|
|
toast.error('Failed to load workflow');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveWorkflow = async () => {
|
|
if (!workflow) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
// Save workflow metadata
|
|
const workflowRes = await fetch(`/api/ticket-workflows/${workflowId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: workflow.name,
|
|
description: workflow.description,
|
|
is_active: workflow.is_active,
|
|
trigger_event: workflow.trigger_event,
|
|
trigger_conditions: workflow.trigger_conditions,
|
|
}),
|
|
});
|
|
|
|
if (!workflowRes.ok) {
|
|
toast.error('Failed to save workflow');
|
|
return;
|
|
}
|
|
|
|
// Save steps (bulk replace)
|
|
const stepsRes = await fetch(`/api/ticket-workflows/${workflowId}/steps`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ steps }),
|
|
});
|
|
|
|
if (!stepsRes.ok) {
|
|
toast.error('Failed to save steps');
|
|
return;
|
|
}
|
|
|
|
toast.success('Workflow saved successfully');
|
|
loadWorkflow(); // Reload to get IDs for new steps
|
|
} catch (error) {
|
|
console.error('Failed to save workflow:', error);
|
|
toast.error('Failed to save workflow');
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const addStep = (stepType: string) => {
|
|
const newStep: TicketWorkflowStep = {
|
|
step_order: steps.length + 1,
|
|
step_type: stepType,
|
|
name: STEP_TYPES.find(t => t.value === stepType)?.label || stepType,
|
|
config: {},
|
|
on_failure: 'continue',
|
|
skip_to_step: null,
|
|
is_active: true,
|
|
condition: null,
|
|
};
|
|
setSteps([...steps, newStep]);
|
|
setExpandedStep(newStep.step_order);
|
|
};
|
|
|
|
const updateStep = (stepOrder: number, updates: Partial<TicketWorkflowStep>) => {
|
|
setSteps(steps.map(s => s.step_order === stepOrder ? { ...s, ...updates } : s));
|
|
};
|
|
|
|
const deleteStep = (stepOrder: number) => {
|
|
const filtered = steps.filter(s => s.step_order !== stepOrder);
|
|
// Renumber remaining steps
|
|
const renumbered = filtered.map((s, idx) => ({ ...s, step_order: idx + 1 }));
|
|
setSteps(renumbered);
|
|
};
|
|
|
|
const moveStep = (stepOrder: number, direction: 'up' | 'down') => {
|
|
const index = steps.findIndex(s => s.step_order === stepOrder);
|
|
if (index === -1) return;
|
|
if (direction === 'up' && index === 0) return;
|
|
if (direction === 'down' && index === steps.length - 1) return;
|
|
|
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
|
const reordered = [...steps];
|
|
[reordered[index], reordered[newIndex]] = [reordered[newIndex], reordered[index]];
|
|
|
|
// Renumber all steps
|
|
const renumbered = reordered.map((s, idx) => ({ ...s, step_order: idx + 1 }));
|
|
setSteps(renumbered);
|
|
};
|
|
|
|
if (isLoading || !workflow) {
|
|
return (
|
|
<div className="container mx-auto p-6">
|
|
<Card>
|
|
<CardContent className="py-12 text-center text-muted-foreground">
|
|
Loading workflow...
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<div>
|
|
<h1 className="text-2xl font-bold">{workflow.name}</h1>
|
|
<p className="text-sm text-muted-foreground">{workflow.description || 'No description'}</p>
|
|
</div>
|
|
<Badge variant={workflow.is_active ? 'default' : 'secondary'}>
|
|
{workflow.is_active ? 'Active' : 'Inactive'}
|
|
</Badge>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={saveWorkflow} disabled={isSaving}>
|
|
<Save className="w-4 h-4 mr-2" />
|
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs defaultValue="steps" className="w-full">
|
|
<TabsList className="grid w-full grid-cols-4">
|
|
<TabsTrigger value="steps">Steps ({steps.length})</TabsTrigger>
|
|
<TabsTrigger value="trigger">Trigger</TabsTrigger>
|
|
<TabsTrigger value="test">Test</TabsTrigger>
|
|
<TabsTrigger value="history">History</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Steps Tab */}
|
|
<TabsContent value="steps" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle>Workflow Steps</CardTitle>
|
|
<CardDescription>Define the step-by-step execution flow</CardDescription>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{STEP_TYPES.map(type => (
|
|
<Button
|
|
key={type.value}
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => addStep(type.value)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-1" />
|
|
{type.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-2">
|
|
{steps.length === 0 ? (
|
|
<p className="text-center text-muted-foreground py-8">
|
|
No steps yet. Add steps using the buttons above.
|
|
</p>
|
|
) : (
|
|
steps.map((step, index) => {
|
|
const stepTypeConfig = STEP_TYPES.find(t => t.value === step.step_type);
|
|
const isExpanded = expandedStep === step.step_order;
|
|
|
|
return (
|
|
<div key={step.step_order} className={`border rounded-lg ${stepTypeConfig?.color || ''}`}>
|
|
{/* Step Header */}
|
|
<div className="flex items-center justify-between p-3">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<div className="flex flex-col gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-4 p-0"
|
|
onClick={() => moveStep(step.step_order, 'up')}
|
|
disabled={index === 0}
|
|
>
|
|
<ChevronUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-4 p-0"
|
|
onClick={() => moveStep(step.step_order, 'down')}
|
|
disabled={index === steps.length - 1}
|
|
>
|
|
<ChevronDown className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
<span className="font-mono text-sm text-muted-foreground">#{step.step_order}</span>
|
|
<span className="font-medium">{step.name}</span>
|
|
<Badge variant="outline" className="text-xs">{step.step_type}</Badge>
|
|
{step.condition && <Badge variant="secondary" className="text-xs">Conditional</Badge>}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<Switch
|
|
checked={step.is_active}
|
|
onCheckedChange={(checked) => updateStep(step.step_order, { is_active: checked })}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setExpandedStep(isExpanded ? null : step.step_order)}
|
|
>
|
|
{isExpanded ? 'Collapse' : 'Expand'}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => deleteStep(step.step_order)}
|
|
>
|
|
<Trash2 className="w-4 h-4 text-red-500" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step Config (Expanded) */}
|
|
{isExpanded && (
|
|
<div className="border-t p-4 bg-white space-y-4">
|
|
{/* Step Help */}
|
|
{STEP_HELP[step.step_type] && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2">
|
|
<h4 className="font-semibold text-sm text-blue-900">What This Step Does</h4>
|
|
<p className="text-sm text-blue-800">{STEP_HELP[step.step_type].purpose}</p>
|
|
<div className="mt-2">
|
|
<h5 className="font-semibold text-xs text-blue-900 mb-1">Configuration Fields:</h5>
|
|
<pre className="text-xs text-blue-800 whitespace-pre-wrap">{STEP_HELP[step.step_type].config}</pre>
|
|
</div>
|
|
<div className="mt-2">
|
|
<h5 className="font-semibold text-xs text-blue-900 mb-1">Example:</h5>
|
|
<code className="text-xs text-blue-800 bg-white px-2 py-1 rounded">{STEP_HELP[step.step_type].example}</code>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<Label>Step Name</Label>
|
|
<Input
|
|
value={step.name}
|
|
onChange={(e) => updateStep(step.step_order, { name: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>On Failure</Label>
|
|
<select
|
|
className="w-full border rounded-md p-2"
|
|
value={step.on_failure}
|
|
onChange={(e) => updateStep(step.step_order, { on_failure: e.target.value as any })}
|
|
>
|
|
<option value="continue">Continue to next step</option>
|
|
<option value="stop">Stop workflow</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Configuration (JSON)</Label>
|
|
<Textarea
|
|
value={JSON.stringify(step.config, null, 2)}
|
|
onChange={(e) => {
|
|
try {
|
|
const parsed = JSON.parse(e.target.value);
|
|
updateStep(step.step_order, { config: parsed });
|
|
} catch {}
|
|
}}
|
|
rows={6}
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Edit the JSON configuration above. See the blue help box for available fields.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Trigger Tab */}
|
|
<TabsContent value="trigger" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Trigger Configuration</CardTitle>
|
|
<CardDescription>Define when this workflow should run</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div>
|
|
<Label>Workflow Name</Label>
|
|
<Input
|
|
value={workflow.name}
|
|
onChange={(e) => setWorkflow({ ...workflow, name: e.target.value })}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Description</Label>
|
|
<Textarea
|
|
value={workflow.description || ''}
|
|
onChange={(e) => setWorkflow({ ...workflow, description: e.target.value })}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Trigger Event</Label>
|
|
<select
|
|
className="w-full border rounded-md p-2"
|
|
value={workflow.trigger_event}
|
|
onChange={(e) => setWorkflow({ ...workflow, trigger_event: e.target.value })}
|
|
>
|
|
<option value="ticket.created">Ticket Created</option>
|
|
<option value="ticket.updated">Ticket Updated</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>Trigger Conditions (JSON)</Label>
|
|
<Textarea
|
|
value={JSON.stringify(workflow.trigger_conditions, null, 2)}
|
|
onChange={(e) => {
|
|
try {
|
|
const parsed = JSON.parse(e.target.value);
|
|
setWorkflow({ ...workflow, trigger_conditions: parsed });
|
|
} catch {}
|
|
}}
|
|
rows={10}
|
|
className="font-mono text-sm"
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Array of conditions: {`[{"field": "ticket_category", "operator": "in", "value": [2,3]}]`}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<Label>Workflow Active</Label>
|
|
<Switch
|
|
checked={workflow.is_active}
|
|
onCheckedChange={(checked) => setWorkflow({ ...workflow, is_active: checked })}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Test Tab */}
|
|
<TabsContent value="test" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Test Workflow</CardTitle>
|
|
<CardDescription>Run a dry-run test on a ticket without making changes</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">
|
|
Test functionality will be implemented here. Use the API endpoint directly:
|
|
<code className="block mt-2 p-2 bg-muted rounded text-sm">
|
|
POST /api/ticket-workflows/{workflowId}/test
|
|
</code>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* History Tab */}
|
|
<TabsContent value="history" className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Execution History</CardTitle>
|
|
<CardDescription>Recent workflow executions</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-muted-foreground">
|
|
Execution history will be loaded here from:
|
|
<code className="block mt-2 p-2 bg-muted rounded text-sm">
|
|
GET /api/ticket-workflows/{workflowId}/executions
|
|
</code>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|