wulf-pulse/app/admin/workflow/[id]/page.tsx

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>
);
}