diff --git a/INDEXEDDB_CRUD.md b/INDEXEDDB_CRUD.md new file mode 100644 index 0000000..08677d9 --- /dev/null +++ b/INDEXEDDB_CRUD.md @@ -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(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 ( +
+ {isLoading && } + {error && {error.message}} + {data.map(item => {item.name})} +
+ ) +} +``` + +#### 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 ( +
+ {timesheets.data.map(ts => ( + handleApprove(ts.id)} + /> + ))} +
+ ) +} +``` + +#### 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( + 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('timesheets', []) +``` + +### After (IndexedDB) +```typescript +const [timesheets, setTimesheets] = useIndexedDBState(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. diff --git a/src/components/DataManagement.tsx b/src/components/DataManagement.tsx index 9d8d0a8..cdd4bf3 100644 --- a/src/components/DataManagement.tsx +++ b/src/components/DataManagement.tsx @@ -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>({}) + const [isLoading, setIsLoading] = useState(false) + + const loadStats = async () => { + const newStats: Record = {} + + 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 ( - -
-

Data Management

-

- Manage application data and reset to defaults -

-
- -
- -
-
-

• Export: Download current data as JSON file

-

• Reset: Clear all data and reload from app-data.json

-

• After reset, refresh the page to see changes

+
+

+ + Data Stores +

+
+ {[ + { 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 }) => ( +
+
+ {name} + + {stats[store] ?? 0} records + +
+ +
+ ))} +
+
+ +
+ + +
+ +
+

Export: Download all data as JSON file

+

Clear All: Delete all data and reload from app-data.json

+

Clear Store: Delete data from specific entity store

+

Note: After clearing data, refresh the page to reload defaults

+

Storage: Using IndexedDB for structured data persistence

) diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 57346cf..1623f50 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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' diff --git a/src/hooks/use-app-data.ts b/src/hooks/use-app-data.ts index 9637d41..2aa0823 100644 --- a/src/hooks/use-app-data.ts +++ b/src/hooks/use-app-data.ts @@ -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('timesheets', []) - const [invoices = [], setInvoices] = useKV('invoices', []) - const [payrollRuns = [], setPayrollRuns] = useKV('payroll-runs', []) - const [workers = [], setWorkers] = useKV('workers', []) - const [complianceDocs = [], setComplianceDocs] = useKV('compliance-docs', []) - const [expenses = [], setExpenses] = useKV('expenses', []) - const [rateCards = [], setRateCards] = useKV('rate-cards', []) + const [timesheets = [], setTimesheets] = useIndexedDBState(STORES.TIMESHEETS, []) + const [invoices = [], setInvoices] = useIndexedDBState(STORES.INVOICES, []) + const [payrollRuns = [], setPayrollRuns] = useIndexedDBState(STORES.PAYROLL_RUNS, []) + const [workers = [], setWorkers] = useIndexedDBState(STORES.WORKERS, []) + const [complianceDocs = [], setComplianceDocs] = useIndexedDBState(STORES.COMPLIANCE_DOCS, []) + const [expenses = [], setExpenses] = useIndexedDBState(STORES.EXPENSES, []) + const [rateCards = [], setRateCards] = useIndexedDBState(STORES.RATE_CARDS, []) const metrics: DashboardMetrics = useMemo(() => { const monthlyRevenue = invoices.reduce((sum, inv) => sum + (inv.amount || 0), 0) diff --git a/src/hooks/use-crud.ts b/src/hooks/use-crud.ts new file mode 100644 index 0000000..3122fc7 --- /dev/null +++ b/src/hooks/use-crud.ts @@ -0,0 +1,179 @@ +import { useState, useCallback } from 'react' +import { indexedDB, BaseEntity } from '@/lib/indexed-db' + +interface CRUDHookResult { + data: T[] + isLoading: boolean + error: Error | null + create: (entity: T) => Promise + read: (id: string) => Promise + readAll: () => Promise + readByIndex: (indexName: string, value: any) => Promise + update: (entity: T) => Promise + remove: (id: string) => Promise + removeAll: () => Promise + bulkCreate: (entities: T[]) => Promise + bulkUpdate: (entities: T[]) => Promise + query: (predicate: (entity: T) => boolean) => Promise + refresh: () => Promise +} + +export function useCRUD(storeName: string): CRUDHookResult { + const [data, setData] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + setIsLoading(true) + setError(null) + try { + const entities = await indexedDB.readAll(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 => { + 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 => { + setError(null) + try { + return await indexedDB.read(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 => { + setError(null) + try { + const entities = await indexedDB.readAll(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 => { + setError(null) + try { + return await indexedDB.readByIndex(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + setError(null) + try { + return await indexedDB.query(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, + } +} diff --git a/src/hooks/use-entity-crud.ts b/src/hooks/use-entity-crud.ts new file mode 100644 index 0000000..a6bd9b0 --- /dev/null +++ b/src/hooks/use-entity-crud.ts @@ -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(STORES.TIMESHEETS) +} + +export function useInvoicesCRUD() { + return useCRUD(STORES.INVOICES) +} + +export function usePayrollRunsCRUD() { + return useCRUD(STORES.PAYROLL_RUNS) +} + +export function useWorkersCRUD() { + return useCRUD(STORES.WORKERS) +} + +export function useComplianceDocsCRUD() { + return useCRUD(STORES.COMPLIANCE_DOCS) +} + +export function useExpensesCRUD() { + return useCRUD(STORES.EXPENSES) +} + +export function useRateCardsCRUD() { + return useCRUD(STORES.RATE_CARDS) +} diff --git a/src/hooks/use-indexed-db-state.ts b/src/hooks/use-indexed-db-state.ts index 446bf0b..2faecda 100644 --- a/src/hooks/use-indexed-db-state.ts +++ b/src/hooks/use-indexed-db-state.ts @@ -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( key: string, @@ -7,11 +17,25 @@ export function useIndexedDBState( ): [T, (value: T | ((prev: T) => T)) => void, () => void] { const [state, setState] = useState(defaultValue) const [isInitialized, setIsInitialized] = useState(false) + const isEntityStore = ENTITY_STORE_NAMES.includes(key) useEffect(() => { const loadState = async () => { try { - const storedValue = await indexedDB.getAppState(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(key) + } + if (storedValue !== null) { setState(storedValue) } @@ -23,7 +47,7 @@ export function useIndexedDBState( } loadState() - }, [key]) + }, [key, isEntityStore]) const updateState = useCallback((value: T | ((prev: T) => T)) => { setState(prevState => { @@ -32,21 +56,39 @@ export function useIndexedDBState( : 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] } diff --git a/src/lib/indexed-db.ts b/src/lib/indexed-db.ts index 7e748ae..416cc2b 100644 --- a/src/lib/indexed-db.ts +++ b/src/lib/indexed-db.ts @@ -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 | 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(storeName: string, entity: T): Promise { + 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(storeName: string, id: string): Promise { + 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(storeName: string): Promise { + 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( + storeName: string, + indexName: string, + value: any + ): Promise { + 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(storeName: string, entity: T): Promise { + 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 { + 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 { + 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(storeName: string, entities: T[]): Promise { + 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(storeName: string, entities: T[]): Promise { + 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( + storeName: string, + predicate: (entity: T) => boolean + ): Promise { + const all = await this.readAll(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 }