Generated by Spark: CRUD operations go into IndexedDB

This commit is contained in:
2026-01-24 02:28:13 +00:00
committed by GitHub
parent 347f2af0b2
commit 62eae05789
8 changed files with 998 additions and 58 deletions

273
INDEXEDDB_CRUD.md Normal file
View File

@@ -0,0 +1,273 @@
# IndexedDB CRUD Operations
This application uses IndexedDB for all data persistence and CRUD (Create, Read, Update, Delete) operations. This provides better performance, offline support, and structured data management compared to simple key-value storage.
## Architecture
### Database Structure
- **Database Name**: `WorkForceProDB`
- **Version**: 2
- **Stores**:
- `sessions` - User session data
- `appState` - General application state
- `timesheets` - Timesheet entities
- `invoices` - Invoice entities
- `payrollRuns` - Payroll run entities
- `workers` - Worker entities
- `complianceDocs` - Compliance document entities
- `expenses` - Expense entities
- `rateCards` - Rate card entities
### Indexes
Each entity store has relevant indexes for efficient querying:
- **timesheets**: `workerId`, `status`, `weekEnding`
- **invoices**: `clientId`, `status`, `invoiceDate`
- **payrollRuns**: `status`, `periodEnding`
- **workers**: `status`, `email`
- **complianceDocs**: `workerId`, `status`, `expiryDate`
- **expenses**: `workerId`, `status`, `date`
- **rateCards**: `clientId`, `role`
## Usage
### Low-Level API (Direct IndexedDB Access)
```typescript
import { indexedDB, STORES } from '@/lib/indexed-db'
// Create
const newTimesheet = { id: 'ts-001', workerId: 'w-123', hours: 40, status: 'pending' }
await indexedDB.create(STORES.TIMESHEETS, newTimesheet)
// Read one
const timesheet = await indexedDB.read(STORES.TIMESHEETS, 'ts-001')
// Read all
const allTimesheets = await indexedDB.readAll(STORES.TIMESHEETS)
// Read by index
const pendingTimesheets = await indexedDB.readByIndex(STORES.TIMESHEETS, 'status', 'pending')
// Update
timesheet.status = 'approved'
await indexedDB.update(STORES.TIMESHEETS, timesheet)
// Delete
await indexedDB.delete(STORES.TIMESHEETS, 'ts-001')
// Delete all
await indexedDB.deleteAll(STORES.TIMESHEETS)
// Bulk operations
await indexedDB.bulkCreate(STORES.TIMESHEETS, [timesheet1, timesheet2, timesheet3])
await indexedDB.bulkUpdate(STORES.TIMESHEETS, [updatedTimesheet1, updatedTimesheet2])
// Query with predicate
const highValueTimesheets = await indexedDB.query(
STORES.TIMESHEETS,
(ts) => ts.hours > 40
)
```
### React Hook API (Recommended)
#### Generic CRUD Hook
```typescript
import { useCRUD } from '@/hooks/use-crud'
import { STORES } from '@/lib/indexed-db'
function MyComponent() {
const {
data, // Current data in state
isLoading, // Loading state
error, // Error state
create, // Create entity
read, // Read single entity
readAll, // Read all entities
readByIndex, // Read by index
update, // Update entity
remove, // Delete entity
removeAll, // Delete all entities
bulkCreate, // Bulk create
bulkUpdate, // Bulk update
query, // Query with predicate
refresh, // Refresh data
} = useCRUD<Timesheet>(STORES.TIMESHEETS)
// Usage examples
const handleCreate = async () => {
await create({ id: 'ts-001', workerId: 'w-123', hours: 40 })
}
const handleUpdate = async (id: string) => {
const timesheet = await read(id)
if (timesheet) {
await update({ ...timesheet, status: 'approved' })
}
}
const handleDelete = async (id: string) => {
await remove(id)
}
const handleSearch = async () => {
const results = await query((ts) => ts.hours > 40)
console.log(results)
}
useEffect(() => {
readAll() // Load initial data
}, [])
return (
<div>
{isLoading && <Spinner />}
{error && <Alert>{error.message}</Alert>}
{data.map(item => <Card key={item.id}>{item.name}</Card>)}
</div>
)
}
```
#### Entity-Specific CRUD Hooks
Pre-configured hooks for each entity type:
```typescript
import {
useTimesheetsCRUD,
useInvoicesCRUD,
usePayrollRunsCRUD,
useWorkersCRUD,
useComplianceDocsCRUD,
useExpensesCRUD,
useRateCardsCRUD
} from '@/hooks/use-entity-crud'
function TimesheetsView() {
const timesheets = useTimesheetsCRUD()
useEffect(() => {
timesheets.readAll()
}, [])
const handleApprove = async (id: string) => {
const timesheet = await timesheets.read(id)
if (timesheet) {
await timesheets.update({ ...timesheet, status: 'approved' })
}
}
return (
<div>
{timesheets.data.map(ts => (
<TimesheetCard
key={ts.id}
timesheet={ts}
onApprove={() => handleApprove(ts.id)}
/>
))}
</div>
)
}
```
#### IndexedDB State Hook (for backwards compatibility)
Automatically detects entity stores and uses appropriate storage:
```typescript
import { useIndexedDBState } from '@/hooks/use-indexed-db-state'
import { STORES } from '@/lib/indexed-db'
function MyComponent() {
// For entity stores: uses IndexedDB entity storage
const [timesheets, setTimesheets] = useIndexedDBState<Timesheet[]>(
STORES.TIMESHEETS,
[]
)
// For non-entity data: uses appState storage
const [preferences, setPreferences] = useIndexedDBState(
'user-preferences',
{ theme: 'light' }
)
// Update always uses functional form for safety
const addTimesheet = (newTimesheet: Timesheet) => {
setTimesheets(current => [...current, newTimesheet])
}
}
```
## Migration from KV Storage
The application has been migrated from KV storage to IndexedDB. Key changes:
1. **`use-app-data` hook**: Now uses `useIndexedDBState` instead of `useKV`
2. **Data persistence**: All entity data is stored in dedicated IndexedDB stores
3. **Performance**: Bulk operations and indexed queries provide better performance
4. **Querying**: Native support for filtering and querying via indexes
### Before (KV Storage)
```typescript
const [timesheets, setTimesheets] = useKV<Timesheet[]>('timesheets', [])
```
### After (IndexedDB)
```typescript
const [timesheets, setTimesheets] = useIndexedDBState<Timesheet[]>(STORES.TIMESHEETS, [])
// OR
const timesheets = useTimesheetsCRUD()
```
## Best Practices
1. **Always use functional updates** when modifying arrays/objects:
```typescript
// ✅ Good
setTimesheets(current => [...current, newItem])
// ❌ Bad (stale closure)
setTimesheets([...timesheets, newItem])
```
2. **Use entity-specific hooks** for typed operations:
```typescript
const timesheets = useTimesheetsCRUD()
```
3. **Leverage indexes** for efficient queries:
```typescript
const pendingItems = await readByIndex('status', 'pending')
```
4. **Handle errors appropriately**:
```typescript
try {
await timesheets.create(newTimesheet)
} catch (error) {
toast.error('Failed to create timesheet')
}
```
5. **Use bulk operations** for multiple items:
```typescript
await timesheets.bulkCreate([item1, item2, item3])
```
## Benefits
-**Structured storage**: Proper relational-style data organization
-**Indexed queries**: Fast lookups by common fields
-**Bulk operations**: Efficient batch processing
-**Type safety**: Full TypeScript support
-**Offline support**: Works without network connection
-**Performance**: Better than key-value for complex data
-**Transactional**: ACID guarantees for data integrity
-**Observable**: React hooks provide reactive updates
## Debugging
Open browser DevTools → Application → IndexedDB → WorkForceProDB to inspect data directly.

