Skip to main content

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)

Sidebar:

Sidebar is a stateless component using only usePathname() for active route highlighting.


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)

Sidebar:

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/refreshToken
  • useInvoiceStore — invoices, fetchInvoices, createInvoice, updateInvoice, deleteInvoice, sendInvoice, markPaid
  • useExpenseStore — expenses, fetchExpenses, createExpense, updateExpense, deleteExpense, approveExpense, payExpense
  • useBankingStore — accounts, transactions, selectedAccountId, fetchAccounts, fetchTransactions, importTransactions
  • useContactStore — contacts, fetchContacts, createContact, updateContact, deleteContact
  • useDashboardStore — metrics, monthlyPL, receivablesAging, expensesByCategory, recentTransactions
  • useSettingsStore — 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

  1. Phase 2a: Create stores with API integration
  2. Phase 2b: Replace useState with store hooks in components
  3. Phase 2c: Add optimistic updates and caching
  4. 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