feat: Add plugin configuration editing with template variable parsing

## Backend Changes:
- Enhanced PluginTemplate.save() to auto-parse template variables from plugin code
- Updated PluginInstallationSerializer to expose template metadata (description, category, version, author, logo, template_variables)
- Fixed template variable parser to handle nested {{ }} braces in default values
- Added brace-counting algorithm to properly extract variables with insertion codes
- Fixed explicit type parameter detection (textarea, text, email, etc.)
- Made scheduled_task optional on PluginInstallation model
- Added EventPlugin through model for event-plugin relationships
- Added Event.execute_plugins() method for plugin automation

## Frontend Changes:
- Created Tasks.tsx page for managing scheduled tasks
- Enhanced MyPlugins page with clickable plugin cards
- Added edit configuration modal with dynamic form generation
- Implemented escape sequence handling (convert \n, \', etc. for display)
- Added plugin logos to My Plugins page
- Updated type definitions for PluginInstallation interface
- Added insertion code documentation to Plugin Docs

## Plugin System:
- All platform plugins now have editable email templates with textarea support
- Template variables properly parsed with full default values
- Insertion codes ({{CUSTOMER_NAME}}, {{BUSINESS_NAME}}, etc.) documented
- Plugin logos displayed in marketplace and My Plugins

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-11-28 23:45:55 -05:00
parent 0f46862125
commit 9b106bf129
38 changed files with 2859 additions and 116 deletions

View File

@@ -0,0 +1,365 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import axios from '../api/client';
import {
Plus,
Play,
Pause,
Trash2,
Edit,
Clock,
Calendar,
RotateCw,
CheckCircle2,
XCircle,
AlertCircle,
Zap,
} from 'lucide-react';
import toast from 'react-hot-toast';
// Types
interface ScheduledTask {
id: string;
name: string;
description: string;
plugin_name: string;
plugin_display_name: string;
schedule_type: 'ONE_TIME' | 'INTERVAL' | 'CRON';
cron_expression?: string;
interval_minutes?: number;
run_at?: string;
next_run_time?: string;
last_run_time?: string;
is_active: boolean;
plugin_config: Record<string, any>;
created_at: string;
updated_at: string;
}
const Tasks: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false);
// Fetch tasks
const { data: tasks = [], isLoading } = useQuery<ScheduledTask[]>({
queryKey: ['scheduled-tasks'],
queryFn: async () => {
const { data } = await axios.get('/api/scheduled-tasks/');
return data;
},
});
// Delete task
const deleteMutation = useMutation({
mutationFn: async (taskId: string) => {
await axios.delete(`/api/scheduled-tasks/${taskId}/`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
toast.success('Task deleted successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to delete task');
},
});
// Toggle task active status
const toggleActiveMutation = useMutation({
mutationFn: async ({ taskId, isActive }: { taskId: string; isActive: boolean }) => {
await axios.patch(`/api/scheduled-tasks/${taskId}/`, { is_active: isActive });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduled-tasks'] });
toast.success('Task updated successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to update task');
},
});
// Trigger task manually
const triggerMutation = useMutation({
mutationFn: async (taskId: string) => {
await axios.post(`/api/scheduled-tasks/${taskId}/trigger/`);
},
onSuccess: () => {
toast.success('Task triggered successfully');
},
onError: (error: any) => {
toast.error(error.response?.data?.detail || 'Failed to trigger task');
},
});
const getScheduleDisplay = (task: ScheduledTask): string => {
if (task.schedule_type === 'ONE_TIME') {
return `Once on ${new Date(task.run_at!).toLocaleString()}`;
} else if (task.schedule_type === 'INTERVAL') {
const hours = Math.floor(task.interval_minutes! / 60);
const mins = task.interval_minutes! % 60;
if (hours > 0 && mins > 0) {
return `Every ${hours}h ${mins}m`;
} else if (hours > 0) {
return `Every ${hours} hour${hours > 1 ? 's' : ''}`;
} else {
return `Every ${mins} minute${mins > 1 ? 's' : ''}`;
}
} else {
return task.cron_expression || 'Custom schedule';
}
};
const getScheduleIcon = (task: ScheduledTask) => {
if (task.schedule_type === 'ONE_TIME') return Calendar;
if (task.schedule_type === 'INTERVAL') return RotateCw;
return Clock;
};
const getStatusColor = (task: ScheduledTask): string => {
if (!task.is_active) return 'text-gray-400 dark:text-gray-500';
if (task.last_run_time) return 'text-green-600 dark:text-green-400';
return 'text-blue-600 dark:text-blue-400';
};
const getStatusIcon = (task: ScheduledTask) => {
if (!task.is_active) return Pause;
if (task.last_run_time) return CheckCircle2;
return AlertCircle;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{t('Tasks')}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Schedule and manage automated plugin executions
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
New Task
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Zap className="w-5 h-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Total Tasks</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">{tasks.length}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Active</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => t.is_active).length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
<Pause className="w-5 h-5 text-gray-600 dark:text-gray-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Paused</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => !t.is_active).length}
</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<RotateCw className="w-5 h-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Recurring</p>
<p className="text-2xl font-bold text-gray-900 dark:text-white">
{tasks.filter(t => t.schedule_type !== 'ONE_TIME').length}
</p>
</div>
</div>
</div>
</div>
{/* Tasks List */}
{tasks.length === 0 ? (
<div className="text-center py-16 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<Zap className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
No tasks yet
</h3>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Create your first automated task to get started
</p>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="w-5 h-5" />
Create Task
</button>
</div>
) : (
<div className="space-y-4">
{tasks.map((task) => {
const ScheduleIcon = getScheduleIcon(task);
const StatusIcon = getStatusIcon(task);
return (
<div
key={task.id}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-6 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{task.name}
</h3>
<StatusIcon className={`w-5 h-5 ${getStatusColor(task)}`} />
</div>
{task.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm mb-3">
{task.description}
</p>
)}
<div className="flex flex-wrap items-center gap-4 text-sm">
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Zap className="w-4 h-4" />
<span>{task.plugin_display_name}</span>
</div>
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<ScheduleIcon className="w-4 h-4" />
<span>{getScheduleDisplay(task)}</span>
</div>
{task.next_run_time && task.is_active && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<Clock className="w-4 h-4" />
<span>Next: {new Date(task.next_run_time).toLocaleString()}</span>
</div>
)}
{task.last_run_time && (
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
<CheckCircle2 className="w-4 h-4" />
<span>Last: {new Date(task.last_run_time).toLocaleString()}</span>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 ml-4">
<button
onClick={() => triggerMutation.mutate(task.id)}
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
title="Run now"
>
<Play className="w-5 h-5" />
</button>
<button
onClick={() => toggleActiveMutation.mutate({
taskId: task.id,
isActive: !task.is_active
})}
className={`p-2 rounded-lg transition-colors ${
task.is_active
? 'text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20'
: 'text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900/20'
}`}
title={task.is_active ? 'Pause' : 'Resume'}
>
{task.is_active ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5" />}
</button>
<button
onClick={() => {/* TODO: Edit modal */}}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Edit"
>
<Edit className="w-5 h-5" />
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to delete this task?')) {
deleteMutation.mutate(task.id);
}
}}
className="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
title="Delete"
>
<Trash2 className="w-5 h-5" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* TODO: Create/Edit Task Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg max-w-2xl w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Create New Task
</h2>
<p className="text-gray-600 dark:text-gray-400">
Task creation form coming soon...
</p>
<div className="mt-6 flex justify-end gap-3">
<button
onClick={() => setShowCreateModal(false)}
className="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Tasks;