From 55895ed43a3cfca7454b404a25fecb077fbff826 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Fri, 23 Jan 2026 01:47:29 +0000 Subject: [PATCH] Generated by Spark: Add filters and advanced search options for each listing, maybe even a query language. --- PRD.md | 12 + src/App.tsx | 205 ++++++++++---- src/components/AdvancedSearch.tsx | 374 ++++++++++++++++++++++++++ src/components/QueryLanguageGuide.tsx | 233 ++++++++++++++++ src/hooks/use-sample-data.ts | 237 ++++++++++++++++ src/lib/query-parser.ts | 227 ++++++++++++++++ 6 files changed, 1230 insertions(+), 58 deletions(-) create mode 100644 src/components/AdvancedSearch.tsx create mode 100644 src/components/QueryLanguageGuide.tsx create mode 100644 src/hooks/use-sample-data.ts create mode 100644 src/lib/query-parser.ts diff --git a/PRD.md b/PRD.md index d1fa32f..d6699c8 100644 --- a/PRD.md +++ b/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 diff --git a/src/App.tsx b/src/App.tsx index 0561a9a..9f85f07 100644 --- a/src/App.tsx +++ b/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('dashboard') const [currentEntity, setCurrentEntity] = useState('Main Agency') const [searchQuery, setSearchQuery] = useState('') @@ -630,6 +636,12 @@ function App() { + } + label="Query Guide" + active={currentView === 'query-guide'} + onClick={() => setCurrentView('query-guide')} + /> } label="Roadmap" @@ -884,6 +896,10 @@ function App() { )} + {currentView === 'query-guide' && ( + + )} + {currentView === 'roadmap' && ( )} @@ -1216,6 +1232,7 @@ function TimesheetsView({ const [isBulkImportOpen, setIsBulkImportOpen] = useState(false) const [selectedTimesheet, setSelectedTimesheet] = useState(null) const [viewingTimesheet, setViewingTimesheet] = useState(null) + const [filteredTimesheets, setFilteredTimesheets] = useState(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({ + +
-
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
setSearchQuery(e.target.value)} - className="pl-10" - /> -
+ )} + + + + + + + + { + addFilter(field, operator, value) + setShowBuilder(false) + }} + /> + + + + + + + + + + + + + + {parsed.filters.length > 0 && ( +
+ Active filters: + {parsed.filters.map((filter, index) => ( + + + {filter.field} {filter.operator} {String(filter.value)} + + + + ))} + {parsed.sortBy && ( + + + sort: {parsed.sortBy} {parsed.sortOrder} + + + )} +
+ )} + +
+ Showing {filteredItems.length} of {items.length} results +
+ + ) +} + +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 ( +
+
+

Build Filter

+

+ Create filters visually without query syntax +

+
+ +
+
+ + +
+ + {selectedField && ( + <> +
+ + +
+ +
+ + {field?.type === 'select' ? ( + + ) : ( + setValue(e.target.value)} + placeholder="Enter value" + /> + )} +
+ + )} +
+ + +
+ ) +} + +interface QueryHelpProps { + fields: FilterField[] +} + +function QueryHelp({ fields }: QueryHelpProps) { + return ( +
+
+

Query Language Help

+

+ Build powerful queries using simple syntax +

+
+ + + + Available Fields + + + {fields.map(field => ( +
+ {field.name} + {field.label} +
+ ))} +
+
+ +
+
Operators
+
+
+ : + contains text +
+
+ = + equals exactly +
+
+ > < + greater/less than +
+
+ in + in list (comma-separated) +
+
+
+ +
+
Examples
+
+
+ status = pending +
+
+ workerName : Smith hours > 40 +
+
+ status in pending,approved +
+
+ amount > 1000 sort amount desc +
+
+
+
+ ) +} diff --git a/src/components/QueryLanguageGuide.tsx b/src/components/QueryLanguageGuide.tsx new file mode 100644 index 0000000..ebe77cb --- /dev/null +++ b/src/components/QueryLanguageGuide.tsx @@ -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 ( +
+
+

Query Language Guide

+

+ Use our powerful query language to filter and sort data across all listing views +

+
+ + + + Basic Syntax + Combine field, operator, and value to create filters + + +
+ field operator value +
+

+ Separate multiple filters with spaces. Use quotes for values with spaces. +

+
+
+ + + + Operators + + +
+
+

Text Operators

+
+ + + + +
+
+
+

Number Operators

+
+ + + + + +
+
+
+

List Operators

+
+ +
+
+
+
+
+ + + + Sorting + Order results by any field + + +
+ sort fieldName asc +
+
+ sort fieldName desc +
+

+ Add sorting at the end of your query to order results ascending (asc) or descending (desc) +

+
+
+ + + + Examples by View + + +
+

+ Timesheets + status, hours, amount +

+
+ + + + +
+
+ + + +
+

+ Invoices + status, amount, currency +

+
+ + + + +
+
+ + + +
+

+ Expenses + category, billable, amount +

+
+ + + +
+
+ + + +
+

+ Compliance + status, documentType, daysUntilExpiry +

+
+ + + +
+
+
+
+ + + + Pro Tips + + +
+ +

Use quotes around values with spaces: clientName = "Acme Corporation"

+
+
+ +

Combine multiple filters: all must match (AND logic)

+
+
+ +

Field names are case-sensitive, but values are not

+
+
+ +

Use the Filter Builder button for a guided experience

+
+
+
+
+ ) +} + +function OperatorRow({ operator, description }: { operator: string; description: string }) { + return ( +
+ + {operator} + + {description} +
+ ) +} + +function ExampleQuery({ query, description }: { query: string; description: string }) { + return ( +
+ {query} +

{description}

+
+ ) +} diff --git a/src/hooks/use-sample-data.ts b/src/hooks/use-sample-data.ts new file mode 100644 index 0000000..1da0cb5 --- /dev/null +++ b/src/hooks/use-sample-data.ts @@ -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('sample-data-initialized', false) + const [, setTimesheets] = useKV('timesheets', []) + const [, setInvoices] = useKV('invoices', []) + const [, setExpenses] = useKV('expenses', []) + const [, setComplianceDocs] = useKV('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]) +} diff --git a/src/lib/query-parser.ts b/src/lib/query-parser.ts new file mode 100644 index 0000000..c9a140d --- /dev/null +++ b/src/lib/query-parser.ts @@ -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 = { + '=': '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>( + 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>( + 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 + }) +}