320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
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)
|
|
|
|
// Send the datetime as UTC to prevent backend timezone conversion
|
|
// The user's selected time should be stored exactly as entered
|
|
let scheduledAt = formData.scheduled_at
|
|
if (scheduledAt.length === 16) {
|
|
scheduledAt += ':00' // Add seconds if missing
|
|
}
|
|
|
|
// Add Z to indicate this is already UTC time, preventing backend conversion
|
|
const scheduledAtUTC = scheduledAt + 'Z'
|
|
|
|
onSubmit({
|
|
...formData,
|
|
parameters,
|
|
scheduled_at: scheduledAtUTC,
|
|
})
|
|
} 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('en-US', {
|
|
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>
|
|
)
|
|
} |