mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: Add filters and advanced search options for each listing, maybe even a query language.
This commit is contained in:
12
PRD.md
12
PRD.md
@@ -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
|
||||
|
||||
205
src/App.tsx
205
src/App.tsx
@@ -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">
|
||||
|
||||
374
src/components/AdvancedSearch.tsx
Normal file
374
src/components/AdvancedSearch.tsx
Normal 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">> <</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 > 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 > 1000 sort amount desc
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
233
src/components/QueryLanguageGuide.tsx
Normal file
233
src/components/QueryLanguageGuide.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
237
src/hooks/use-sample-data.ts
Normal file
237
src/hooks/use-sample-data.ts
Normal 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
227
src/lib/query-parser.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user