feat: add schedulers feature with task management
- Introduced SchedulersPage for managing scheduled tasks. - Implemented CreateTaskDialog for creating new scheduled tasks. - Added SchedulersHeader for filtering and searching tasks. - Created SchedulersTable to display scheduled tasks with actions. - Implemented loading and error states with SchedulersLoadingStates. - Added API service for task management in schedulers. - Enhanced date formatting utility to handle timezone. - Updated AppSidebar and AppRoutes to include SchedulersPage.
This commit is contained in:
308
src/components/schedulers/CreateTaskDialog.tsx
Normal file
308
src/components/schedulers/CreateTaskDialog.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
type CreateScheduledTaskRequest,
|
||||
type RecurrenceType,
|
||||
type TaskType,
|
||||
getRecurrenceTypeLabel,
|
||||
getTaskTypeLabel,
|
||||
} from '@/lib/api/services/schedulers'
|
||||
import { getSupportedTimezones } from '@/utils/locale'
|
||||
import { useLocale } from '@/hooks/use-locale'
|
||||
import { CalendarPlus, Loader2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
loading: boolean
|
||||
onSubmit: (data: CreateScheduledTaskRequest) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const TASK_TYPES: TaskType[] = ['CREDIT_RECHARGE', 'PLAY_SOUND', 'PLAY_PLAYLIST']
|
||||
const RECURRENCE_TYPES: RecurrenceType[] = ['NONE', 'HOURLY', 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY', 'CRON']
|
||||
|
||||
export function CreateTaskDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
loading,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CreateTaskDialogProps) {
|
||||
const { timezone } = useLocale()
|
||||
|
||||
const [formData, setFormData] = useState<CreateScheduledTaskRequest>({
|
||||
name: '',
|
||||
task_type: 'PLAY_SOUND',
|
||||
scheduled_at: '',
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'NONE',
|
||||
cron_expression: null,
|
||||
recurrence_count: null,
|
||||
expires_at: null,
|
||||
})
|
||||
|
||||
const [parametersJson, setParametersJson] = useState('{}')
|
||||
const [parametersError, setParametersError] = useState<string | null>(null)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate parameters JSON
|
||||
try {
|
||||
const parameters = JSON.parse(parametersJson)
|
||||
onSubmit({
|
||||
...formData,
|
||||
parameters,
|
||||
})
|
||||
} catch {
|
||||
setParametersError('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
|
||||
const handleParametersChange = (value: string) => {
|
||||
setParametersJson(value)
|
||||
setParametersError(null)
|
||||
|
||||
// Try to parse JSON to validate
|
||||
try {
|
||||
JSON.parse(value)
|
||||
} catch {
|
||||
if (value.trim()) {
|
||||
setParametersError('Invalid JSON format')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
task_type: 'PLAY_SOUND',
|
||||
scheduled_at: '',
|
||||
timezone: timezone,
|
||||
parameters: {},
|
||||
recurrence_type: 'NONE',
|
||||
cron_expression: null,
|
||||
recurrence_count: null,
|
||||
expires_at: null,
|
||||
})
|
||||
setParametersJson('{}')
|
||||
setParametersError(null)
|
||||
onCancel()
|
||||
}
|
||||
|
||||
const getDefaultScheduledTime = () => {
|
||||
const now = new Date()
|
||||
now.setMinutes(now.getMinutes() + 10) // Default to 10 minutes from now
|
||||
|
||||
// Format the time in the user's timezone
|
||||
const formatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
|
||||
const parts = formatter.formatToParts(now)
|
||||
const partsObj = parts.reduce((acc, part) => {
|
||||
acc[part.type] = part.value
|
||||
return acc
|
||||
}, {} as Record<string, string>)
|
||||
|
||||
// Return in datetime-local format (YYYY-MM-DDTHH:MM)
|
||||
return `${partsObj.year}-${partsObj.month}-${partsObj.day}T${partsObj.hour}:${partsObj.minute}`
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CalendarPlus className="h-5 w-5" />
|
||||
Create Scheduled Task
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Task Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Enter task name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="task_type">Task Type</Label>
|
||||
<Select
|
||||
value={formData.task_type}
|
||||
onValueChange={(value: TaskType) => setFormData(prev => ({ ...prev, task_type: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TASK_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getTaskTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="scheduled_at">Scheduled At</Label>
|
||||
<Input
|
||||
id="scheduled_at"
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_at || getDefaultScheduledTime()}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, scheduled_at: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Select
|
||||
value={formData.timezone}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, timezone: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="UTC">UTC</SelectItem>
|
||||
{getSupportedTimezones().map((tz) => (
|
||||
<SelectItem key={tz} value={tz}>
|
||||
{tz.replace('_', ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrence_type">Recurrence</Label>
|
||||
<Select
|
||||
value={formData.recurrence_type}
|
||||
onValueChange={(value: RecurrenceType) => setFormData(prev => ({ ...prev, recurrence_type: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECURRENCE_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getRecurrenceTypeLabel(type)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.recurrence_type === 'CRON' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cron_expression">Cron Expression</Label>
|
||||
<Input
|
||||
id="cron_expression"
|
||||
value={formData.cron_expression || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, cron_expression: e.target.value }))}
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.recurrence_type !== 'NONE' && formData.recurrence_type !== 'CRON' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="recurrence_count">Max Executions</Label>
|
||||
<Input
|
||||
id="recurrence_count"
|
||||
type="number"
|
||||
value={formData.recurrence_count || ''}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
recurrence_count: e.target.value ? parseInt(e.target.value) : null
|
||||
}))}
|
||||
placeholder="Leave empty for infinite"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="expires_at">Expires At (Optional)</Label>
|
||||
<Input
|
||||
id="expires_at"
|
||||
type="datetime-local"
|
||||
value={formData.expires_at || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value || null }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parameters">Task Parameters</Label>
|
||||
<Textarea
|
||||
id="parameters"
|
||||
value={parametersJson}
|
||||
onChange={(e) => handleParametersChange(e.target.value)}
|
||||
placeholder='{"sound_id": 123, "playlist_id": 456}'
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
{parametersError && (
|
||||
<p className="text-sm text-destructive">{parametersError}</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter task-specific parameters as JSON. For PLAY_SOUND use {"sound_id"}, for PLAY_PLAYLIST use {"playlist_id"}.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !!parametersError}>
|
||||
{loading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create Task
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user