State Management
Bilko State Management
Current State: Primarily React hooks (useState, useEffect) Installed but Minimal Use: Zustand 4.5.0 Future State: Migrate to Zustand for global state
Architecture Overview
flowchart LR
subgraph CURRENT["Current Architecture (Phase 1)"]
MD["lib/mock-data.ts\n(static imports)"] -->|imported directly| PG["Page Component\n(useState + useMemo)"]
PG -->|renders| UI["UI Components"]
PG -->|local state only| PG
end
subgraph FUTURE["Future Architecture (Phase 2)"]
API["Backend API\n(Express + PostgreSQL)"] -->|fetch| ST["Zustand Stores\nauth / org / invoices\nexpenses / banking / ui"]
ST -->|subscribe| PG2["Page Component\nuseInvoicesStore()"]
PG2 -->|renders| UI2["UI Components"]
PG2 -->|optimistic update| ST
end
style CURRENT fill:#1a1a2e,stroke:#4a4a6e,color:#aaa
style FUTURE fill:#1a2e1a,stroke:#4a6e4a,color:#aaa
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)
const [expandedSections, setExpandedSections] = useState<string[]>([
"Sales",
"Purchases",
"Reports"
])
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["expandedSections: string[]"]
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 (Installed but Not Used)
Package: zustand: ^4.5.0 (installed in package.json)
Current Usage: None
Planned Usage: Global state stores for:
- User authentication state
- Organization/company data
- Cached invoices/expenses/contacts
- UI preferences (theme, sidebar expanded)
Future State Architecture (Phase 2)
Planned Zustand Store Structure
classDiagram
class AuthStore {
+user: User | null
+isAuthenticated: boolean
+isLoading: boolean
+token: string | null
+error: string | null
+login(email, password) Promise~void~
+logout() 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
// Planned: stores/auth.ts
interface AuthState {
user: User | null
isAuthenticated: boolean
token: string | null
login: (email: string, password: string) => Promise<void>
logout: () => void
refreshToken: () => Promise<void>
}
Organization Store
// Planned: stores/organization.ts
interface OrgState {
organization: Organization | null
settings: OrgSettings
updateSettings: (settings: Partial<OrgSettings>) => Promise<void>
}
Invoices Store
// Planned: stores/invoices.ts
interface InvoicesState {
invoices: Invoice[]
isLoading: boolean
error: string | null
fetchInvoices: (filters: InvoiceFilters) => Promise<void>
createInvoice: (data: InvoiceCreateData) => Promise<Invoice>
updateInvoice: (id: string, data: Partial<Invoice>) => Promise<void>
deleteInvoice: (id: string) => Promise<void>
sendInvoice: (id: string, emailData: EmailData) => Promise<void>
}
Expenses Store
// Planned: stores/expenses.ts
interface ExpensesState {
expenses: Expense[]
isLoading: boolean
fetchExpenses: (filters: ExpenseFilters) => Promise<void>
createExpense: (data: ExpenseCreateData) => Promise<Expense>
updateExpense: (id: string, data: Partial<Expense>) => Promise<void>
deleteExpense: (id: string) => Promise<void>
}
Banking Store
// Planned: stores/banking.ts
interface BankingState {
accounts: BankAccount[]
transactions: BankTransaction[]
isLoading: boolean
fetchAccounts: () => Promise<void>
fetchTransactions: (accountId: string) => Promise<void>
importTransactions: (accountId: string, file: File) => Promise<void>
reconcileTransaction: (txId: string) => Promise<void>
linkTransaction: (txId: string, linkType: string, linkId: string) => Promise<void>
}
UI Store
// Planned: stores/ui.ts
interface UIState {
sidebarOpen: boolean
sidebarExpandedSections: string[]
theme: 'light' | 'dark'
toggleSidebar: () => void
toggleSection: (section: string) => void
setTheme: (theme: 'light' | 'dark') => void
}
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):
// Invoice list page
const [invoices, setInvoices] = useState<Invoice[]>(mockInvoices)
After (Phase 2):
// Invoice list page
import { useInvoicesStore } from '@/stores/invoices'
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
// Planned: 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:
- React hooks (useState, useMemo, useEffect)
- Mock data imports
- Local component state
- No persistence
- No global state
- Zustand installed but unused
Future State (Phase 2):
- Zustand stores for global state
- API integration layer
- Loading/error states
- Optimistic updates
- State persistence (UI prefs)
- JWT token management
No comments to display
No comments to display