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:
@@ -12,6 +12,7 @@ import { LoginPage } from './pages/LoginPage'
|
||||
import { PlaylistEditPage } from './pages/PlaylistEditPage'
|
||||
import { PlaylistsPage } from './pages/PlaylistsPage'
|
||||
import { RegisterPage } from './pages/RegisterPage'
|
||||
import { SchedulersPage } from './pages/SchedulersPage'
|
||||
import { SoundsPage } from './pages/SoundsPage'
|
||||
import { SettingsPage } from './pages/admin/SettingsPage'
|
||||
import { UsersPage } from './pages/admin/UsersPage'
|
||||
@@ -110,6 +111,14 @@ function AppRoutes() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/schedulers"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<SchedulersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/account"
|
||||
element={
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
import {
|
||||
CalendarClock,
|
||||
Download,
|
||||
Home,
|
||||
Music,
|
||||
@@ -48,6 +49,7 @@ export function AppSidebar({ showCompactPlayer = false }: AppSidebarProps) {
|
||||
<NavItem href="/sounds" icon={Music} title="Sounds" />
|
||||
<NavItem href="/playlists" icon={PlayCircle} title="Playlists" />
|
||||
<NavItem href="/extractions" icon={Download} title="Extractions" />
|
||||
<NavItem href="/schedulers" icon={CalendarClock} title="Schedulers" />
|
||||
</NavGroup>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
138
src/components/schedulers/SchedulersHeader.tsx
Normal file
138
src/components/schedulers/SchedulersHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
124
src/components/schedulers/SchedulersLoadingStates.tsx
Normal file
124
src/components/schedulers/SchedulersLoadingStates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
src/components/schedulers/SchedulersTable.tsx
Normal file
216
src/components/schedulers/SchedulersTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './player'
|
||||
export * from './files'
|
||||
export * from './extractions'
|
||||
export * from './favorites'
|
||||
export * from './schedulers'
|
||||
|
||||
201
src/lib/api/services/schedulers.ts
Normal file
201
src/lib/api/services/schedulers.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { apiClient } from '../client'
|
||||
import type { ApiResponse } from '../types'
|
||||
|
||||
// Task types
|
||||
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'
|
||||
}
|
||||
}
|
||||
176
src/pages/SchedulersPage.tsx
Normal file
176
src/pages/SchedulersPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -112,4 +112,34 @@ export function formatDateDistanceToNow(
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user