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:
JSC
2025-08-29 00:09:45 +02:00
parent 6a40311a82
commit 009780e64c
10 changed files with 1205 additions and 0 deletions

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