State Management
Bilko State Management
Current State: Zustand stores fully implemented for all domains + React hooks for local UI state Zustand Version: 4.5.0 Pattern: All stores try API first, fall back to mock data when API unavailable
Architecture Overview
flowchart LR
subgraph CURRENT["Current Architecture"]
MD["lib/mock-data.ts\n(fallback)"]
API_NOW["Backend API\n(Express + PostgreSQL)"] -->|try first| ST_NOW["Zustand Stores\nauth / invoices / expenses\nbanking / contact / dashboard / settings"]
MD -->|fallback if API down| ST_NOW
ST_NOW -->|subscribe| PG["Page Component\nuseInvoiceStore() etc."]
PG -->|renders| UI["UI Components"]
PG -->|local UI state| PG
end
subgraph LOCAL["Local UI State (React hooks only)"]
LSTATE["statusFilter / searchQuery / step\ndateRange / sortColumn / sidebarOpen\nactiveSection / plExpanded / etc."]
end
Implemented Zustand Stores (lib/stores/)
All stores are implemented and exported from lib/stores/index.ts:
| Store | Export | File |
|---|---|---|
| Auth | useAuthStore |
auth-store.ts |
| Invoices | useInvoiceStore |
invoice-store.ts |
| Expenses | useExpenseStore |
expense-store.ts |
| Banking | useBankingStore |
banking-store.ts |
| Contacts | useContactStore |
contact-store.ts |
| Dashboard | useDashboardStore |
dashboard-store.ts |
| Settings | useSettingsStore |
settings-store.ts |
Current State Patterns
Local Component State (React useState)
Usage: Most components use local state for UI interactions and form data.
Examples:
Dashboard Page:
// No local state — purely presentational, uses mock data imports
Invoice List Page:
const [statusFilter, setStatusFilter] = useState<string>("all")
const [searchQuery, setSearchQuery] = useState<string>("")
const [dateRange, setDateRange] = useState<string>("this-month")
const [sortColumn, setSortColumn] = useState<string>("date")
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc")
Invoice Wizard:
const [step, setStep] = useState(1)
const [customer, setCustomer] = useState<Contact | null>(null)
const [showAddCustomer, setShowAddCustomer] = useState(false)
const [invoiceDetails, setInvoiceDetails] = useState<InvoiceDetails>({...})
const [lineItems, setLineItems] = useState<LineItem[]>([...])
const [notes, setNotes] = useState("Thank you for your business!")
const [terms, setTerms] = useState("Payment due within 30 days.")
const [emailData, setEmailData] = useState({...})
Expenses Page:
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [periodFilter, setPeriodFilter] = useState("This Month")
const [categoryFilter, setCategoryFilter] = useState("All Categories")
const [searchQuery, setSearchQuery] = useState("")
const [formData, setFormData] = useState({...})
Banking Page:
const [selectedAccount, setSelectedAccount] = useState(mockBankAccounts[0].id)
Reports Page:
const [plExpanded, setPlExpanded] = useState({
revenue: true,
expenses: true
})
VAT Report:
const [currentStep, setCurrentStep] = useState<'reconciliation' | 'audit' | 'summary'>('reconciliation')
Settings Page:
const [activeSection, setActiveSection] = useState('company')
const [companyData, setCompanyData] = useState({...})
const [vatSettings, setVatSettings] = useState({...})
Dashboard Layout:
const [sidebarOpen, setSidebarOpen] = useState(false)
Local State Distribution by Page
flowchart TD
subgraph IL["Invoice List /invoices"]
IL1["statusFilter\nsearchQuery\ndateRange\nsortColumn\nsortDirection"]
end
subgraph IW["Invoice Wizard /invoices/new"]
IW1["step (1-6)\ncustomer\nshowAddCustomer\ninvoiceDetails\nlineItems\nnotes / terms\nemailData"]
end
subgraph EP["Expenses /expenses"]
EP1["isDialogOpen\nperiodFilter\ncategoryFilter\nsearchQuery\nformData"]
end
subgraph BP["Banking /banking"]
BP1["selectedAccount"]
end
subgraph RP["Reports /reports"]
RP1["plExpanded\n{revenue, expenses}"]
end
subgraph VP["VAT /reports/vat"]
VP1["currentStep\n'reconciliation' | 'audit' | 'summary'"]
end
subgraph SP["Settings /settings"]
SP1["activeSection\ncompanyData\nvatSettings"]
end
subgraph LY["Dashboard Layout"]
LY1["sidebarOpen (mobile)"]
end
subgraph SB["Sidebar component"]
SB1["stateless — usePathname() only"]
end
Computed State (React useMemo)
Purpose: Derived values from props/state to avoid expensive recalculations.
Invoice List:
// Filtered/sorted invoice list
const filteredInvoices = useMemo(() => {
let filtered = [...mockInvoices]
// Apply filters
if (statusFilter !== "all") {
filtered = filtered.filter((inv) => inv.status === statusFilter)
}
if (searchQuery) {
const query = searchQuery.toLowerCase()
filtered = filtered.filter(
(inv) =>
inv.customerName.toLowerCase().includes(query) ||
inv.number.toLowerCase().includes(query)
)
}
// Date range filter logic...
// Sort logic...
return filtered
}, [statusFilter, searchQuery, dateRange, sortColumn, sortDirection])
// Summary calculations
const summary = useMemo(() => {
const total = filteredInvoices.length
const byStatus = filteredInvoices.reduce((acc, inv) => {
// Aggregate by status...
return acc
}, {})
return { total, byStatus }
}, [filteredInvoices])
Invoice Wizard:
// Customer list
const customers = useMemo(
() => mockContacts.filter((c) => c.type === "customer"),
[]
)
// Calculate totals
const totals = useMemo(() => {
const subtotal = lineItems.reduce(
(sum, item) => sum + item.quantity * item.unitPrice,
0
)
const vatTotal = lineItems.reduce(
(sum, item) =>
sum + item.quantity * item.unitPrice * (item.vatRate / 100),
0
)
const total = subtotal + vatTotal
return { subtotal, vatTotal, total }
}, [lineItems])
Expenses Page:
// Filtered expenses
const filteredExpenses = useMemo(() => {
return mockExpenses.filter(expense => {
const matchesCategory = categoryFilter === "All Categories" || expense.category === categoryFilter
const matchesSearch = searchQuery === "" ||
expense.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
expense.vendor.toLowerCase().includes(searchQuery.toLowerCase())
return matchesCategory && matchesSearch
})
}, [categoryFilter, searchQuery])
// Stats
const stats = useMemo(() => {
const total = filteredExpenses.reduce((sum, exp) => {
// Convert to EUR and sum...
}, 0)
const pending = filteredExpenses.filter(e => e.status === 'pending').length
const approved = filteredExpenses.filter(e => e.status === 'approved').length
const paid = filteredExpenses.filter(e => e.status === 'paid').length
return { total, pending, approved, paid }
}, [filteredExpenses])
Banking Page:
// Total balance in EUR
const totalBalanceEUR = useMemo(() => {
return mockBankAccounts.reduce((sum, acc) => {
const eurAmount = acc.currency === 'EUR' ? acc.balance :
acc.currency === 'RSD' ? acc.balance / 117 :
acc.balance / 2 // BAM to EUR
return sum + eurAmount
}, 0)
}, [])
// Unreconciled transactions
const unreconciledTransactions = useMemo(() => {
return mockBankTransactions.filter(tx => !tx.reconciled && tx.bankAccountId === selectedAccount)
}, [selectedAccount])
Navigation State (Next.js usePathname)
const pathname = usePathname()
const isActive = (href: string | undefined) => {
if (!href) return false
return pathname === href
}
Usage: Highlights active navigation item based on current route.
Router State (Next.js useRouter)
Invoice Wizard:
const router = useRouter()
const handleNext = () => {
if (step === 6) {
alert("Invoice sent!")
router.push("/invoices")
} else {
setStep(step + 1)
}
}
const handleCancel = () => {
if (confirm("Are you sure you want to cancel? All changes will be lost.")) {
router.push("/invoices")
}
}
Usage: Programmatic navigation after form submission or cancel.
Data Flow (Current)
flowchart LR
MD["lib/mock-data.ts\nmockInvoices\nmockExpenses\nmockBankAccounts\nmockContacts\nmockBankTransactions"]
MD -->|"import { mockInvoices }"| IL["Invoice List\nfilter → sort → display"]
MD -->|"import { mockExpenses }"| EP["Expenses Page\nfilter → stats → display"]
MD -->|"import { mockBankAccounts }"| BP["Banking Page\ncurrency conversion → display"]
MD -->|"import { mockContacts }"| IW["Invoice Wizard\nfilter customers → wizard"]
MD -->|"import metrics"| DP["Dashboard\nmetrics → charts"]
IL -->|"useMemo(filteredInvoices)"| IT["Invoice Table"]
IL -->|"useMemo(summary)"| IS["Summary Bar"]
EP -->|"useMemo(filteredExpenses)"| ET["Expense Table"]
EP -->|"useMemo(stats)"| ES["Summary Stats"]
BP -->|"useMemo(totalBalanceEUR)"| BA["Balance Display"]
BP -->|"useMemo(unreconciledTx)"| REC["Reconcile Tab"]
Mock Data Import Pattern
All pages import mock data directly:
import { mockInvoices, mockExpenses, mockBankAccounts } from "@/lib/mock-data"
Issues:
- No centralized state
- Data changes lost on page refresh
- No persistence
- Each page re-imports same data
Data Transformation
Components transform mock data for display:
// Dashboard: Calculate metrics from raw data
const dashboardMetrics = {
cashBalance: 2478170,
revenueMTD: 485700,
unpaidInvoices: 218200,
// ...
}
// Invoice list: Filter/sort/search
const filteredInvoices = mockInvoices.filter(...)
// Banking: Currency conversion
const totalBalanceEUR = mockBankAccounts.reduce((sum, acc) => {
const eurAmount = convertToEUR(acc.balance, acc.currency)
return sum + eurAmount
}, 0)
Zustand (IMPLEMENTED — all domain stores active)
Package: zustand: ^4.5.0 (installed and used)
Current Usage: All 7 stores implemented in lib/stores/. Pattern: API call → mock data fallback on failure.
Stores and their responsibilities:
useAuthStore— user, organization, isAuthenticated, login/logout/checkAuth/refreshTokenuseInvoiceStore— invoices, fetchInvoices, createInvoice, updateInvoice, deleteInvoice, sendInvoice, markPaiduseExpenseStore— expenses, fetchExpenses, createExpense, updateExpense, deleteExpense, approveExpense, payExpenseuseBankingStore— accounts, transactions, selectedAccountId, fetchAccounts, fetchTransactions, importTransactionsuseContactStore— contacts, fetchContacts, createContact, updateContact, deleteContactuseDashboardStore— metrics, monthlyPL, receivablesAging, expensesByCategory, recentTransactionsuseSettingsStore— organization, taxRates, fetchSettings, updateSettings, fetchTaxRates, updateTaxRates
Zustand Store Reference
Implemented Store Interfaces
classDiagram
class AuthStore {
+user: User | null
+organization: Organization | null
+isAuthenticated: boolean
+isLoading: boolean
+error: string | null
+login(email, password) Promise~void~
+logout() Promise~void~
+refreshToken() Promise~void~
+checkAuth() Promise~boolean~
}
class OrgStore {
+organization: Organization | null
+settings: OrgSettings
+updateSettings(settings) Promise~void~
}
class InvoicesStore {
+invoices: Invoice[]
+isLoading: boolean
+error: string | null
+fetchInvoices(filters) Promise~void~
+createInvoice(data) Promise~Invoice~
+updateInvoice(id, data) Promise~void~
+deleteInvoice(id) Promise~void~
+sendInvoice(id, emailData) Promise~void~
}
class ExpensesStore {
+expenses: Expense[]
+isLoading: boolean
+fetchExpenses(filters) Promise~void~
+createExpense(data) Promise~Expense~
+updateExpense(id, data) Promise~void~
+deleteExpense(id) Promise~void~
}
class BankingStore {
+accounts: BankAccount[]
+transactions: BankTransaction[]
+isLoading: boolean
+fetchAccounts() Promise~void~
+fetchTransactions(accountId) Promise~void~
+importTransactions(accountId, file) Promise~void~
+reconcileTransaction(txId) Promise~void~
+linkTransaction(txId, type, id) Promise~void~
}
class UIStore {
+sidebarOpen: boolean
+sidebarExpandedSections: string[]
+theme: string
+toggleSidebar() void
+toggleSection(section) void
+setTheme(theme) void
}
AuthStore --> OrgStore : loads org on login
AuthStore --> InvoicesStore : scoped by orgId
AuthStore --> ExpensesStore : scoped by orgId
AuthStore --> BankingStore : scoped by orgId
Planned Zustand Stores
Auth Store
// IMPLEMENTED: lib/stores/auth-store.ts
interface AuthState {
user: User | null
organization: Organization | null
isAuthenticated: boolean
isLoading: boolean
error: string | null
login: (email: string, password: string) => Promise<void>
logout: () => Promise<void>
checkAuth: () => Promise<boolean>
refreshToken: () => Promise<void>
}
Organization Store
// SPECIFICATION (not yet a separate store — organization is part of AuthStore + SettingsStore)
// SettingsStore (lib/stores/settings-store.ts) covers this:
interface SettingsState {
organization: any | null
taxRates: any[]
isLoading: boolean
error: string | null
fetchSettings: () => Promise<void>
updateSettings: (data: any) => Promise<void>
fetchTaxRates: () => Promise<void>
updateTaxRates: (data: any) => Promise<void>
clearError: () => void
}
Invoices Store
// IMPLEMENTED: lib/stores/invoice-store.ts
interface InvoiceState {
invoices: any[]
currentInvoice: any | null
isLoading: boolean
error: string | null
meta: { page: number; limit: number; total: number } | null
fetchInvoices: (params?: Record<string, string>) => Promise<void>
fetchInvoice: (id: string) => Promise<void>
createInvoice: (data: any) => Promise<any>
updateInvoice: (id: string, data: any) => Promise<void>
deleteInvoice: (id: string) => Promise<void>
sendInvoice: (id: string) => Promise<void>
markPaid: (id: string, paidAt?: string) => Promise<void>
clearError: () => void
}
Expenses Store
// IMPLEMENTED: lib/stores/expense-store.ts
interface ExpenseState {
expenses: any[]
currentExpense: any | null
isLoading: boolean
error: string | null
meta: { page: number; limit: number; total: number } | null
fetchExpenses: (params?: Record<string, string>) => Promise<void>
fetchExpense: (id: string) => Promise<void>
createExpense: (data: any) => Promise<any>
updateExpense: (id: string, data: any) => Promise<void>
deleteExpense: (id: string) => Promise<void>
approveExpense: (id: string) => Promise<void>
payExpense: (id: string) => Promise<void>
clearError: () => void
}
Banking Store
// IMPLEMENTED: lib/stores/banking-store.ts
interface BankingState {
accounts: any[]
transactions: any[]
selectedAccountId: string | null
isLoading: boolean
error: string | null
fetchAccounts: () => Promise<void>
fetchTransactions: (accountId: string, params?: Record<string, string>) => Promise<void>
createAccount: (data: any) => Promise<any>
importTransactions: (accountId: string, file: File) => Promise<void>
setSelectedAccount: (id: string) => void
clearError: () => void
}
// Note: reconcileTransaction and linkTransaction not yet in store — SPECIFICATION for Phase 2
Planned /Stores (Not Yet Implemented
Implemented)
Note: UIStore is a specification for future implementation. It does not exist in the codebase yet.
UI Store
// SPECIFICATION — no UIStore exists yet. UI state is local React useState:
// DashboardLayout: sidebarOpen (useState)
// Sidebar: stateless (uses usePathname only)
// Settings: activeSection (useState)
// Reports: plExpanded (useState)
// VAT: currentStep (useState)
Migration Strategy
- Phase 2a: Create stores with API integration
- Phase 2b: Replace useState with store hooks in components
- Phase 2c: Add optimistic updates and caching
- Phase 2d: Implement persistence (localStorage for UI prefs)
Example Migration:
Before (current)current — store with mock fallback):
// Invoice list page
import { mockInvoicesuseInvoiceStore } from "@/lib/mock-data"stores"
const { invoices, isLoading, fetchInvoices } = useInvoiceStore()
useEffect(() => {
fetchInvoices()
}, [fetchInvoices])
const filteredInvoices = useMemo(() => {
return mockInvoices.invoices.filter(...)
}, [invoices, statusFilter, searchQuery, dateRange, sortColumn, sortDirection])
After (Phase 2):
// Invoice list page
import { useInvoicesStore } from '@/lib/stores'
const { invoices, fetchInvoices, isLoading } = useInvoicesStore()
useEffect(() => {
fetchInvoices({ status: statusFilter, dateRange })
}, [statusFilter, dateRange])
API Integration Pattern (Future)
API Client (lib/api.ts)
// Planned: lib/api.ts
const API_BASE = process.env.NEXT_PUBLIC_API_URL
export const api = {
get: async (endpoint: string) => {
const res = await fetch(`${API_BASE}${endpoint}`, {
headers: { Authorization: `Bearer ${getToken()}` }
})
if (!res.ok) throw new Error(await res.text())
return res.json()
},
post: async (endpoint: string, data: any) => {
const res = await fetch(`${API_BASE}${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`
},
body: JSON.stringify(data)
})
if (!res.ok) throw new Error(await res.text())
return res.json()
},
// PATCH, DELETE...
}
Store Integration
// IMPLEMENTED: lib/stores/invoices.ts
export const useInvoicesStore = create<InvoicesState>((set, get) => ({
invoices: [],
isLoading: false,
error: null,
fetchInvoices: async (filters) => {
set({ isLoading: true, error: null })
try {
const data = await api.get(`/invoices?${buildQueryString(filters)}`)
set({ invoices: data, isLoading: false })
} catch (error) {
set({ error: error.message, isLoading: false })
}
},
createInvoice: async (data) => {
const invoice = await api.post('/invoices', data)
set({ invoices: [...get().invoices, invoice] })
return invoice
},
// Other CRUD methods...
}))
Future Data Flow
flowchart LR
subgraph BROWSER["Browser"]
subgraph STORES["Zustand Stores"]
AS["AuthStore\ntoken, user"]
IS["InvoicesStore\ninvoices, isLoading"]
ES["ExpensesStore\nexpenses, isLoading"]
BS["BankingStore\naccounts, transactions"]
US["UIStore\nsidebarOpen, theme"]
end
subgraph PAGES["Pages"]
IP["Invoice List"]
IWP["Invoice Wizard"]
EP["Expenses"]
BP["Banking"]
end
subgraph PERSIST["Persistence"]
LS["localStorage\nUI prefs (UIStore)"]
CK["httpOnly Cookie\nJWT token"]
end
end
BE["Backend API\n(Express + PostgreSQL)"]
AS -->|"Authorization: Bearer"| BE
BE -->|"invoices[]"| IS
BE -->|"expenses[]"| ES
BE -->|"accounts[], txs[]"| BS
IS --> IP
IS --> IWP
ES --> EP
BS --> BP
US <-->|persist| LS
AS <-->|token| CK
Loading States (Future)
Current: No loading states (instant mock data)
Future: Loading skeletons and states
// Component usage (future)
const { invoices, isLoading } = useInvoicesStore()
if (isLoading) {
return <InvoiceListSkeleton />
}
Error Handling (Future)
Current: No error handling (mock data never fails)
Future: Error boundaries and toast notifications
// Store error state (future)
const { error } = useInvoicesStore()
if (error) {
toast.error(`Failed to load invoices: ${error}`)
}
Optimistic Updates (Future)
Concept: Update UI immediately, rollback on API failure
// Example: Delete invoice (future)
deleteInvoice: async (id) => {
// Optimistic update
const prevInvoices = get().invoices
set({ invoices: prevInvoices.filter(inv => inv.id !== id) })
try {
await api.delete(`/invoices/${id}`)
} catch (error) {
// Rollback on failure
set({ invoices: prevInvoices, error: error.message })
toast.error('Failed to delete invoice')
}
}
State Persistence (Future)
UI Preferences: localStorage Auth Token: httpOnly cookie (secure) Data Cache: sessionStorage (optional, for performance)
// Example: Persist sidebar state (future)
export const useUIStore = create(
persist<UIState>(
(set) => ({
sidebarOpen: false,
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen }))
}),
{ name: 'bilko-ui-preferences' }
)
)
Summary
Current State:
- Zustand stores implemented for all 7 domains (auth, invoices, expenses, banking, contacts, dashboard, settings)
- All stores follow API-first pattern with mock data fallback when API unavailable
- React hooks (useState, useMemo, useEffect) for local page UI state (filters, dialogs, wizard steps)
- No persistence — refreshing loses filter state
- Auth store includes organization field
Future State (Phase 2):
- Replace mock fallback with real API responses
- Add optimistic updates (rollback on failure)
- State persistence (localStorage for UI prefs)
- Loading skeleton states in pages that use stores