View File

@@ -1,36 +1,68 @@
import { useState, useEffect } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { toast } from 'sonner'
import { indexedDB, STORES } from '@/lib/indexed-db'
import { Database, Download, Trash, ArrowsClockwise, HardDrive } from '@phosphor-icons/react'
export function DataManagement() {
const [stats, setStats] = useState<Record<string, number>>({})
const [isLoading, setIsLoading] = useState(false)
const loadStats = async () => {
const newStats: Record<string, number> = {}
for (const [key, storeName] of Object.entries(STORES)) {
if (key !== 'SESSIONS' && key !== 'APP_STATE') {
try {
const data = await indexedDB.readAll(storeName)
newStats[storeName] = data.length
} catch (error) {
newStats[storeName] = 0
}
}
}
setStats(newStats)
}
useEffect(() => {
loadStats()
}, [])
const resetAllData = async () => {
setIsLoading(true)
try {
await window.spark.kv.delete('sample-data-initialized')
await window.spark.kv.delete('timesheets')
await window.spark.kv.delete('invoices')
await window.spark.kv.delete('payroll-runs')
await window.spark.kv.delete('workers')
await window.spark.kv.delete('compliance-docs')
await window.spark.kv.delete('expenses')
await window.spark.kv.delete('rate-cards')
await window.spark.kv.delete('clients')
await indexedDB.deleteAll(STORES.TIMESHEETS)
await indexedDB.deleteAll(STORES.INVOICES)
await indexedDB.deleteAll(STORES.PAYROLL_RUNS)
await indexedDB.deleteAll(STORES.WORKERS)
await indexedDB.deleteAll(STORES.COMPLIANCE_DOCS)
await indexedDB.deleteAll(STORES.EXPENSES)
await indexedDB.deleteAll(STORES.RATE_CARDS)
await indexedDB.deleteAppState('sample-data-initialized')
await loadStats()
toast.success('Data cleared - refresh to reload from JSON')
} catch (error) {
toast.error('Failed to clear data')
console.error(error)
} finally {
setIsLoading(false)
}
}
const exportData = async () => {
setIsLoading(true)
try {
const timesheets = await window.spark.kv.get('timesheets')
const invoices = await window.spark.kv.get('invoices')
const payrollRuns = await window.spark.kv.get('payroll-runs')
const workers = await window.spark.kv.get('workers')
const complianceDocs = await window.spark.kv.get('compliance-docs')
const expenses = await window.spark.kv.get('expenses')
const rateCards = await window.spark.kv.get('rate-cards')
const clients = await window.spark.kv.get('clients')
const timesheets = await indexedDB.readAll(STORES.TIMESHEETS)
const invoices = await indexedDB.readAll(STORES.INVOICES)
const payrollRuns = await indexedDB.readAll(STORES.PAYROLL_RUNS)
const workers = await indexedDB.readAll(STORES.WORKERS)
const complianceDocs = await indexedDB.readAll(STORES.COMPLIANCE_DOCS)
const expenses = await indexedDB.readAll(STORES.EXPENSES)
const rateCards = await indexedDB.readAll(STORES.RATE_CARDS)
const data = {
timesheets,
@@ -40,7 +72,8 @@ export function DataManagement() {
complianceDocs,
expenses,
rateCards,
clients
exportedAt: new Date().toISOString(),
version: 2
}
const dataStr = JSON.stringify(data, null, 2)
@@ -48,38 +81,118 @@ export function DataManagement() {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `workforce-data-${new Date().toISOString().split('T')[0]}.json`
a.download = `workforce-indexeddb-export-${new Date().toISOString().split('T')[0]}.json`
a.click()
URL.revokeObjectURL(url)
toast.success('Data exported successfully')
} catch (error) {
toast.error('Failed to export data')
console.error(error)
} finally {
setIsLoading(false)
}
}
const clearStore = async (storeName: string, displayName: string) => {
setIsLoading(true)
try {
await indexedDB.deleteAll(storeName)
await loadStats()
toast.success(`${displayName} cleared`)
} catch (error) {
toast.error(`Failed to clear ${displayName}`)
console.error(error)
} finally {
setIsLoading(false)
}
}
return (
<Card className="p-6 space-y-4">
<div>
<h3 className="text-lg font-semibold mb-2">Data Management</h3>
<p className="text-sm text-muted-foreground mb-4">
Manage application data and reset to defaults
</p>
</div>
<div className="flex flex-col gap-2">
<Button onClick={exportData} variant="outline">
Export Current Data
</Button>
<Button onClick={resetAllData} variant="destructive">
Reset to Default Data
<Card className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-2">
<Database size={24} className="text-primary" weight="duotone" />
<h3 className="text-lg font-semibold">IndexedDB Data Management</h3>
</div>
<p className="text-sm text-muted-foreground">
Manage application data stored in browser IndexedDB
</p>
</div>
<Button
onClick={loadStats}
size="sm"
variant="ghost"
disabled={isLoading}
>
<ArrowsClockwise size={16} />
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p> Export: Download current data as JSON file</p>
<p> Reset: Clear all data and reload from app-data.json</p>
<p> After reset, refresh the page to see changes</p>
<div className="space-y-3">
<h4 className="text-sm font-medium flex items-center gap-2">
<HardDrive size={16} />
Data Stores
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{[
{ store: STORES.TIMESHEETS, name: 'Timesheets' },
{ store: STORES.INVOICES, name: 'Invoices' },
{ store: STORES.PAYROLL_RUNS, name: 'Payroll Runs' },
{ store: STORES.WORKERS, name: 'Workers' },
{ store: STORES.COMPLIANCE_DOCS, name: 'Compliance Docs' },
{ store: STORES.EXPENSES, name: 'Expenses' },
{ store: STORES.RATE_CARDS, name: 'Rate Cards' },
].map(({ store, name }) => (
<div
key={store}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{name}</span>
<Badge variant="secondary">
{stats[store] ?? 0} records
</Badge>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => clearStore(store, name)}
disabled={isLoading || !stats[store]}
>
<Trash size={16} />
</Button>
</div>
))}
</div>
</div>
<div className="flex flex-col gap-2 pt-4 border-t">
<Button
onClick={exportData}
variant="outline"
disabled={isLoading}
>
<Download size={16} />
Export All Data (JSON)
</Button>
<Button
onClick={resetAllData}
variant="destructive"
disabled={isLoading}
>
<Trash size={16} />
Clear All Data
</Button>
</div>
<div className="text-xs text-muted-foreground space-y-1 pt-2 border-t">
<p> <strong>Export:</strong> Download all data as JSON file</p>
<p> <strong>Clear All:</strong> Delete all data and reload from app-data.json</p>
<p> <strong>Clear Store:</strong> Delete data from specific entity store</p>
<p> <strong>Note:</strong> After clearing data, refresh the page to reload defaults</p>
<p className="text-primary"> <strong>Storage:</strong> Using IndexedDB for structured data persistence</p>
</div>
</Card>
)

View File

@@ -106,6 +106,16 @@ export { useSessionStorage } from './use-session-storage'
export { useSessionTimeout } from './use-session-timeout'
export { useSessionTimeoutPreferences } from './use-session-timeout-preferences'
export { useIndexedDBState, useIndexedDBCache } from './use-indexed-db-state'
export { useCRUD } from './use-crud'
export {
useTimesheetsCRUD,
useInvoicesCRUD,
usePayrollRunsCRUD,
useWorkersCRUD,
useComplianceDocsCRUD,
useExpensesCRUD,
useRateCardsCRUD
} from './use-entity-crud'
export type { AsyncState } from './use-async'
export type { FormErrors } from './use-form-validation'

View File

@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import { useKV } from '@github/spark/hooks'
import { useIndexedDBState } from '@/hooks/use-indexed-db-state'
import { STORES } from '@/lib/indexed-db'
import type {
Timesheet,
Invoice,
@@ -12,13 +13,13 @@ import type {
} from '@/lib/types'
export function useAppData() {
const [timesheets = [], setTimesheets] = useKV<Timesheet[]>('timesheets', [])
const [invoices = [], setInvoices] = useKV<Invoice[]>('invoices', [])
const [payrollRuns = [], setPayrollRuns] = useKV<PayrollRun[]>('payroll-runs', [])
const [workers = [], setWorkers] = useKV<Worker[]>('workers', [])
const [complianceDocs = [], setComplianceDocs] = useKV<ComplianceDocument[]>('compliance-docs', [])
const [expenses = [], setExpenses] = useKV<Expense[]>('expenses', [])
const [rateCards = [], setRateCards] = useKV<RateCard[]>('rate-cards', [])
const [timesheets = [], setTimesheets] = useIndexedDBState<Timesheet[]>(STORES.TIMESHEETS, [])
const [invoices = [], setInvoices] = useIndexedDBState<Invoice[]>(STORES.INVOICES, [])
const [payrollRuns = [], setPayrollRuns] = useIndexedDBState<PayrollRun[]>(STORES.PAYROLL_RUNS, [])
const [workers = [], setWorkers] = useIndexedDBState<Worker[]>(STORES.WORKERS, [])
const [complianceDocs = [], setComplianceDocs] = useIndexedDBState<ComplianceDocument[]>(STORES.COMPLIANCE_DOCS, [])
const [expenses = [], setExpenses] = useIndexedDBState<Expense[]>(STORES.EXPENSES, [])
const [rateCards = [], setRateCards] = useIndexedDBState<RateCard[]>(STORES.RATE_CARDS, [])
const metrics: DashboardMetrics = useMemo(() => {
const monthlyRevenue = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0)

179
src/hooks/use-crud.ts Normal file
View File

@@ -0,0 +1,179 @@
import { useState, useCallback } from 'react'
import { indexedDB, BaseEntity } from '@/lib/indexed-db'
interface CRUDHookResult<T extends BaseEntity> {
data: T[]
isLoading: boolean
error: Error | null
create: (entity: T) => Promise<T>
read: (id: string) => Promise<T | null>
readAll: () => Promise<T[]>
readByIndex: (indexName: string, value: any) => Promise<T[]>
update: (entity: T) => Promise<T>
remove: (id: string) => Promise<void>
removeAll: () => Promise<void>
bulkCreate: (entities: T[]) => Promise<T[]>
bulkUpdate: (entities: T[]) => Promise<T[]>
query: (predicate: (entity: T) => boolean) => Promise<T[]>
refresh: () => Promise<void>
}
export function useCRUD<T extends BaseEntity>(storeName: string): CRUDHookResult<T> {
const [data, setData] = useState<T[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const refresh = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const entities = await indexedDB.readAll<T>(storeName)
setData(entities)
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to load data')
setError(error)
throw error
} finally {
setIsLoading(false)
}
}, [storeName])
const create = useCallback(async (entity: T): Promise<T> => {
setError(null)
try {
const created = await indexedDB.create(storeName, entity)
await refresh()
return created
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to create entity')
setError(error)
throw error
}
}, [storeName, refresh])
const read = useCallback(async (id: string): Promise<T | null> => {
setError(null)
try {
return await indexedDB.read<T>(storeName, id)
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to read entity')
setError(error)
throw error
}
}, [storeName])
const readAll = useCallback(async (): Promise<T[]> => {
setError(null)
try {
const entities = await indexedDB.readAll<T>(storeName)
setData(entities)
return entities
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to read all entities')
setError(error)
throw error
}
}, [storeName])
const readByIndex = useCallback(async (indexName: string, value: any): Promise<T[]> => {
setError(null)
try {
return await indexedDB.readByIndex<T>(storeName, indexName, value)
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to read entities by index')
setError(error)
throw error
}
}, [storeName])
const update = useCallback(async (entity: T): Promise<T> => {
setError(null)
try {
const updated = await indexedDB.update(storeName, entity)
await refresh()
return updated
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to update entity')
setError(error)
throw error
}
}, [storeName, refresh])
const remove = useCallback(async (id: string): Promise<void> => {
setError(null)
try {
await indexedDB.delete(storeName, id)
await refresh()
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to delete entity')
setError(error)
throw error
}
}, [storeName, refresh])
const removeAll = useCallback(async (): Promise<void> => {
setError(null)
try {
await indexedDB.deleteAll(storeName)
setData([])
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to delete all entities')
setError(error)
throw error
}
}, [storeName])
const bulkCreate = useCallback(async (entities: T[]): Promise<T[]> => {
setError(null)
try {
const created = await indexedDB.bulkCreate(storeName, entities)
await refresh()
return created
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to bulk create entities')
setError(error)
throw error
}
}, [storeName, refresh])
const bulkUpdate = useCallback(async (entities: T[]): Promise<T[]> => {
setError(null)
try {
const updated = await indexedDB.bulkUpdate(storeName, entities)
await refresh()
return updated
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to bulk update entities')
setError(error)
throw error
}
}, [storeName, refresh])
const query = useCallback(async (predicate: (entity: T) => boolean): Promise<T[]> => {
setError(null)
try {
return await indexedDB.query<T>(storeName, predicate)
} catch (err) {
const error = err instanceof Error ? err : new Error('Failed to query entities')
setError(error)
throw error
}
}, [storeName])
return {
data,
isLoading,
error,
create,
read,
readAll,
readByIndex,
update,
remove,
removeAll,
bulkCreate,
bulkUpdate,
query,
refresh,
}
}

View File

@@ -0,0 +1,39 @@
import { useCRUD } from './use-crud'
import { STORES } from '@/lib/indexed-db'
import type {
Timesheet,
Invoice,
PayrollRun,
Worker,
ComplianceDocument,
Expense,
RateCard
} from '@/lib/types'
export function useTimesheetsCRUD() {
return useCRUD<Timesheet>(STORES.TIMESHEETS)
}
export function useInvoicesCRUD() {
return useCRUD<Invoice>(STORES.INVOICES)
}
export function usePayrollRunsCRUD() {
return useCRUD<PayrollRun>(STORES.PAYROLL_RUNS)
}
export function useWorkersCRUD() {
return useCRUD<Worker>(STORES.WORKERS)
}
export function useComplianceDocsCRUD() {
return useCRUD<ComplianceDocument>(STORES.COMPLIANCE_DOCS)
}
export function useExpensesCRUD() {
return useCRUD<Expense>(STORES.EXPENSES)
}
export function useRateCardsCRUD() {
return useCRUD<RateCard>(STORES.RATE_CARDS)
}

View File

@@ -1,5 +1,15 @@
import { useState, useEffect, useCallback } from 'react'
import { indexedDB } from '@/lib/indexed-db'
import { indexedDB, STORES } from '@/lib/indexed-db'
const ENTITY_STORE_NAMES = [
STORES.TIMESHEETS,
STORES.INVOICES,
STORES.PAYROLL_RUNS,
STORES.WORKERS,
STORES.COMPLIANCE_DOCS,
STORES.EXPENSES,
STORES.RATE_CARDS,
] as string[]
export function useIndexedDBState<T>(
key: string,
@@ -7,11 +17,25 @@ export function useIndexedDBState<T>(
): [T, (value: T | ((prev: T) => T)) => void, () => void] {
const [state, setState] = useState<T>(defaultValue)
const [isInitialized, setIsInitialized] = useState(false)
const isEntityStore = ENTITY_STORE_NAMES.includes(key)
useEffect(() => {
const loadState = async () => {
try {
const storedValue = await indexedDB.getAppState<T>(key)
let storedValue: T | null = null
if (isEntityStore) {
try {
const entities = await indexedDB.readAll(key)
storedValue = (entities.length > 0 ? entities : null) as T | null
} catch (error) {
console.warn(`Store "${key}" not accessible, using default value`, error)
storedValue = null
}
} else {
storedValue = await indexedDB.getAppState<T>(key)
}
if (storedValue !== null) {
setState(storedValue)
}
@@ -23,7 +47,7 @@ export function useIndexedDBState<T>(
}
loadState()
}, [key])
}, [key, isEntityStore])
const updateState = useCallback((value: T | ((prev: T) => T)) => {
setState(prevState => {
@@ -32,21 +56,39 @@ export function useIndexedDBState<T>(
: value
if (isInitialized) {
indexedDB.saveAppState(key, newState).catch(error => {
console.error(`Failed to save state for key "${key}":`, error)
})
if (isEntityStore && Array.isArray(newState)) {
indexedDB.deleteAll(key)
.then(() => {
if (newState.length > 0) {
return indexedDB.bulkCreate(key, newState)
}
})
.catch(error => {
console.error(`Failed to save entities for store "${key}":`, error)
})
} else {
indexedDB.saveAppState(key, newState).catch(error => {
console.error(`Failed to save state for key "${key}":`, error)
})
}
}
return newState
})
}, [key, isInitialized])
}, [key, isInitialized, isEntityStore])
const deleteState = useCallback(() => {
setState(defaultValue)
indexedDB.deleteAppState(key).catch(error => {
console.error(`Failed to delete state for key "${key}":`, error)
})
}, [key, defaultValue])
if (isEntityStore) {
indexedDB.deleteAll(key).catch(error => {
console.error(`Failed to delete entities from store "${key}":`, error)
})
} else {
indexedDB.deleteAppState(key).catch(error => {
console.error(`Failed to delete state for key "${key}":`, error)
})
}
}, [key, defaultValue, isEntityStore])
return [state, updateState, deleteState]
}

View File

@@ -1,7 +1,14 @@
const DB_NAME = 'WorkForceProDB'
const DB_VERSION = 1
const DB_VERSION = 2
const SESSION_STORE = 'sessions'
const APP_STATE_STORE = 'appState'
const TIMESHEETS_STORE = 'timesheets'
const INVOICES_STORE = 'invoices'
const PAYROLL_RUNS_STORE = 'payrollRuns'
const WORKERS_STORE = 'workers'
const COMPLIANCE_DOCS_STORE = 'complianceDocs'
const EXPENSES_STORE = 'expenses'
const RATE_CARDS_STORE = 'rateCards'
interface SessionData {
id: string
@@ -24,6 +31,11 @@ interface AppStateData {
timestamp: number
}
interface BaseEntity {
id: string
[key: string]: any
}
class IndexedDBManager {
private db: IDBDatabase | null = null
private initPromise: Promise<void> | null = null
@@ -56,6 +68,52 @@ class IndexedDBManager {
if (!db.objectStoreNames.contains(APP_STATE_STORE)) {
db.createObjectStore(APP_STATE_STORE, { keyPath: 'key' })
}
if (!db.objectStoreNames.contains(TIMESHEETS_STORE)) {
const timesheetsStore = db.createObjectStore(TIMESHEETS_STORE, { keyPath: 'id' })
timesheetsStore.createIndex('workerId', 'workerId', { unique: false })
timesheetsStore.createIndex('status', 'status', { unique: false })
timesheetsStore.createIndex('weekEnding', 'weekEnding', { unique: false })
}
if (!db.objectStoreNames.contains(INVOICES_STORE)) {
const invoicesStore = db.createObjectStore(INVOICES_STORE, { keyPath: 'id' })
invoicesStore.createIndex('clientId', 'clientId', { unique: false })
invoicesStore.createIndex('status', 'status', { unique: false })
invoicesStore.createIndex('invoiceDate', 'invoiceDate', { unique: false })
}
if (!db.objectStoreNames.contains(PAYROLL_RUNS_STORE)) {
const payrollStore = db.createObjectStore(PAYROLL_RUNS_STORE, { keyPath: 'id' })
payrollStore.createIndex('status', 'status', { unique: false })
payrollStore.createIndex('periodEnding', 'periodEnding', { unique: false })
}
if (!db.objectStoreNames.contains(WORKERS_STORE)) {
const workersStore = db.createObjectStore(WORKERS_STORE, { keyPath: 'id' })
workersStore.createIndex('status', 'status', { unique: false })
workersStore.createIndex('email', 'email', { unique: false })
}
if (!db.objectStoreNames.contains(COMPLIANCE_DOCS_STORE)) {
const complianceStore = db.createObjectStore(COMPLIANCE_DOCS_STORE, { keyPath: 'id' })
complianceStore.createIndex('workerId', 'workerId', { unique: false })
complianceStore.createIndex('status', 'status', { unique: false })
complianceStore.createIndex('expiryDate', 'expiryDate', { unique: false })
}
if (!db.objectStoreNames.contains(EXPENSES_STORE)) {
const expensesStore = db.createObjectStore(EXPENSES_STORE, { keyPath: 'id' })
expensesStore.createIndex('workerId', 'workerId', { unique: false })
expensesStore.createIndex('status', 'status', { unique: false })
expensesStore.createIndex('date', 'date', { unique: false })
}
if (!db.objectStoreNames.contains(RATE_CARDS_STORE)) {
const rateCardsStore = db.createObjectStore(RATE_CARDS_STORE, { keyPath: 'id' })
rateCardsStore.createIndex('clientId', 'clientId', { unique: false })
rateCardsStore.createIndex('role', 'role', { unique: false })
}
}
})
@@ -273,7 +331,232 @@ class IndexedDBManager {
this.initPromise = null
}
}
async create<T extends BaseEntity>(storeName: string, entity: T): Promise<T> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.add(entity)
request.onsuccess = () => resolve(entity)
request.onerror = () => reject(new Error(`Failed to create entity in ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async read<T extends BaseEntity>(storeName: string, id: string): Promise<T | null> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
const request = store.get(id)
request.onsuccess = () => resolve(request.result || null)
request.onerror = () => reject(new Error(`Failed to read entity from ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async readAll<T extends BaseEntity>(storeName: string): Promise<T[]> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
const request = store.getAll()
request.onsuccess = () => resolve(request.result || [])
request.onerror = () => reject(new Error(`Failed to read all entities from ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async readByIndex<T extends BaseEntity>(
storeName: string,
indexName: string,
value: any
): Promise<T[]> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readonly')
const store = transaction.objectStore(storeName)
const index = store.index(indexName)
const request = index.getAll(value)
request.onsuccess = () => resolve(request.result || [])
request.onerror = () => reject(new Error(`Failed to read entities by index from ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async update<T extends BaseEntity>(storeName: string, entity: T): Promise<T> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.put(entity)
request.onsuccess = () => resolve(entity)
request.onerror = () => reject(new Error(`Failed to update entity in ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async delete(storeName: string, id: string): Promise<void> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.delete(id)
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error(`Failed to delete entity from ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async deleteAll(storeName: string): Promise<void> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(new Error(`Failed to clear store ${storeName}`))
} catch (error) {
reject(error)
}
})
}
async bulkCreate<T extends BaseEntity>(storeName: string, entities: T[]): Promise<T[]> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
let completed = 0
const errors: Error[] = []
entities.forEach(entity => {
const request = store.add(entity)
request.onsuccess = () => {
completed++
if (completed === entities.length) {
if (errors.length > 0) {
reject(new Error(`Failed to create ${errors.length} entities in ${storeName}`))
} else {
resolve(entities)
}
}
}
request.onerror = () => {
errors.push(new Error(`Failed to create entity with id ${entity.id}`))
completed++
if (completed === entities.length) {
reject(new Error(`Failed to create ${errors.length} entities in ${storeName}`))
}
}
})
if (entities.length === 0) {
resolve([])
}
} catch (error) {
reject(error)
}
})
}
async bulkUpdate<T extends BaseEntity>(storeName: string, entities: T[]): Promise<T[]> {
const db = await this.ensureDb()
return new Promise((resolve, reject) => {
try {
const transaction = db.transaction([storeName], 'readwrite')
const store = transaction.objectStore(storeName)
let completed = 0
const errors: Error[] = []
entities.forEach(entity => {
const request = store.put(entity)
request.onsuccess = () => {
completed++
if (completed === entities.length) {
if (errors.length > 0) {
reject(new Error(`Failed to update ${errors.length} entities in ${storeName}`))
} else {
resolve(entities)
}
}
}
request.onerror = () => {
errors.push(new Error(`Failed to update entity with id ${entity.id}`))
completed++
if (completed === entities.length) {
reject(new Error(`Failed to update ${errors.length} entities in ${storeName}`))
}
}
})
if (entities.length === 0) {
resolve([])
}
} catch (error) {
reject(error)
}
})
}
async query<T extends BaseEntity>(
storeName: string,
predicate: (entity: T) => boolean
): Promise<T[]> {
const all = await this.readAll<T>(storeName)
return all.filter(predicate)
}
}
export const indexedDB = new IndexedDBManager()
export type { SessionData, AppStateData }
export const STORES = {
SESSIONS: SESSION_STORE,
APP_STATE: APP_STATE_STORE,
TIMESHEETS: TIMESHEETS_STORE,
INVOICES: INVOICES_STORE,
PAYROLL_RUNS: PAYROLL_RUNS_STORE,
WORKERS: WORKERS_STORE,
COMPLIANCE_DOCS: COMPLIANCE_DOCS_STORE,
EXPENSES: EXPENSES_STORE,
RATE_CARDS: RATE_CARDS_STORE,
} as const
export type { SessionData, AppStateData, BaseEntity }