Add background filtering jobs, improved date picker, AI reasoning display
- Implement background job system for AI filtering to avoid HTTP timeouts - Add FilteringJob model to track progress of long-running filtering operations - Add real-time progress polling for filtering operations on round details page - Create custom DateTimePicker component with calendar popup (no year picker hassle) - Fix round date persistence bug (refetchOnWindowFocus was resetting form state) - Integrate filtering controls into round details page for filtering rounds - Display AI reasoning for flagged/filtered projects in results table - Add onboarding system scaffolding (schema, routes, basic UI) - Allow setting round dates in the past for manual overrides Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -91,8 +91,8 @@ const navigation = [
|
||||
icon: Handshake,
|
||||
},
|
||||
{
|
||||
name: 'Forms',
|
||||
href: '/admin/forms' as const,
|
||||
name: 'Onboarding',
|
||||
href: '/admin/onboarding' as const,
|
||||
icon: FileText,
|
||||
},
|
||||
]
|
||||
|
||||
72
src/components/ui/calendar.tsx
Normal file
72
src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { DayPicker } from 'react-day-picker'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn('p-3', className)}
|
||||
classNames={{
|
||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||
month: 'space-y-4',
|
||||
month_caption: 'flex justify-center pt-1 relative items-center',
|
||||
caption_label: 'text-sm font-medium',
|
||||
nav: 'space-x-1 flex items-center',
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute left-1'
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100 absolute right-1'
|
||||
),
|
||||
month_grid: 'w-full border-collapse space-y-1',
|
||||
weekdays: 'flex',
|
||||
weekday:
|
||||
'text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]',
|
||||
week: 'flex w-full mt-2',
|
||||
day: 'h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
|
||||
day_button: cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'h-9 w-9 p-0 font-normal aria-selected:opacity-100'
|
||||
),
|
||||
range_end: 'day-range-end',
|
||||
selected:
|
||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||
today: 'bg-accent text-accent-foreground',
|
||||
outside:
|
||||
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||
disabled: 'text-muted-foreground opacity-50',
|
||||
range_middle:
|
||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||
hidden: 'invisible',
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === 'left' ? (
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = 'Calendar'
|
||||
|
||||
export { Calendar }
|
||||
192
src/components/ui/datetime-picker.tsx
Normal file
192
src/components/ui/datetime-picker.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { format, setHours, setMinutes } from 'date-fns'
|
||||
import { CalendarIcon, Clock } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
|
||||
interface DateTimePickerProps {
|
||||
value?: Date | null
|
||||
onChange?: (date: Date | null) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
/** If true, only shows date picker without time */
|
||||
dateOnly?: boolean
|
||||
/** If true, allows clearing the value */
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
// Generate hour options (00-23)
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i)
|
||||
|
||||
// Generate minute options (00, 15, 30, 45) for easier selection
|
||||
const minutes = [0, 15, 30, 45]
|
||||
|
||||
export function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select date and time',
|
||||
disabled = false,
|
||||
className,
|
||||
dateOnly = false,
|
||||
clearable = true,
|
||||
}: DateTimePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [selectedDate, setSelectedDate] = React.useState<Date | undefined>(
|
||||
value ?? undefined
|
||||
)
|
||||
|
||||
// Sync internal state with external value
|
||||
React.useEffect(() => {
|
||||
setSelectedDate(value ?? undefined)
|
||||
}, [value])
|
||||
|
||||
const handleDateSelect = (date: Date | undefined) => {
|
||||
if (!date) {
|
||||
setSelectedDate(undefined)
|
||||
onChange?.(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve time from previous selection or use noon as default
|
||||
const newDate = selectedDate
|
||||
? setHours(setMinutes(date, selectedDate.getMinutes()), selectedDate.getHours())
|
||||
: setHours(setMinutes(date, 0), 12) // Default to noon
|
||||
|
||||
setSelectedDate(newDate)
|
||||
onChange?.(newDate)
|
||||
}
|
||||
|
||||
const handleTimeChange = (type: 'hour' | 'minute', valueStr: string) => {
|
||||
if (!selectedDate) return
|
||||
|
||||
const numValue = parseInt(valueStr, 10)
|
||||
let newDate: Date
|
||||
|
||||
if (type === 'hour') {
|
||||
newDate = setHours(selectedDate, numValue)
|
||||
} else {
|
||||
newDate = setMinutes(selectedDate, numValue)
|
||||
}
|
||||
|
||||
setSelectedDate(newDate)
|
||||
onChange?.(newDate)
|
||||
}
|
||||
|
||||
const handleClear = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setSelectedDate(undefined)
|
||||
onChange?.(null)
|
||||
}
|
||||
|
||||
const formatDisplayDate = (date: Date) => {
|
||||
if (dateOnly) {
|
||||
return format(date, 'MMM d, yyyy')
|
||||
}
|
||||
return format(date, 'MMM d, yyyy HH:mm')
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!selectedDate && 'text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{selectedDate ? formatDisplayDate(selectedDate) : placeholder}
|
||||
{clearable && selectedDate && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="ml-auto text-muted-foreground hover:text-foreground"
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleClear(e as unknown as React.MouseEvent)}
|
||||
>
|
||||
×
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<div className="flex flex-col">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
|
||||
{!dateOnly && (
|
||||
<div className="border-t p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Time:</span>
|
||||
<Select
|
||||
value={selectedDate ? String(selectedDate.getHours()) : undefined}
|
||||
onValueChange={(v) => handleTimeChange('hour', v)}
|
||||
disabled={!selectedDate}
|
||||
>
|
||||
<SelectTrigger className="w-[70px]">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{hours.map((hour) => (
|
||||
<SelectItem key={hour} value={String(hour)}>
|
||||
{String(hour).padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground">:</span>
|
||||
<Select
|
||||
value={selectedDate ? String(selectedDate.getMinutes()) : undefined}
|
||||
onValueChange={(v) => handleTimeChange('minute', v)}
|
||||
disabled={!selectedDate}
|
||||
>
|
||||
<SelectTrigger className="w-[70px]">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{minutes.map((minute) => (
|
||||
<SelectItem key={minute} value={String(minute)}>
|
||||
{String(minute).padStart(2, '0')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedDate && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Selected: {format(selectedDate, 'EEEE, MMMM d, yyyy')} at{' '}
|
||||
{format(selectedDate, 'HH:mm')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user