Generated by Spark: Add filters and advanced search options for each listing, maybe even a query language.

This commit is contained in:
2026-01-23 01:47:29 +00:00
committed by GitHub
parent b7d9e57721
commit 55895ed43a
6 changed files with 1230 additions and 58 deletions

12
PRD.md
View File

@@ -103,6 +103,18 @@ This is a multi-module enterprise platform requiring navigation between distinct
- Progression: Hours worked → Accrual calculated at 5.6% → Balance updated → Worker requests holiday → Manager approves → Balance deducted → Holiday pay included in next payroll
- Success criteria: Automatic accrual from all timesheets, real-time balance visibility, integration with payroll system
**Advanced Search & Query Language**
- Functionality: Powerful query language parser with filter builder UI for all list views (timesheets, invoices, expenses, compliance)
- Purpose: Enables power users to rapidly filter and sort large datasets using natural query syntax while providing visual builder for less technical users
- Trigger: User types in search bar or opens filter builder
- Progression: Enter query (e.g., "status = pending hours > 40") → Parse and validate → Apply filters → Display results count → Show active filter badges → Clear/modify filters
- Success criteria: Supports text (contains/equals/starts/ends), numeric (>/</=/>=/<=/), list (in), and sorting operators; sub-second query parsing; persistent filter state; guided help documentation; exports include active filters
- Query Examples:
- Timesheets: `status = pending workerName : Smith hours > 40 sort amount desc`
- Invoices: `amount > 5000 currency = GBP status in sent,overdue`
- Expenses: `category = Travel billable = true status = pending`
- Compliance: `status = expiring daysUntilExpiry < 30 documentType : DBS`
## Edge Case Handling
- **Missing Timesheet Data**: Display clear empty states with guided actions to submit or import timesheets

View File

