Compare commits

..

5 Commits

Author SHA1 Message Date
JSC
851738f04f feat: integrate Combobox component for timezone selection in CreateTaskDialog and AccountPage
Some checks failed
Frontend CI / lint (push) Failing after 19s
Frontend CI / build (push) Has been skipped
2025-08-29 03:33:38 +02:00
JSC
70de6ad919 feat: implement combobox for timezone, sound, and playlist selection in CreateTaskDialog 2025-08-29 03:02:15 +02:00
JSC
40b053c446 feat: enhance CreateTaskDialog with sound and playlist selection, including loading states and task-specific parameters 2025-08-29 02:47:58 +02:00
JSC
4251057668 refactor: standardize task and recurrence type strings to lowercase across components and services 2025-08-29 00:39:00 +02:00
JSC
009780e64c 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.
2025-08-29 00:09:45 +02:00
12 changed files with 1522 additions and 13 deletions

View File

@@ -12,6 +12,7 @@ import { LoginPage } from './pages/LoginPage'
import { PlaylistEditPage } from './pages/PlaylistEditPage' import { PlaylistEditPage } from './pages/PlaylistEditPage'
import { PlaylistsPage } from './pages/PlaylistsPage' import { PlaylistsPage } from './pages/PlaylistsPage'
import { RegisterPage } from './pages/RegisterPage' import { RegisterPage } from './pages/RegisterPage'
import { SchedulersPage } from './pages/SchedulersPage'
import { SoundsPage } from './pages/SoundsPage' import { SoundsPage } from './pages/SoundsPage'
import { SettingsPage } from './pages/admin/SettingsPage' import { SettingsPage } from './pages/admin/SettingsPage'
import { UsersPage } from './pages/admin/UsersPage' import { UsersPage } from './pages/admin/UsersPage'
@@ -110,6 +111,14 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/schedulers"
element={
<ProtectedRoute>
<SchedulersPage />
</ProtectedRoute>
}
/>
<Route <Route
path="/account" path="/account"
element={ element={

View File

@@ -8,6 +8,7 @@ import {
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { useAuth } from '@/contexts/AuthContext' import { useAuth } from '@/contexts/AuthContext'
import { import {
CalendarClock,
Download, Download,
Home, Home,
Music, Music,
@@ -48,6 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
<NavItem href="/sounds" icon={Music} title="Sounds" /> <NavItem href="/sounds" icon={Music} title="Sounds" />
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" /> <NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
<NavItem href="/extractions" icon={Download} title="Extractions" /> <NavItem href="/extractions" icon={Download} title="Extractions" />
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
</NavGroup> </NavGroup>
{user.role === 'admin' && ( {user.role === 'admin' && (

View File

@@ -0,0 +1,492 @@
import { Button } from '@/components/ui/button'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
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 { soundsService } from '@/lib/api/services/sounds'
import { playlistsService } from '@/lib/api/services/playlists'
import { getSupportedTimezones } from '@/utils/locale'
import { useLocale } from '@/hooks/use-locale'
import { CalendarPlus, Loader2, Music, PlayCircle } from 'lucide-react'
import { useState, useEffect } 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 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}`
}
const [formData, setFormData] = useState<CreateScheduledTaskRequest>({
name: '',
task_type: 'play_sound',
scheduled_at: getDefaultScheduledTime(),
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)
// Task-specific parameters
const [selectedSoundId, setSelectedSoundId] = useState<string>('')
const [selectedPlaylistId, setSelectedPlaylistId] = useState<string>('')
const [playMode, setPlayMode] = useState<string>('continuous')
const [shuffle, setShuffle] = useState<boolean>(false)
// Data loading
const [sounds, setSounds] = useState<Array<{id: number, name?: string, filename: string}>>([])
const [playlists, setPlaylists] = useState<Array<{id: number, name: string}>>([])
const [loadingSounds, setLoadingSounds] = useState(false)
const [loadingPlaylists, setLoadingPlaylists] = useState(false)
// Load sounds and playlists when dialog opens
useEffect(() => {
if (open) {
loadSounds()
loadPlaylists()
}
}, [open])
const loadSounds = async () => {
setLoadingSounds(true)
try {
const soundsData = await soundsService.getSounds({
types: ['SDB', 'TTS']
})
setSounds(soundsData || [])
} catch (error) {
console.error('Failed to load sounds:', error)
} finally {
setLoadingSounds(false)
}
}
const loadPlaylists = async () => {
setLoadingPlaylists(true)
try {
const playlistsData = await playlistsService.getPlaylists({})
setPlaylists(playlistsData.playlists || [])
} catch (error) {
console.error('Failed to load playlists:', error)
} finally {
setLoadingPlaylists(false)
}
}
// Prepare options for comboboxes
const soundOptions: ComboboxOption[] = sounds.map((sound) => ({
value: sound.id.toString(),
label: sound.name || sound.filename,
icon: <Music className="h-4 w-4" />,
searchValue: `${sound.id}-${sound.name || sound.filename}`
}))
const playlistOptions: ComboboxOption[] = playlists.map((playlist) => ({
value: playlist.id.toString(),
label: playlist.name,
icon: <PlayCircle className="h-4 w-4" />,
searchValue: `${playlist.id}-${playlist.name}`
}))
const timezoneOptions: ComboboxOption[] = [
{ value: 'UTC', label: 'UTC' },
...getSupportedTimezones().map((tz) => ({
value: tz,
label: tz.replace('_', ' '),
searchValue: tz.replace('_', ' ')
}))
]
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Build parameters based on task type
let parameters: Record<string, unknown> = {}
if (formData.task_type === 'play_sound') {
if (!selectedSoundId) {
setParametersError('Please select a sound')
return
}
parameters = { sound_id: parseInt(selectedSoundId) }
} else if (formData.task_type === 'play_playlist') {
if (!selectedPlaylistId) {
setParametersError('Please select a playlist')
return
}
parameters = {
playlist_id: parseInt(selectedPlaylistId),
play_mode: playMode,
shuffle: shuffle,
}
} else if (formData.task_type === 'credit_recharge') {
// For credit recharge, use the JSON textarea for custom parameters
try {
parameters = JSON.parse(parametersJson)
} catch {
setParametersError('Invalid JSON format')
return
}
}
// Send the datetime as UTC to prevent backend timezone conversion
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,
})
}
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: getDefaultScheduledTime(),
timezone: timezone,
parameters: {},
recurrence_type: 'none',
cron_expression: null,
recurrence_count: null,
expires_at: null,
})
setParametersJson('{}')
setParametersError(null)
setSelectedSoundId('')
setSelectedPlaylistId('')
setPlayMode('continuous')
setShuffle(false)
onCancel()
}
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}
onChange={(e) => setFormData(prev => ({ ...prev, scheduled_at: e.target.value }))}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="timezone">Timezone</Label>
<Combobox
value={formData.timezone}
onValueChange={(value) => setFormData(prev => ({ ...prev, timezone: value }))}
options={timezoneOptions}
placeholder="Select timezone..."
searchPlaceholder="Search timezone..."
emptyMessage="No timezone found."
/>
</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>
{/* Task-specific parameters based on task type */}
{formData.task_type === 'play_sound' && (
<div className="space-y-2">
<Label htmlFor="sound">Sound to Play</Label>
<Combobox
value={selectedSoundId}
onValueChange={setSelectedSoundId}
options={soundOptions}
placeholder="Select a sound"
searchPlaceholder="Search sounds..."
emptyMessage="No sound found."
loading={loadingSounds}
loadingMessage="Loading sounds..."
/>
{parametersError && (
<p className="text-sm text-destructive">{parametersError}</p>
)}
</div>
)}
{formData.task_type === 'play_playlist' && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="playlist">Playlist to Play</Label>
<Combobox
value={selectedPlaylistId}
onValueChange={setSelectedPlaylistId}
options={playlistOptions}
placeholder="Select a playlist"
searchPlaceholder="Search playlists..."
emptyMessage="No playlist found."
loading={loadingPlaylists}
loadingMessage="Loading playlists..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="playMode">Play Mode</Label>
<Select
value={playMode}
onValueChange={setPlayMode}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="continuous">Continuous</SelectItem>
<SelectItem value="loop">Loop</SelectItem>
<SelectItem value="loop_one">Loop One</SelectItem>
<SelectItem value="random">Random</SelectItem>
<SelectItem value="single">Single</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="shuffle">Shuffle</Label>
<Select
value={shuffle.toString()}
onValueChange={(value) => setShuffle(value === 'true')}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">No</SelectItem>
<SelectItem value="true">Yes</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{parametersError && (
<p className="text-sm text-destructive">{parametersError}</p>
)}
</div>
)}
{formData.task_type === 'credit_recharge' && (
<div className="space-y-2">
<Label htmlFor="parameters">Credit Recharge Parameters</Label>
<Textarea
id="parameters"
value={parametersJson}
onChange={(e) => handleParametersChange(e.target.value)}
placeholder='{"user_id": 123} or {} for all users'
className="font-mono text-sm"
rows={3}
/>
{parametersError && (
<p className="text-sm text-destructive">{parametersError}</p>
)}
<p className="text-xs text-muted-foreground">
Optional: specify {"user_id"} to recharge specific user, or leave empty {} to recharge all users.
</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>
)
}

View File

@@ -0,0 +1,138 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
type TaskStatus,
type TaskType,
getTaskStatusLabel,
getTaskTypeLabel,
} from '@/lib/api/services/schedulers'
import {
CalendarPlus,
RefreshCw,
Search,
} from 'lucide-react'
interface SchedulersHeaderProps {
searchQuery: string
onSearchChange: (query: string) => void
statusFilter: TaskStatus | 'all'
onStatusFilterChange: (status: TaskStatus | 'all') => void
taskTypeFilter: TaskType | 'all'
onTaskTypeFilterChange: (taskType: TaskType | 'all') => void
onRefresh: () => void
onCreateClick: () => void
loading: boolean
error: string | null
taskCount: number
}
const TASK_STATUSES: TaskStatus[] = ['pending', 'running', 'completed', 'failed', 'cancelled']
const TASK_TYPES: TaskType[] = ['credit_recharge', 'play_sound', 'play_playlist']
export function SchedulersHeader({
searchQuery,
onSearchChange,
statusFilter,
onStatusFilterChange,
taskTypeFilter,
onTaskTypeFilterChange,
onRefresh,
onCreateClick,
loading,
error,
taskCount,
}: SchedulersHeaderProps) {
return (
<div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Scheduled Tasks</h1>
<p className="text-muted-foreground">
Manage your scheduled tasks and automation
{taskCount > 0 && (
<span className="ml-2 text-sm">
({taskCount} task{taskCount !== 1 ? 's' : ''})
</span>
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={onRefresh}
disabled={loading}
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button onClick={onCreateClick} size="sm">
<CalendarPlus className="h-4 w-4" />
Create Task
</Button>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder="Search tasks..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2">
<Select
value={statusFilter}
onValueChange={onStatusFilterChange}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
{TASK_STATUSES.map((status) => (
<SelectItem key={status} value={status}>
{getTaskStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={taskTypeFilter}
onValueChange={onTaskTypeFilterChange}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{TASK_TYPES.map((taskType) => (
<SelectItem key={taskType} value={taskType}>
{getTaskTypeLabel(taskType)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{error && (
<div className="bg-destructive/10 border border-destructive/20 rounded-md p-4">
<p className="text-destructive text-sm">{error}</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,124 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertCircle, CalendarClock, RefreshCw } from 'lucide-react'
interface SchedulersErrorProps {
error: string
onRetry: () => void
}
export function SchedulersLoading() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }, (_, i) => (
<Card key={i}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="space-y-2">
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-32" />
</div>
<Skeleton className="h-6 w-20" />
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div className="space-y-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-20" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-16" />
<Skeleton className="h-4 w-12" />
</div>
<div className="space-y-1">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4 w-16" />
</div>
</div>
<div className="flex justify-end mt-4 pt-4 border-t">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-16" />
<Skeleton className="h-8 w-20" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
)
}
export function SchedulersError({ error, onRetry }: SchedulersErrorProps) {
return (
<Card className="border-destructive/50">
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center text-center py-8">
<AlertCircle className="h-12 w-12 text-destructive mb-4" />
<h3 className="text-lg font-semibold mb-2">Error Loading Tasks</h3>
<p className="text-muted-foreground mb-4 max-w-sm">{error}</p>
<Button onClick={onRetry} variant="outline">
<RefreshCw className="h-4 w-4 mr-2" />
Try Again
</Button>
</div>
</CardContent>
</Card>
)
}
interface SchedulersEmptyProps {
searchQuery: string
statusFilter: string
taskTypeFilter: string
}
export function SchedulersEmpty({
searchQuery,
statusFilter,
taskTypeFilter,
}: SchedulersEmptyProps) {
const hasFilters = searchQuery.trim() || statusFilter !== 'all' || taskTypeFilter !== 'all'
return (
<Card>
<CardContent className="pt-6">
<div className="flex flex-col items-center justify-center text-center py-8">
<CalendarClock className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">
{hasFilters ? 'No matching tasks found' : 'No scheduled tasks'}
</h3>
<p className="text-muted-foreground mb-4 max-w-sm">
{hasFilters
? 'Try adjusting your search criteria or filters to find tasks.'
: 'Get started by creating your first scheduled task for automation.'}
</p>
{hasFilters && (
<p className="text-sm text-muted-foreground">
{searchQuery && (
<span>
Search: <code className="bg-muted px-1 rounded">{searchQuery}</code>
</span>
)}
{statusFilter !== 'all' && (
<span className="ml-2">
Status: <code className="bg-muted px-1 rounded">{statusFilter}</code>
</span>
)}
{taskTypeFilter !== 'all' && (
<span className="ml-2">
Type: <code className="bg-muted px-1 rounded">{taskTypeFilter}</code>
</span>
)}
</p>
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,216 @@
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { formatDate } from '@/utils/format-date'
import {
type ScheduledTask,
getRecurrenceTypeLabel,
getTaskStatusLabel,
getTaskStatusVariant,
getTaskTypeLabel,
schedulersService,
} from '@/lib/api/services/schedulers'
import {
CalendarClock,
MoreHorizontal,
Pause,
Play,
Square,
} from 'lucide-react'
import { useState } from 'react'
import { toast } from 'sonner'
interface SchedulersTableProps {
tasks: ScheduledTask[]
onTaskUpdated?: (task: ScheduledTask) => void
onTaskDeleted?: (taskId: number) => void
}
export function SchedulersTable({ tasks, onTaskUpdated, onTaskDeleted }: SchedulersTableProps) {
const [loadingActions, setLoadingActions] = useState<Record<number, boolean>>({})
const handleToggleActive = async (task: ScheduledTask) => {
if (loadingActions[task.id]) return
try {
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
const updatedTask = await schedulersService.updateTask(task.id, {
is_active: !task.is_active,
})
onTaskUpdated?.(updatedTask)
toast.success(`Task ${task.is_active ? 'paused' : 'resumed'} successfully`)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to update task'
toast.error(message)
} finally {
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
}
}
const handleCancelTask = async (task: ScheduledTask) => {
if (loadingActions[task.id]) return
try {
setLoadingActions(prev => ({ ...prev, [task.id]: true }))
await schedulersService.cancelTask(task.id)
onTaskDeleted?.(task.id)
toast.success('Task cancelled successfully')
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to cancel task'
toast.error(message)
} finally {
setLoadingActions(prev => ({ ...prev, [task.id]: false }))
}
}
if (tasks.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<CalendarClock className="h-12 w-12 mx-auto mb-4" />
<p>No scheduled tasks found</p>
</div>
)
}
return (
<div className="space-y-4">
{tasks.map((task) => (
<Card key={task.id} className={task.is_active ? '' : 'opacity-60'}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold">{task.name}</h3>
<Badge variant={getTaskStatusVariant(task.status)}>
{getTaskStatusLabel(task.status)}
</Badge>
{!task.is_active && (
<Badge variant="outline" className="text-muted-foreground">
Paused
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{getTaskTypeLabel(task.task_type)}
{task.recurrence_type !== 'none' && (
<span className="ml-2">
{getRecurrenceTypeLabel(task.recurrence_type)}
</span>
)}
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
disabled={loadingActions[task.id]}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleToggleActive(task)}
disabled={task.status === 'completed' || task.status === 'cancelled'}
>
{task.is_active ? (
<>
<Pause className="h-4 w-4 mr-2" />
Pause Task
</>
) : (
<>
<Play className="h-4 w-4 mr-2" />
Resume Task
</>
)}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleCancelTask(task)}
disabled={task.status === 'completed' || task.status === 'cancelled'}
className="text-destructive focus:text-destructive"
>
<Square className="h-4 w-4 mr-2" />
Cancel Task
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p className="text-muted-foreground mb-1">Scheduled</p>
<p className="font-medium">
{formatDate(task.scheduled_at)}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Next Run</p>
<p className="font-medium">
{task.next_execution_at
? formatDate(task.next_execution_at)
: task.status === 'completed'
? 'Completed'
: task.status === 'cancelled'
? 'Cancelled'
: 'N/A'}
</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Executions</p>
<p className="font-medium">{task.executions_count}</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Last Run</p>
<p className="font-medium">
{task.last_executed_at
? formatDate(task.last_executed_at)
: 'Never'}
</p>
</div>
</div>
{task.error_message && (
<div className="mt-4 p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive">{task.error_message}</p>
</div>
)}
{Object.keys(task.parameters).length > 0 && (
<div className="mt-4">
<details className="group">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground transition-colors">
Parameters ({Object.keys(task.parameters).length})
</summary>
<div className="mt-2 p-3 bg-muted rounded-md">
<pre className="text-xs overflow-x-auto">
{JSON.stringify(task.parameters, null, 2)}
</pre>
</div>
</details>
</div>
)}
</CardContent>
</Card>
))}
</div>
)
}

View File

@@ -0,0 +1,119 @@
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { Check, ChevronsUpDown } from "lucide-react"
import { cn } from "@/lib/utils"
export interface ComboboxOption {
value: string
label: string
icon?: React.ReactNode
searchValue?: string
}
interface ComboboxProps {
value?: string
onValueChange: (value: string) => void
options: ComboboxOption[]
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
disabled?: boolean
loading?: boolean
loadingMessage?: string
className?: string
}
export function Combobox({
value,
onValueChange,
options,
placeholder = "Select option...",
searchPlaceholder = "Search options...",
emptyMessage = "No option found.",
disabled = false,
loading = false,
loadingMessage = "Loading...",
className,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const selectedOption = options.find((option) => option.value === value)
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between", className)}
disabled={disabled || loading}
>
{selectedOption ? (
<div className="flex items-center gap-2">
{selectedOption.icon}
<span>{selectedOption.label}</span>
</div>
) : loading ? (
loadingMessage
) : (
placeholder
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
style={{ WebkitOverflowScrolling: 'touch' } as React.CSSProperties}
>
<Command onWheel={(e) => e.stopPropagation()}>
<CommandInput placeholder={searchPlaceholder} />
<CommandList
className="max-h-[200px] overflow-y-auto overscroll-contain"
style={{ touchAction: 'pan-y' }}
>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
{options.map((option) => (
<CommandItem
key={option.value}
value={option.searchValue || `${option.value}-${option.label}`}
onSelect={() => {
onValueChange(option.value)
setOpen(false)
}}
>
<div className="flex items-center gap-2">
{option.icon}
<span>{option.label}</span>
</div>
<Check
className={cn(
"ml-auto h-4 w-4",
value === option.value ? "opacity-100" : "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}

View File

@@ -4,3 +4,4 @@ export * from './player'
export * from './files' export * from './files'
export * from './extractions' export * from './extractions'
export * from './favorites' export * from './favorites'
export * from './schedulers'

View File

@@ -0,0 +1,201 @@
import { apiClient } from '../client'
import type { ApiResponse } from '../types'
// Task types (backend expects lowercase)
export type TaskType = 'credit_recharge' | 'play_sound' | 'play_playlist'
export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
export type RecurrenceType = 'none' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'cron'
// Task interfaces
export interface ScheduledTask {
id: number
name: string
task_type: TaskType
status: TaskStatus
user_id: number | null
scheduled_at: string
timezone: string
parameters: Record<string, unknown>
recurrence_type: RecurrenceType
cron_expression: string | null
recurrence_count: number | null
expires_at: string | null
executions_count: number
last_executed_at: string | null
next_execution_at: string | null
error_message: string | null
is_active: boolean
created_at: string
updated_at: string
}
export interface CreateScheduledTaskRequest {
name: string
task_type: TaskType
scheduled_at: string
timezone?: string
parameters?: Record<string, unknown>
recurrence_type?: RecurrenceType
cron_expression?: string | null
recurrence_count?: number | null
expires_at?: string | null
}
export interface UpdateScheduledTaskRequest {
name?: string
scheduled_at?: string
timezone?: string
parameters?: Record<string, unknown>
is_active?: boolean
expires_at?: string | null
}
export interface GetUserTasksParams {
status?: TaskStatus
task_type?: TaskType
limit?: number
offset?: number
}
export interface GetAllTasksParams {
status?: TaskStatus
task_type?: TaskType
limit?: number
offset?: number
}
// API service
export const schedulersService = {
// User tasks
async getUserTasks(params?: GetUserTasksParams): Promise<ScheduledTask[]> {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.append('status', params.status)
if (params?.task_type) searchParams.append('task_type', params.task_type)
if (params?.limit) searchParams.append('limit', params.limit.toString())
if (params?.offset) searchParams.append('offset', params.offset.toString())
const queryString = searchParams.toString()
const endpoint = `/api/v1/scheduler/tasks${queryString ? `?${queryString}` : ''}`
return await apiClient.get<ScheduledTask[]>(endpoint)
},
async getTask(taskId: number): Promise<ScheduledTask> {
return await apiClient.get<ScheduledTask>(`/api/v1/scheduler/tasks/${taskId}`)
},
async createTask(data: CreateScheduledTaskRequest): Promise<ScheduledTask> {
return await apiClient.post<ScheduledTask>('/api/v1/scheduler/tasks', data)
},
async updateTask(taskId: number, data: UpdateScheduledTaskRequest): Promise<ScheduledTask> {
return await apiClient.patch<ScheduledTask>(`/api/v1/scheduler/tasks/${taskId}`, data)
},
async cancelTask(taskId: number): Promise<ApiResponse> {
return await apiClient.delete<ApiResponse>(`/api/v1/scheduler/tasks/${taskId}`)
},
// Admin endpoints
async getAllTasks(params?: GetAllTasksParams): Promise<ScheduledTask[]> {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.append('status', params.status)
if (params?.task_type) searchParams.append('task_type', params.task_type)
if (params?.limit) searchParams.append('limit', params.limit.toString())
if (params?.offset) searchParams.append('offset', params.offset.toString())
const queryString = searchParams.toString()
const endpoint = `/api/v1/scheduler/admin/tasks${queryString ? `?${queryString}` : ''}`
return await apiClient.get<ScheduledTask[]>(endpoint)
},
async getSystemTasks(params?: { status?: TaskStatus; task_type?: TaskType }): Promise<ScheduledTask[]> {
const searchParams = new URLSearchParams()
if (params?.status) searchParams.append('status', params.status)
if (params?.task_type) searchParams.append('task_type', params.task_type)
const queryString = searchParams.toString()
const endpoint = `/api/v1/scheduler/admin/system-tasks${queryString ? `?${queryString}` : ''}`
return await apiClient.get<ScheduledTask[]>(endpoint)
},
async createSystemTask(data: CreateScheduledTaskRequest): Promise<ScheduledTask> {
return await apiClient.post<ScheduledTask>('/api/v1/scheduler/admin/system-tasks', data)
},
}
// Utility functions
export function getTaskTypeLabel(taskType: TaskType): string {
switch (taskType) {
case 'credit_recharge':
return 'Credit Recharge'
case 'play_sound':
return 'Play Sound'
case 'play_playlist':
return 'Play Playlist'
default:
return taskType
}
}
export function getTaskStatusLabel(status: TaskStatus): string {
switch (status) {
case 'pending':
return 'Pending'
case 'running':
return 'Running'
case 'completed':
return 'Completed'
case 'failed':
return 'Failed'
case 'cancelled':
return 'Cancelled'
default:
return status
}
}
export function getRecurrenceTypeLabel(recurrenceType: RecurrenceType): string {
switch (recurrenceType) {
case 'none':
return 'None'
case 'hourly':
return 'Hourly'
case 'daily':
return 'Daily'
case 'weekly':
return 'Weekly'
case 'monthly':
return 'Monthly'
case 'yearly':
return 'Yearly'
case 'cron':
return 'Custom'
default:
return recurrenceType
}
}
export function getTaskStatusVariant(status: TaskStatus): 'default' | 'secondary' | 'destructive' | 'outline' {
switch (status) {
case 'pending':
return 'outline'
case 'running':
return 'default'
case 'completed':
return 'secondary'
case 'failed':
return 'destructive'
case 'cancelled':
return 'outline'
default:
return 'default'
}
}

View File

@@ -2,6 +2,7 @@ import { AppLayout } from '@/components/AppLayout'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Combobox, type ComboboxOption } from '@/components/ui/combobox'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -75,6 +76,13 @@ export function AccountPage() {
const [providers, setProviders] = useState<UserProvider[]>([]) const [providers, setProviders] = useState<UserProvider[]>([])
const [providersLoading, setProvidersLoading] = useState(true) const [providersLoading, setProvidersLoading] = useState(true)
// Prepare timezone options for combobox
const timezoneOptions: ComboboxOption[] = getSupportedTimezones().map((tz) => ({
value: tz,
label: tz.replace('_', ' '),
searchValue: tz.replace('_', ' ')
}))
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setProfileName(user.name) setProfileName(user.name)
@@ -390,21 +398,14 @@ export function AccountPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Timezone</Label> <Label>Timezone</Label>
<Select <Combobox
value={timezone} value={timezone}
onValueChange={setTimezone} onValueChange={setTimezone}
> options={timezoneOptions}
<SelectTrigger> placeholder="Select timezone..."
<SelectValue /> searchPlaceholder="Search timezone..."
</SelectTrigger> emptyMessage="No timezone found."
<SelectContent> />
{getSupportedTimezones().map((tz) => (
<SelectItem key={tz} value={tz}>
{tz.replace('_', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Choose your timezone for date and time display Choose your timezone for date and time display
</p> </p>

View File

@@ -0,0 +1,176 @@
import { AppLayout } from '@/components/AppLayout'
import { CreateTaskDialog } from '@/components/schedulers/CreateTaskDialog'
import { SchedulersHeader } from '@/components/schedulers/SchedulersHeader'
import {
SchedulersEmpty,
SchedulersError,
SchedulersLoading,
} from '@/components/schedulers/SchedulersLoadingStates'
import { SchedulersTable } from '@/components/schedulers/SchedulersTable'
import {
type CreateScheduledTaskRequest,
type ScheduledTask,
type TaskStatus,
type TaskType,
schedulersService,
} from '@/lib/api/services/schedulers'
import { useCallback, useEffect, useState } from 'react'
import { toast } from 'sonner'
export function SchedulersPage() {
const [tasks, setTasks] = useState<ScheduledTask[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Search and filtering state
const [searchQuery, setSearchQuery] = useState('')
const [statusFilter, setStatusFilter] = useState<TaskStatus | 'all'>('all')
const [taskTypeFilter, setTaskTypeFilter] = useState<TaskType | 'all'>('all')
// Create task dialog state
const [showCreateDialog, setShowCreateDialog] = useState(false)
const [createLoading, setCreateLoading] = useState(false)
// Debounce search query
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(searchQuery)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchQuery(searchQuery)
}, 300)
return () => clearTimeout(handler)
}, [searchQuery])
const fetchTasks = useCallback(async () => {
try {
setLoading(true)
setError(null)
const allTasks = await schedulersService.getUserTasks({
status: statusFilter !== 'all' ? statusFilter : undefined,
task_type: taskTypeFilter !== 'all' ? taskTypeFilter : undefined,
limit: 100, // Get all tasks for now, we'll implement pagination if needed
})
// Client-side search filtering
let filteredTasks = allTasks
if (debouncedSearchQuery.trim()) {
const query = debouncedSearchQuery.toLowerCase()
filteredTasks = allTasks.filter(task =>
task.name.toLowerCase().includes(query) ||
task.task_type.toLowerCase().includes(query) ||
task.status.toLowerCase().includes(query)
)
}
setTasks(filteredTasks)
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch tasks'
setError(errorMessage)
toast.error(errorMessage)
} finally {
setLoading(false)
}
}, [debouncedSearchQuery, statusFilter, taskTypeFilter])
useEffect(() => {
fetchTasks()
}, [fetchTasks])
const handleCreateTask = async (data: CreateScheduledTaskRequest) => {
try {
setCreateLoading(true)
await schedulersService.createTask(data)
toast.success('Task created successfully')
// Close dialog and refresh tasks
setShowCreateDialog(false)
fetchTasks()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to create task'
toast.error(errorMessage)
} finally {
setCreateLoading(false)
}
}
const handleCancelCreate = () => {
setShowCreateDialog(false)
}
const handleTaskUpdated = (updatedTask: ScheduledTask) => {
setTasks(prev => prev.map(task =>
task.id === updatedTask.id ? updatedTask : task
))
}
const handleTaskDeleted = (taskId: number) => {
setTasks(prev => prev.filter(task => task.id !== taskId))
}
const renderContent = () => {
if (loading) {
return <SchedulersLoading />
}
if (error) {
return <SchedulersError error={error} onRetry={fetchTasks} />
}
if (tasks.length === 0) {
return (
<SchedulersEmpty
searchQuery={searchQuery}
statusFilter={statusFilter}
taskTypeFilter={taskTypeFilter}
/>
)
}
return (
<SchedulersTable
tasks={tasks}
onTaskUpdated={handleTaskUpdated}
onTaskDeleted={handleTaskDeleted}
/>
)
}
return (
<AppLayout
breadcrumb={{
items: [{ label: 'Dashboard', href: '/' }, { label: 'Schedulers' }],
}}
>
<div className="flex-1 rounded-xl bg-muted/50 p-4">
<SchedulersHeader
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
statusFilter={statusFilter}
onStatusFilterChange={setStatusFilter}
taskTypeFilter={taskTypeFilter}
onTaskTypeFilterChange={setTaskTypeFilter}
onRefresh={fetchTasks}
onCreateClick={() => setShowCreateDialog(true)}
loading={loading}
error={error}
taskCount={tasks.length}
/>
<CreateTaskDialog
open={showCreateDialog}
onOpenChange={setShowCreateDialog}
loading={createLoading}
onSubmit={handleCreateTask}
onCancel={handleCancelCreate}
/>
<div className="mt-6">
{renderContent()}
</div>
</div>
</AppLayout>
)
}

View File

@@ -112,4 +112,34 @@ export function formatDateDistanceToNow(
} }
return formatDistanceToNow(date, { addSuffix: true }); return formatDistanceToNow(date, { addSuffix: true });
}
/**
* Format a date string with timezone consideration
* @param dateString - The date string to format
* @param timezone - The target timezone (default: 'UTC')
* @returns Formatted date string with timezone
*/
export function formatDateTime(dateString: string, timezone: string = 'UTC'): string {
try {
const date = new Date(dateString)
if (isNaN(date.getTime())) {
return 'Invalid Date'
}
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,
})
return formatter.format(date)
} catch {
return 'Invalid Date'
}
} }