@@ -1,6 +1,7 @@
import { useState } from 'react'
import { useKV } from '@github/spark/hooks'
import { useNotifications } from '@/hooks/use-notifications'
import { useSampleData } from '@/hooks/use-sample-data'
import {
Clock,
Receipt,
@@ -35,7 +36,8 @@ import {
Gear,
FileText,
CaretDown,
CaretRight
CaretRight,
Question
} from '@phosphor-icons/react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
@@ -77,6 +79,8 @@ import { TimesheetDetailDialog } from '@/components/TimesheetDetailDialog'
import { InvoiceDetailDialog } from '@/components/InvoiceDetailDialog'
import { ExpenseDetailDialog } from '@/components/ExpenseDetailDialog'
import { ComplianceDetailDialog } from '@/components/ComplianceDetailDialog'
import { AdvancedSearch, type FilterField } from '@/components/AdvancedSearch'
import { QueryLanguageGuide } from '@/components/QueryLanguageGuide'
import type {
Timesheet,
Invoice,
@@ -93,9 +97,11 @@ import type {
ShiftEntry
} from '@/lib/types'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns'
type View = 'dashboard' | 'timesheets' | 'billing' | 'payroll' | 'compliance' | 'expenses' | 'roadmap' | 'reports' | 'currency' | 'email-templates' | 'invoice-templates' | 'qr-scanner' | 'missing-timesheets' | 'purchase-orders' | 'onboarding' | 'audit-trail' | 'notification-rules' | 'batch-import' | 'rate-templates' | 'custom-reports' | 'holiday-pay' | 'contract-validation' | 'shift-patterns' | 'query-guide'
function App() {
useSampleData()
const [currentView, setCurrentView] = useState<View>('dashboard')
const [currentEntity, setCurrentEntity] = useState('Main Agency')
const [searchQuery, setSearchQuery] = useState('')
@@ -630,6 +636,12 @@ function App() {
</NavGroup>
<Separator className="my-2" />
<NavItem
icon={<Question size={20} />}
label="Query Guide"
active={currentView === 'query-guide'}
onClick={() => setCurrentView('query-guide')}
/>
<NavItem
icon={<MapTrifold size={20} />}
label="Roadmap"
@@ -884,6 +896,10 @@ function App() {
<ShiftPatternManager />
)}
{currentView === 'query-guide' && (
<QueryLanguageGuide />
)}
{currentView === 'roadmap' && (
<RoadmapView />
)}
@@ -1216,6 +1232,7 @@ function TimesheetsView({
const [isBulkImportOpen, setIsBulkImportOpen] = useState(false)
const [selectedTimesheet, setSelectedTimesheet] = useState<Timesheet | null>(null)
const [viewingTimesheet, setViewingTimesheet] = useState<Timesheet | null>(null)
const [filteredTimesheets, setFilteredTimesheets] = useState<Timesheet[]>(timesheets)
const [formData, setFormData] = useState({
workerName: '',
clientName: '',
@@ -1225,11 +1242,24 @@ function TimesheetsView({
})
const [csvData, setCsvData] = useState('')
const filteredTimesheets = timesheets.filter(t => {
const matchesSearch = t.workerName.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.clientName.toLowerCase().includes(searchQuery.toLowerCase())
const timesheetFields: FilterField[] = [
{ name: 'workerName', label: 'Worker Name', type: 'text' },
{ name: 'clientName', label: 'Client Name', type: 'text' },
{ name: 'status', label: 'Status', type: 'select', options: [
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'processing', label: 'Processing' }
]},
{ name: 'hours', label: 'Hours', type: 'number' },
{ name: 'amount', label: 'Amount', type: 'number' },
{ name: 'weekEnding', label: 'Week Ending', type: 'date' },
{ name: 'submittedDate', label: 'Submitted Date', type: 'date' }
]
const timesheetsToFilter = timesheets.filter(t => {
const matchesStatus = statusFilter === 'all' || t.status === statusFilter
return matchesSearch && matchesStatus
return matchesStatus
})
const handleSubmitCreate = () => {
@@ -1381,19 +1411,14 @@ function TimesheetsView({
</div>
</div>
<AdvancedSearch
items={timesheetsToFilter}
fields={timesheetFields}
onResultsChange={setFilteredTimesheets}
placeholder="Search timesheets or use query language (e.g., status = pending hours > 40)"
/>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<MagnifyingGlass
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by worker or client..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as any)}>
<SelectTrigger className="w-40">
<div className="flex items-center gap-2">
@@ -1699,11 +1724,26 @@ interface BillingViewProps {
function BillingView({ invoices, searchQuery, setSearchQuery, onSendInvoice, onCreatePlacementInvoice, onCreateCreditNote, rateCards }: BillingViewProps) {
const [viewingInvoice, setViewingInvoice] = useState<Invoice | null>(null)
const [filteredInvoices, setFilteredInvoices] = useState<Invoice[]>(invoices)
const filteredInvoices = invoices.filter(i =>
i.invoiceNumber.toLowerCase().includes(searchQuery.toLowerCase()) ||
i.clientName.toLowerCase().includes(searchQuery.toLowerCase())
)
const invoiceFields: FilterField[] = [
{ name: 'invoiceNumber', label: 'Invoice Number', type: 'text' },
{ name: 'clientName', label: 'Client Name', type: 'text' },
{ name: 'status', label: 'Status', type: 'select', options: [
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'paid', label: 'Paid' },
{ value: 'overdue', label: 'Overdue' }
]},
{ name: 'amount', label: 'Amount', type: 'number' },
{ name: 'currency', label: 'Currency', type: 'select', options: [
{ value: 'GBP', label: 'GBP' },
{ value: 'USD', label: 'USD' },
{ value: 'EUR', label: 'EUR' }
]},
{ name: 'issueDate', label: 'Issue Date', type: 'date' },
{ name: 'dueDate', label: 'Due Date', type: 'date' }
]
return (
<div className="space-y-6">
@@ -1722,19 +1762,14 @@ function BillingView({ invoices, searchQuery, setSearchQuery, onSendInvoice, onC
</div>
</div>
<AdvancedSearch
items={invoices}
fields={invoiceFields}
onResultsChange={setFilteredInvoices}
placeholder="Search invoices or use query language (e.g., status = overdue amount > 1000)"
/>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<MagnifyingGlass
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search by invoice number or client..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Button variant="outline">
<Download size={18} className="mr-2" />
Export
@@ -1946,6 +1981,7 @@ function ComplianceView({ complianceDocs, onUploadDocument }: ComplianceViewProp
const expiredDocs = complianceDocs.filter(d => d.status === 'expired')
const [isUploadOpen, setIsUploadOpen] = useState(false)
const [viewingDocument, setViewingDocument] = useState<ComplianceDocument | null>(null)
const [filteredDocs, setFilteredDocs] = useState<ComplianceDocument[]>(complianceDocs)
const [uploadFormData, setUploadFormData] = useState({
workerId: '',
workerName: '',
@@ -1953,6 +1989,25 @@ function ComplianceView({ complianceDocs, onUploadDocument }: ComplianceViewProp
expiryDate: ''
})
const complianceFields: FilterField[] = [
{ name: 'workerName', label: 'Worker Name', type: 'text' },
{ name: 'documentType', label: 'Document Type', type: 'select', options: [
{ value: 'DBS Check', label: 'DBS Check' },
{ value: 'Right to Work', label: 'Right to Work' },
{ value: 'Professional License', label: 'Professional License' },
{ value: 'First Aid Certificate', label: 'First Aid Certificate' },
{ value: 'Driving License', label: 'Driving License' },
{ value: 'Passport', label: 'Passport' }
]},
{ name: 'status', label: 'Status', type: 'select', options: [
{ value: 'valid', label: 'Valid' },
{ value: 'expiring', label: 'Expiring' },
{ value: 'expired', label: 'Expired' }
]},
{ name: 'daysUntilExpiry', label: 'Days Until Expiry', type: 'number' },
{ name: 'expiryDate', label: 'Expiry Date', type: 'date' }
]
const handleSubmitUpload = () => {
if (!uploadFormData.workerName || !uploadFormData.documentType || !uploadFormData.expiryDate) {
toast.error('Please fill in all fields')
@@ -2048,6 +2103,13 @@ function ComplianceView({ complianceDocs, onUploadDocument }: ComplianceViewProp
</Dialog>
</div>
<AdvancedSearch
items={complianceDocs}
fields={complianceFields}
onResultsChange={setFilteredDocs}
placeholder="Search documents or use query language (e.g., status = expiring daysUntilExpiry < 30)"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card className="border-l-4 border-warning/20">
<CardHeader>
@@ -2079,21 +2141,24 @@ function ComplianceView({ complianceDocs, onUploadDocument }: ComplianceViewProp
<Tabs defaultValue="expiring" className="space-y-4">
<TabsList>
<TabsTrigger value="expiring">
Expiring Soon ({expiringDocs.length})
Expiring Soon ({filteredDocs.filter(d => d.status === 'expiring').length})
</TabsTrigger>
<TabsTrigger value="expired">
Expired ({expiredDocs.length})
Expired ({filteredDocs.filter(d => d.status === 'expired').length})
</TabsTrigger>
<TabsTrigger value="valid">
Valid ({complianceDocs.filter(d => d.status === 'valid').length})
Valid ({filteredDocs.filter(d => d.status === 'valid').length})
</TabsTrigger>
<TabsTrigger value="all">
All ({filteredDocs.length})
</TabsTrigger>
</TabsList>
<TabsContent value="expiring" className="space-y-3">
{expiringDocs.map(doc => (
{filteredDocs.filter(d => d.status === 'expiring').map(doc => (
<ComplianceCard key={doc.id} document={doc} onViewDetails={setViewingDocument} />
))}
{expiringDocs.length === 0 && (
{filteredDocs.filter(d => d.status === 'expiring').length === 0 && (
<Card className="p-12 text-center">
<CheckCircle size={48} className="mx-auto text-success mb-4" />
<h3 className="text-lg font-semibold mb-2">All documents current</h3>
@@ -2103,13 +2168,19 @@ function ComplianceView({ complianceDocs, onUploadDocument }: ComplianceViewProp
</TabsContent>
<TabsContent value="expired" className="space-y-3">
{expiredDocs.map(doc => (
{filteredDocs.filter(d => d.status === 'expired').map(doc => (
<ComplianceCard key={doc.id} document={doc} onViewDetails={setViewingDocument} />
))}
</TabsContent>
<TabsContent value="valid" className="space-y-3">
{complianceDocs.filter(d => d.status === 'valid').map(doc => (
{filteredDocs.filter(d => d.status === 'valid').map(doc => (
<ComplianceCard key={doc.id} document={doc} onViewDetails={setViewingDocument} />
))}
</TabsContent>
<TabsContent value="all" className="space-y-3">
{filteredDocs.map(doc => (
<ComplianceCard key={doc.id} document={doc} onViewDetails={setViewingDocument} />
))}
</TabsContent>
@@ -2217,6 +2288,7 @@ function ExpensesView({
const [statusFilter, setStatusFilter] = useState<'all' | ExpenseStatus>('all')
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [viewingExpense, setViewingExpense] = useState<Expense | null>(null)
const [filteredExpenses, setFilteredExpenses] = useState<Expense[]>(expenses)
const [formData, setFormData] = useState({
workerName: '',
clientName: '',
@@ -2227,12 +2299,34 @@ function ExpensesView({
billable: true
})
const filteredExpenses = expenses.filter(e => {
const matchesSearch = e.workerName.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.clientName.toLowerCase().includes(searchQuery.toLowerCase()) ||
e.description.toLowerCase().includes(searchQuery.toLowerCase())
const expenseFields: FilterField[] = [
{ name: 'workerName', label: 'Worker Name', type: 'text' },
{ name: 'clientName', label: 'Client Name', type: 'text' },
{ name: 'status', label: 'Status', type: 'select', options: [
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
{ value: 'paid', label: 'Paid' }
]},
{ name: 'category', label: 'Category', type: 'select', options: [
{ value: 'Travel', label: 'Travel' },
{ value: 'Accommodation', label: 'Accommodation' },
{ value: 'Meals', label: 'Meals' },
{ value: 'Equipment', label: 'Equipment' },
{ value: 'Training', label: 'Training' },
{ value: 'Other', label: 'Other' }
]},
{ name: 'amount', label: 'Amount', type: 'number' },
{ name: 'date', label: 'Date', type: 'date' },
{ name: 'billable', label: 'Billable', type: 'select', options: [
{ value: 'true', label: 'Yes' },
{ value: 'false', label: 'No' }
]}
]
const expensesToFilter = expenses.filter(e => {
const matchesStatus = statusFilter === 'all' || e.status === statusFilter
return matchesSearch && matchesStatus
return matchesStatus
})
const handleSubmitCreate = () => {
@@ -2372,19 +2466,14 @@ function ExpensesView({
</Dialog>
</div>
<AdvancedSearch
items={expensesToFilter}
fields={expenseFields}
onResultsChange={setFilteredExpenses}
placeholder="Search expenses or use query language (e.g., category = Travel billable = true)"
/>
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<MagnifyingGlass
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder="Search expenses..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as any)}>
<SelectTrigger className="w-40">
<div className="flex items-center gap-2">

View File

@@ -0,0 +1,374 @@
import { useState, useMemo } from 'react'
import { parseQuery, applyFilters, applySorting, type ParsedQuery } from '@/lib/query-parser'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { MagnifyingGlass, Funnel, X, Question, Plus } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
export interface FilterField {
name: string
label: string
type: 'text' | 'number' | 'date' | 'select'
options?: { value: string; label: string }[]
}
export interface AdvancedSearchProps<T> {
items: T[]
fields: FilterField[]
onResultsChange: (results: T[]) => void
placeholder?: string
className?: string
}
export function AdvancedSearch<T extends Record<string, any>>({
items,
fields,
onResultsChange,
placeholder = 'Search or use query language...',
className
}: AdvancedSearchProps<T>) {
const [query, setQuery] = useState('')
const [showBuilder, setShowBuilder] = useState(false)
const [activeFilters, setActiveFilters] = useState<Array<{
field: string
operator: string
value: string
}>>([])
const parsed = useMemo(() => parseQuery(query), [query])
const filteredItems = useMemo(() => {
let results = items
if (parsed.filters.length > 0) {
results = applyFilters(results, parsed.filters)
}
if (parsed.sortBy && parsed.sortOrder) {
results = applySorting(results, parsed.sortBy, parsed.sortOrder)
}
return results
}, [items, parsed])
useMemo(() => {
onResultsChange(filteredItems)
}, [filteredItems, onResultsChange])
const addFilter = (field: string, operator: string, value: string) => {
const newFilter = { field, operator, value }
setActiveFilters([...activeFilters, newFilter])
const filterQuery = `${field} ${operator} "${value}"`
setQuery(prev => prev ? `${prev} ${filterQuery}` : filterQuery)
}
const removeFilter = (index: number) => {
const newFilters = activeFilters.filter((_, i) => i !== index)
setActiveFilters(newFilters)
const newQuery = newFilters
.map(f => `${f.field} ${f.operator} "${f.value}"`)
.join(' ')
setQuery(newQuery)
}
const clearAll = () => {
setQuery('')
setActiveFilters([])
}
return (
<div className={cn('space-y-3', className)}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<MagnifyingGlass
size={18}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-10 pr-10"
/>
{query && (
<Button
variant="ghost"
size="sm"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
onClick={clearAll}
>
<X size={14} />
</Button>
)}
</div>
<Popover open={showBuilder} onOpenChange={setShowBuilder}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Funnel size={18} className="mr-2" />
Filter Builder
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<FilterBuilder
fields={fields}
onAddFilter={(field, operator, value) => {
addFilter(field, operator, value)
setShowBuilder(false)
}}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
<Question size={18} />
</Button>
</PopoverTrigger>
<PopoverContent className="w-96" align="end">
<QueryHelp fields={fields} />
</PopoverContent>
</Popover>
</div>
{parsed.filters.length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground">Active filters:</span>
{parsed.filters.map((filter, index) => (
<Badge
key={index}
variant="secondary"
className="gap-1 pr-1"
>
<span className="font-mono text-xs">
{filter.field} {filter.operator} {String(filter.value)}
</span>
<button
onClick={() => {
const newFilters = parsed.filters.filter((_, i) => i !== index)
const newQuery = newFilters
.map(f => `${f.field} ${f.operator} "${f.value}"`)
.join(' ')
setQuery(newQuery)
}}
className="ml-1 hover:bg-secondary-foreground/20 rounded p-0.5"
>
<X size={12} />
</button>
</Badge>
))}
{parsed.sortBy && (
<Badge variant="outline" className="gap-1">
<span className="font-mono text-xs">
sort: {parsed.sortBy} {parsed.sortOrder}
</span>
</Badge>
)}
</div>
)}
<div className="text-xs text-muted-foreground">
Showing {filteredItems.length} of {items.length} results
</div>
</div>
)
}
interface FilterBuilderProps {
fields: FilterField[]
onAddFilter: (field: string, operator: string, value: string) => void
}
function FilterBuilder({ fields, onAddFilter }: FilterBuilderProps) {
const [selectedField, setSelectedField] = useState('')
const [selectedOperator, setSelectedOperator] = useState('contains')
const [value, setValue] = useState('')
const field = fields.find(f => f.name === selectedField)
const operators = field?.type === 'number'
? [
{ value: '=', label: 'Equals' },
{ value: '>', label: 'Greater than' },
{ value: '>=', label: 'Greater or equal' },
{ value: '<', label: 'Less than' },
{ value: '<=', label: 'Less or equal' }
]
: field?.type === 'select'
? [
{ value: '=', label: 'Equals' },
{ value: 'in', label: 'In list' }
]
: [
{ value: 'contains', label: 'Contains' },
{ value: '=', label: 'Equals' },
{ value: 'starts', label: 'Starts with' },
{ value: 'ends', label: 'Ends with' }
]
const handleAdd = () => {
if (selectedField && value) {
onAddFilter(selectedField, selectedOperator, value)
setSelectedField('')
setValue('')
}
}
return (
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-1">Build Filter</h4>
<p className="text-xs text-muted-foreground">
Create filters visually without query syntax
</p>
</div>
<div className="space-y-3">
<div className="space-y-2">
<Label>Field</Label>
<Select value={selectedField} onValueChange={setSelectedField}>
<SelectTrigger>
<SelectValue placeholder="Select field" />
</SelectTrigger>
<SelectContent>
{fields.map(field => (
<SelectItem key={field.name} value={field.name}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedField && (
<>
<div className="space-y-2">
<Label>Operator</Label>
<Select value={selectedOperator} onValueChange={setSelectedOperator}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{operators.map(op => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Value</Label>
{field?.type === 'select' ? (
<Select value={value} onValueChange={setValue}>
<SelectTrigger>
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
{field.options?.map(opt => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
type={field?.type === 'number' ? 'number' : field?.type === 'date' ? 'date' : 'text'}
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Enter value"
/>
)}
</div>
</>
)}
</div>
<Button onClick={handleAdd} disabled={!selectedField || !value} className="w-full">
<Plus size={16} className="mr-2" />
Add Filter
</Button>
</div>
)
}
interface QueryHelpProps {
fields: FilterField[]
}
function QueryHelp({ fields }: QueryHelpProps) {
return (
<div className="space-y-4">
<div>
<h4 className="font-semibold mb-1">Query Language Help</h4>
<p className="text-xs text-muted-foreground">
Build powerful queries using simple syntax
</p>
</div>
<Card className="bg-muted/50">
<CardHeader className="pb-3">
<CardTitle className="text-sm">Available Fields</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
{fields.map(field => (
<div key={field.name} className="flex items-center justify-between text-xs">
<code className="bg-background px-1.5 py-0.5 rounded">{field.name}</code>
<span className="text-muted-foreground">{field.label}</span>
</div>
))}
</CardContent>
</Card>
<div className="space-y-2">
<h5 className="text-sm font-semibold">Operators</h5>
<div className="space-y-1 text-xs">
<div className="flex items-center gap-2">
<code className="bg-muted px-1.5 py-0.5 rounded">:</code>
<span className="text-muted-foreground">contains text</span>
</div>
<div className="flex items-center gap-2">
<code className="bg-muted px-1.5 py-0.5 rounded">=</code>
<span className="text-muted-foreground">equals exactly</span>
</div>
<div className="flex items-center gap-2">
<code className="bg-muted px-1.5 py-0.5 rounded">&gt; &lt;</code>
<span className="text-muted-foreground">greater/less than</span>
</div>
<div className="flex items-center gap-2">
<code className="bg-muted px-1.5 py-0.5 rounded">in</code>
<span className="text-muted-foreground">in list (comma-separated)</span>
</div>
</div>
</div>
<div className="space-y-2">
<h5 className="text-sm font-semibold">Examples</h5>
<div className="space-y-1.5">
<div className="bg-muted p-2 rounded text-xs font-mono">
status = pending
</div>
<div className="bg-muted p-2 rounded text-xs font-mono">
workerName : Smith hours &gt; 40
</div>
<div className="bg-muted p-2 rounded text-xs font-mono">
status in pending,approved
</div>
<div className="bg-muted p-2 rounded text-xs font-mono">
amount &gt; 1000 sort amount desc
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,233 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
export function QueryLanguageGuide() {
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-semibold mb-2">Query Language Guide</h2>
<p className="text-muted-foreground">
Use our powerful query language to filter and sort data across all listing views
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Basic Syntax</CardTitle>
<CardDescription>Combine field, operator, and value to create filters</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="bg-muted p-4 rounded-lg font-mono text-sm">
field operator value
</div>
<p className="text-sm text-muted-foreground">
Separate multiple filters with spaces. Use quotes for values with spaces.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Operators</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="font-semibold mb-2 text-sm">Text Operators</h4>
<div className="space-y-2">
<OperatorRow operator=":" description="Contains text (case-insensitive)" />
<OperatorRow operator="=" description="Equals exactly" />
<OperatorRow operator="starts" description="Starts with text" />
<OperatorRow operator="ends" description="Ends with text" />
</div>
</div>
<div>
<h4 className="font-semibold mb-2 text-sm">Number Operators</h4>
<div className="space-y-2">
<OperatorRow operator="=" description="Equals number" />
<OperatorRow operator=">" description="Greater than" />
<OperatorRow operator=">=" description="Greater than or equal" />
<OperatorRow operator="<" description="Less than" />
<OperatorRow operator="<=" description="Less than or equal" />
</div>
</div>
<div>
<h4 className="font-semibold mb-2 text-sm">List Operators</h4>
<div className="space-y-2">
<OperatorRow operator="in" description="Value in comma-separated list" />
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Sorting</CardTitle>
<CardDescription>Order results by any field</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
sort fieldName asc
</div>
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
sort fieldName desc
</div>
<p className="text-sm text-muted-foreground">
Add sorting at the end of your query to order results ascending (asc) or descending (desc)
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Examples by View</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div>
<h4 className="font-semibold mb-3 flex items-center gap-2">
Timesheets
<Badge variant="outline">status, hours, amount</Badge>
</h4>
<div className="space-y-2">
<ExampleQuery
query="status = pending"
description="Show only pending timesheets"
/>
<ExampleQuery
query="workerName : Smith hours > 40"
description="Find Smith's timesheets over 40 hours"
/>
<ExampleQuery
query="status in pending,approved sort amount desc"
description="Pending or approved, sorted by amount high to low"
/>
<ExampleQuery
query="clientName : Acme amount >= 1000"
description="Acme Corp timesheets worth £1000 or more"
/>
</div>
</div>
<Separator />
<div>
<h4 className="font-semibold mb-3 flex items-center gap-2">
Invoices
<Badge variant="outline">status, amount, currency</Badge>
</h4>
<div className="space-y-2">
<ExampleQuery
query="status = overdue"
description="Show all overdue invoices"
/>
<ExampleQuery
query="amount > 5000 currency = GBP"
description="High-value GBP invoices"
/>
<ExampleQuery
query="clientName : Tech status in sent,overdue"
description="Unpaid invoices for Tech clients"
/>
<ExampleQuery
query="status = paid sort amount desc"
description="Paid invoices, largest first"
/>
</div>
</div>
<Separator />
<div>
<h4 className="font-semibold mb-3 flex items-center gap-2">
Expenses
<Badge variant="outline">category, billable, amount</Badge>
</h4>
<div className="space-y-2">
<ExampleQuery
query="category = Travel billable = true"
description="Billable travel expenses"
/>
<ExampleQuery
query="status = pending amount > 100"
description="Pending expenses over £100"
/>
<ExampleQuery
query="workerName : Johnson category in Travel,Accommodation"
description="Johnson's travel and accommodation"
/>
</div>
</div>
<Separator />
<div>
<h4 className="font-semibold mb-3 flex items-center gap-2">
Compliance
<Badge variant="outline">status, documentType, daysUntilExpiry</Badge>
</h4>
<div className="space-y-2">
<ExampleQuery
query="status = expiring daysUntilExpiry < 30"
description="Documents expiring within 30 days"
/>
<ExampleQuery
query="documentType : DBS status in expiring,expired"
description="DBS checks that need attention"
/>
<ExampleQuery
query="workerName : Brown sort daysUntilExpiry asc"
description="Brown's documents by expiry date"
/>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-accent/10 border-accent/20">
<CardHeader>
<CardTitle className="text-lg">Pro Tips</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex items-start gap-2">
<span className="text-accent font-semibold"></span>
<p>Use quotes around values with spaces: <code className="bg-muted px-1 rounded">clientName = "Acme Corporation"</code></p>
</div>
<div className="flex items-start gap-2">
<span className="text-accent font-semibold"></span>
<p>Combine multiple filters: all must match (AND logic)</p>
</div>
<div className="flex items-start gap-2">
<span className="text-accent font-semibold"></span>
<p>Field names are case-sensitive, but values are not</p>
</div>
<div className="flex items-start gap-2">
<span className="text-accent font-semibold"></span>
<p>Use the Filter Builder button for a guided experience</p>
</div>
</CardContent>
</Card>
</div>
)
}
function OperatorRow({ operator, description }: { operator: string; description: string }) {
return (
<div className="flex items-center gap-3 text-sm">
<code className="bg-background px-2 py-1 rounded font-mono font-semibold min-w-[60px] text-center">
{operator}
</code>
<span className="text-muted-foreground">{description}</span>
</div>
)
}
function ExampleQuery({ query, description }: { query: string; description: string }) {
return (
<div className="bg-muted/50 p-3 rounded-lg space-y-1">
<code className="text-sm font-mono text-foreground">{query}</code>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
)
}

View File

@@ -0,0 +1,237 @@
import { useEffect } from 'react'
import { useKV } from '@github/spark/hooks'
import type { Timesheet, Invoice, Expense, ComplianceDocument } from '@/lib/types'
export function useSampleData() {
const [hasInitialized, setHasInitialized] = useKV<boolean>('sample-data-initialized', false)
const [, setTimesheets] = useKV<Timesheet[]>('timesheets', [])
const [, setInvoices] = useKV<Invoice[]>('invoices', [])
const [, setExpenses] = useKV<Expense[]>('expenses', [])
const [, setComplianceDocs] = useKV<ComplianceDocument[]>('compliance-docs', [])
useEffect(() => {
if (hasInitialized) return
const sampleTimesheets: Timesheet[] = [
{
id: 'TS-001',
workerId: 'W-001',
workerName: 'John Smith',
clientName: 'Acme Corporation',
weekEnding: '2025-01-17',
hours: 40,
status: 'pending',
submittedDate: '2025-01-18T09:00:00Z',
amount: 1200,
rate: 30
},
{
id: 'TS-002',
workerId: 'W-002',
workerName: 'Sarah Johnson',
clientName: 'Tech Solutions Ltd',
weekEnding: '2025-01-17',
hours: 45,
status: 'pending',
submittedDate: '2025-01-18T10:30:00Z',
amount: 1575,
rate: 35
},
{
id: 'TS-003',
workerId: 'W-001',
workerName: 'John Smith',
clientName: 'Acme Corporation',
weekEnding: '2025-01-10',
hours: 37.5,
status: 'approved',
submittedDate: '2025-01-11T09:00:00Z',
approvedDate: '2025-01-12T14:00:00Z',
amount: 1125,
rate: 30
},
{
id: 'TS-004',
workerId: 'W-003',
workerName: 'Michael Brown',
clientName: 'Global Industries',
weekEnding: '2025-01-17',
hours: 52,
status: 'pending',
submittedDate: '2025-01-18T11:00:00Z',
amount: 1820,
rate: 35
},
{
id: 'TS-005',
workerId: 'W-004',
workerName: 'Emma Wilson',
clientName: 'Tech Solutions Ltd',
weekEnding: '2025-01-17',
hours: 40,
status: 'approved',
submittedDate: '2025-01-18T08:30:00Z',
approvedDate: '2025-01-18T15:00:00Z',
amount: 1000,
rate: 25
}
]
const sampleInvoices: Invoice[] = [
{
id: 'INV-001',
invoiceNumber: 'INV-00001',
clientName: 'Acme Corporation',
issueDate: '2025-01-15',
dueDate: '2025-02-14',
amount: 5400,
status: 'sent',
currency: 'GBP'
},
{
id: 'INV-002',
invoiceNumber: 'INV-00002',
clientName: 'Tech Solutions Ltd',
issueDate: '2025-01-10',
dueDate: '2025-02-09',
amount: 3150,
status: 'paid',
currency: 'GBP'
},
{
id: 'INV-003',
invoiceNumber: 'INV-00003',
clientName: 'Global Industries',
issueDate: '2024-12-20',
dueDate: '2025-01-19',
amount: 8900,
status: 'overdue',
currency: 'GBP'
},
{
id: 'INV-004',
invoiceNumber: 'INV-00004',
clientName: 'Tech Solutions Ltd',
issueDate: '2025-01-18',
dueDate: '2025-02-17',
amount: 2500,
status: 'draft',
currency: 'GBP'
}
]
const sampleExpenses: Expense[] = [
{
id: 'EXP-001',
workerId: 'W-001',
workerName: 'John Smith',
clientName: 'Acme Corporation',
date: '2025-01-15',
category: 'Travel',
description: 'Train ticket to client site',
amount: 45.50,
currency: 'GBP',
status: 'pending',
submittedDate: '2025-01-16T10:00:00Z',
billable: true
},
{
id: 'EXP-002',
workerId: 'W-002',
workerName: 'Sarah Johnson',
clientName: 'Tech Solutions Ltd',
date: '2025-01-14',
category: 'Meals',
description: 'Lunch meeting with client team',
amount: 35.00,
currency: 'GBP',
status: 'approved',
submittedDate: '2025-01-15T09:00:00Z',
approvedDate: '2025-01-16T11:00:00Z',
billable: true
},
{
id: 'EXP-003',
workerId: 'W-003',
workerName: 'Michael Brown',
clientName: 'Global Industries',
date: '2025-01-16',
category: 'Accommodation',
description: 'Hotel stay for 2 nights',
amount: 240.00,
currency: 'GBP',
status: 'pending',
submittedDate: '2025-01-17T08:30:00Z',
billable: true
},
{
id: 'EXP-004',
workerId: 'W-001',
workerName: 'John Smith',
clientName: 'Acme Corporation',
date: '2025-01-10',
category: 'Equipment',
description: 'Laptop accessories',
amount: 85.00,
currency: 'GBP',
status: 'rejected',
submittedDate: '2025-01-11T14:00:00Z',
billable: false
}
]
const sampleComplianceDocs: ComplianceDocument[] = [
{
id: 'DOC-001',
workerId: 'W-001',
workerName: 'John Smith',
documentType: 'DBS Check',
expiryDate: '2025-02-15',
status: 'expiring',
daysUntilExpiry: 28
},
{
id: 'DOC-002',
workerId: 'W-002',
workerName: 'Sarah Johnson',
documentType: 'Right to Work',
expiryDate: '2026-06-30',
status: 'valid',
daysUntilExpiry: 528
},
{
id: 'DOC-003',
workerId: 'W-003',
workerName: 'Michael Brown',
documentType: 'Professional License',
expiryDate: '2025-01-10',
status: 'expired',
daysUntilExpiry: -8
},
{
id: 'DOC-004',
workerId: 'W-004',
workerName: 'Emma Wilson',
documentType: 'First Aid Certificate',
expiryDate: '2025-03-20',
status: 'valid',
daysUntilExpiry: 61
},
{
id: 'DOC-005',
workerId: 'W-001',
workerName: 'John Smith',
documentType: 'Driving License',
expiryDate: '2025-02-05',
status: 'expiring',
daysUntilExpiry: 18
}
]
setTimesheets(sampleTimesheets)
setInvoices(sampleInvoices)
setExpenses(sampleExpenses)
setComplianceDocs(sampleComplianceDocs)
setHasInitialized(true)
}, [hasInitialized, setTimesheets, setInvoices, setExpenses, setComplianceDocs, setHasInitialized])
}

227
src/lib/query-parser.ts Normal file
View File

@@ -0,0 +1,227 @@
export interface QueryFilter {
field: string
operator: 'equals' | 'contains' | 'gt' | 'gte' | 'lt' | 'lte' | 'between' | 'in' | 'startsWith' | 'endsWith'
value: any
}
export interface ParsedQuery {
filters: QueryFilter[]
sortBy?: string
sortOrder?: 'asc' | 'desc'
error?: string
}
export function parseQuery(query: string): ParsedQuery {
const result: ParsedQuery = { filters: [] }
if (!query || query.trim() === '') {
return result
}
const tokens = tokenize(query)
let i = 0
while (i < tokens.length) {
const token = tokens[i]
if (token.toLowerCase() === 'sort' && i + 2 < tokens.length) {
result.sortBy = tokens[i + 1]
const order = tokens[i + 2].toLowerCase()
if (order === 'asc' || order === 'desc') {
result.sortOrder = order
}
i += 3
continue
}
if (i + 2 < tokens.length) {
const field = token
const operator = tokens[i + 1]
let value = tokens[i + 2]
const filter = parseFilter(field, operator, value)
if (filter) {
result.filters.push(filter)
i += 3
} else {
i += 1
}
} else {
i += 1
}
}
return result
}
function tokenize(query: string): string[] {
const tokens: string[] = []
let current = ''
let inQuotes = false
for (let i = 0; i < query.length; i++) {
const char = query[i]
if (char === '"' || char === "'") {
if (inQuotes) {
if (current) {
tokens.push(current)
current = ''
}
inQuotes = false
} else {
if (current) {
tokens.push(current)
current = ''
}
inQuotes = true
}
} else if (char === ' ' && !inQuotes) {
if (current) {
tokens.push(current)
current = ''
}
} else {
current += char
}
}
if (current) {
tokens.push(current)
}
return tokens
}
function parseFilter(field: string, operator: string, value: string): QueryFilter | null {
const operatorMap: Record<string, QueryFilter['operator']> = {
'=': 'equals',
'==': 'equals',
':': 'contains',
'contains': 'contains',
'>': 'gt',
'>=': 'gte',
'<': 'lt',
'<=': 'lte',
'in': 'in',
'starts': 'startsWith',
'ends': 'endsWith',
}
const mappedOperator = operatorMap[operator.toLowerCase()]
if (!mappedOperator) {
return null
}
let parsedValue: any = value
if (mappedOperator === 'in') {
parsedValue = value.split(',').map(v => v.trim())
} else if (!isNaN(Number(value))) {
parsedValue = Number(value)
} else if (value.toLowerCase() === 'true') {
parsedValue = true
} else if (value.toLowerCase() === 'false') {
parsedValue = false
}
return {
field,
operator: mappedOperator,
value: parsedValue
}
}
export function applyFilters<T extends Record<string, any>>(
items: T[],
filters: QueryFilter[]
): T[] {
if (filters.length === 0) return items
return items.filter(item => {
return filters.every(filter => {
const fieldValue = getNestedValue(item, filter.field)
if (fieldValue === undefined || fieldValue === null) {
return false
}
switch (filter.operator) {
case 'equals':
return String(fieldValue).toLowerCase() === String(filter.value).toLowerCase()
case 'contains':
return String(fieldValue).toLowerCase().includes(String(filter.value).toLowerCase())
case 'startsWith':
return String(fieldValue).toLowerCase().startsWith(String(filter.value).toLowerCase())
case 'endsWith':
return String(fieldValue).toLowerCase().endsWith(String(filter.value).toLowerCase())
case 'gt':
return Number(fieldValue) > Number(filter.value)
case 'gte':
return Number(fieldValue) >= Number(filter.value)
case 'lt':
return Number(fieldValue) < Number(filter.value)
case 'lte':
return Number(fieldValue) <= Number(filter.value)
case 'in':
return Array.isArray(filter.value) && filter.value.some(v =>
String(fieldValue).toLowerCase() === String(v).toLowerCase()
)
default:
return false
}
})
})
}
function getNestedValue(obj: any, path: string): any {
const keys = path.split('.')
let value = obj
for (const key of keys) {
if (value && typeof value === 'object' && key in value) {
value = value[key]
} else {
return undefined
}
}
return value
}
export function applySorting<T extends Record<string, any>>(
items: T[],
sortBy?: string,
sortOrder: 'asc' | 'desc' = 'asc'
): T[] {
if (!sortBy) return items
return [...items].sort((a, b) => {
const aVal = getNestedValue(a, sortBy)
const bVal = getNestedValue(b, sortBy)
if (aVal === undefined || aVal === null) return 1
if (bVal === undefined || bVal === null) return -1
let comparison = 0
if (typeof aVal === 'number' && typeof bVal === 'number') {
comparison = aVal - bVal
} else if (aVal instanceof Date && bVal instanceof Date) {
comparison = aVal.getTime() - bVal.getTime()
} else {
comparison = String(aVal).localeCompare(String(bVal))
}
return sortOrder === 'asc' ? comparison : -comparison
})
}