Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
3a6430a
feat(OUT-3886): store user-selected Xero accounts on settings
SandipBajracharya Jun 18, 2026
ec52fb0
feat(OUT-3886): use selected Xero accounts during sync with fallback
SandipBajracharya Jun 18, 2026
b1f2d42
fix(OUT-3886): harden Xero account resolution against archived accounts
SandipBajracharya Jun 22, 2026
5bf0658
feat(OUT-3886): post invoice payments against the originating sales a…
SandipBajracharya Jun 22, 2026
6bae40f
refactor(OUT-3886): rename selected → selectedAccount
SandipBajracharya Jun 22, 2026
85b2345
feat(OUT-3887): add XeroAccounts service and client account types
SandipBajracharya Jun 22, 2026
3c1dba2
feat(OUT-3887): deliver xero accounts to settings context
SandipBajracharya Jun 22, 2026
8659d15
feat(OUT-3887): add account mapping dropdown components
SandipBajracharya Jun 22, 2026
241add5
feat(OUT-3887): add account mapping accordion with confirm mode
SandipBajracharya Jun 22, 2026
7719dc8
fix(OUT-3887): make account default option keyboard-reachable
SandipBajracharya Jun 22, 2026
7357083
refactor(OUT-3887): extract page data fetching into getPageData
SandipBajracharya Jun 23, 2026
e5bf5bc
feat(OUT-3887): add reusable SearchableSelectMenu component
SandipBajracharya Jun 23, 2026
8aa79f1
refactor(OUT-3887): migrate AccountSelect to SearchableSelectMenu
SandipBajracharya Jun 23, 2026
1ca0cbd
fix(OUT-3887): preserve OR search semantics and stabilize close
SandipBajracharya Jun 23, 2026
47d44c6
refactor(OUT-3887): migrate ProductMappingTableRow to SearchableSelec…
SandipBajracharya Jun 23, 2026
902c31d
refactor(OUT-3887): extract getTrigger helper in AccountSelect
SandipBajracharya Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 73 additions & 27 deletions src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { defaultSettings } from '@settings/constants/defaults'
import { SettingsContextProvider } from '@settings/context/SettingsContext'
import ProductMappingsService from '@settings/lib/ProductMappings.service'
import SettingsService from '@settings/lib/Settings.service'
import XeroAccountsService from '@settings/lib/XeroAccounts.service'
import { SyncLogsService } from '@sync-logs/lib/SyncLogs.service'
import type { CountryCode } from 'xero-node'
import type { PageProps } from '@/app/(home)/types'
Expand All @@ -18,6 +19,7 @@ import { CopilotAPI } from '@/lib/copilot/CopilotAPI'
import { serializeClientUser } from '@/lib/copilot/models/ClientUser.model'
import User from '@/lib/copilot/models/User.model'
import logger from '@/lib/logger'
import type { ClientXeroAccounts } from '@/lib/xero/accounts'
import { isSupportedCountry } from '@/lib/xero/region'
import type { ClientXeroItem } from '@/lib/xero/types'
import XeroAPI from '@/lib/xero/XeroAPI'
Expand Down Expand Up @@ -112,6 +114,20 @@ const getXeroItems = async (user: User, connection: XeroConnection): Promise<Cli
return await productMappingsService.getClientXeroItems()
}

const getXeroAccounts = async (
user: User,
connection: XeroConnection,
): Promise<ClientXeroAccounts> => {
if (!connection.tenantId || !connection.status || !connection.tokenSet)
return { income: [], bank: [], expense: [], archivedAccountCodes: [] }

const xeroAccountsService = new XeroAccountsService(
user,
connection as XeroConnectionWithTokenSet,
)
return await xeroAccountsService.getClientXeroAccounts()
}

const getLastSyncedAt = async (user: User, connection: XeroConnection): Promise<Date | null> => {
if (!connection.tenantId || !connection.tokenSet) return null

Expand All @@ -128,6 +144,53 @@ const getCountryCode = async (connection: XeroConnection): Promise<CountryCode |
return countryCode || null
}

const getPageData = async (user: User, connection: XeroConnection) => {
let xeroAuthFailed = false
const onAuthError = () => {
xeroAuthFailed = true
}

const [settings, productMappings, xeroItems, xeroAccounts, lastSyncedAt, countryCode] =
await Promise.all([
getSettings(user, connection),
withXeroErrorHandler(
getProductMappings(user, connection),
[],
'Error fetching product mappings',
onAuthError,
),
withXeroErrorHandler(
getXeroItems(user, connection),
[],
'Error fetching xero items',
onAuthError,
),
withXeroErrorHandler(
getXeroAccounts(user, connection),
{ income: [], bank: [], expense: [], archivedAccountCodes: [] },
'Error fetching xero accounts',
onAuthError,
),
getLastSyncedAt(user, connection),
withXeroErrorHandler(
getCountryCode(connection),
null,
'Error fetching organisation country code',
onAuthError,
),
])

return {
settings,
productMappings,
xeroItems,
xeroAccounts,
lastSyncedAt,
countryCode,
xeroAuthFailed,
}
}

const Home = async ({ searchParams }: PageProps) => {
const sp = await searchParams
const user = await User.authenticate(sp.token)
Expand All @@ -141,33 +204,15 @@ const Home = async ({ searchParams }: PageProps) => {
])
const connection = await ensureValidConnection(user, rawConnection)

let xeroAuthFailed = false
const onAuthError = () => {
xeroAuthFailed = true
}

const [settings, productMappings, xeroItems, lastSyncedAt, countryCode] = await Promise.all([
getSettings(user, connection),
withXeroErrorHandler(
getProductMappings(user, connection),
[],
'Error fetching product mappings',
onAuthError,
),
withXeroErrorHandler(
getXeroItems(user, connection),
[],
'Error fetching xero items',
onAuthError,
),
getLastSyncedAt(user, connection),
withXeroErrorHandler(
getCountryCode(connection),
null,
'Error fetching organisation country code',
onAuthError,
),
])
const {
settings,
productMappings,
xeroItems,
xeroAccounts,
lastSyncedAt,
countryCode,
xeroAuthFailed,
} = await getPageData(user, connection)

// Persist the resolved country code and gate sync to supported regions (US, AU)
if (countryCode && connection.tenantId) {
Expand Down Expand Up @@ -215,6 +260,7 @@ const Home = async ({ searchParams }: PageProps) => {
{...settings}
productMappings={productMappings}
xeroItems={xeroItems}
xeroAccounts={xeroAccounts}
>
<main className="min-h-[100vh] px-8 pt-6 pb-[54px] sm:px-[100px] lg:px-[220px]">
<RealtimeXeroConnections user={clientUser} />
Expand Down
155 changes: 155 additions & 0 deletions src/components/ui/SearchableSelectMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import { useDropdown } from '@settings/hooks/useDropdown'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'

// Focus index for the optional action row. -1 = none, 0.. = options.
const ACTION_INDEX = -2

interface SearchableSelectMenuProps<T> {
onClose: () => void
className?: string
searchPlaceholder?: string
options: T[]
getOptionKey: (option: T) => string
// Each value is matched independently against the query (OR semantics).
getSearchValues: (option: T) => string[]
renderOption: (option: T) => ReactNode
onSelect: (option: T) => void
emptyText: string
action?: {
render: () => ReactNode
onSelect: () => void
}
}

export const SearchableSelectMenu = <T,>({
onClose,
className,
searchPlaceholder = 'Search',
options,
getOptionKey,
getSearchValues,
renderOption,
onSelect,
emptyText,
action,
}: SearchableSelectMenuProps<T>) => {
// Keep a stable close reference so useDropdown doesn't re-bind its listener each render.
const onCloseRef = useRef(onClose)
onCloseRef.current = onClose
const close = useCallback(() => onCloseRef.current(), [])

const { dropdownRef } = useDropdown({ setOpenDropdownId: close })
const [searchQuery, setSearchQuery] = useState('')
const [focusedIndex, setFocusedIndex] = useState(-1)
const listRef = useRef<HTMLDivElement>(null)

const query = searchQuery.toLowerCase()
const filtered = options.filter((option) =>
getSearchValues(option).some((value) => value.toLowerCase().includes(query)),
)

// biome-ignore lint/correctness/useExhaustiveDependencies: reset focus when query changes
useEffect(() => {
setFocusedIndex(-1)
}, [searchQuery])

useEffect(() => {
// Only option rows live in the scrollable list.
if (focusedIndex < 0 || !listRef.current) return
const focusedElement = listRef.current.children[focusedIndex] as HTMLElement | undefined
focusedElement?.scrollIntoView({ block: 'nearest' })
}, [focusedIndex])

const selectOption = (option: T) => {
onSelect(option)
close()
}

const selectAction = () => {
action?.onSelect()
close()
}

const handleKeyDown = (e: React.KeyboardEvent) => {
const lastIndex = filtered.length - 1
if (e.key === 'ArrowDown') {
e.preventDefault()
setFocusedIndex((prev) => {
// From the top, hit the action row first when present.
if (prev === -1) {
if (action) return ACTION_INDEX
return lastIndex >= 0 ? 0 : -1
}
if (prev === ACTION_INDEX) return lastIndex >= 0 ? 0 : ACTION_INDEX
return Math.min(prev + 1, lastIndex)
})
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setFocusedIndex((prev) => {
if (prev === -1) return lastIndex >= 0 ? lastIndex : action ? ACTION_INDEX : -1
if (prev === 0) return action ? ACTION_INDEX : 0
// Action row is the top; no wrap.
if (prev === ACTION_INDEX) return ACTION_INDEX
return Math.max(prev - 1, 0)
})
} else if (e.key === 'Enter') {
e.preventDefault()
if (focusedIndex === ACTION_INDEX) selectAction()
else if (filtered[focusedIndex]) selectOption(filtered[focusedIndex])
} else if (e.key === 'Escape') {
e.preventDefault()
close()
}
}

return (
<div ref={dropdownRef} className={className}>
<div className="px-3 py-2">
<input
type="text"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
// biome-ignore lint/a11y/noAutofocus: focus search on open
autoFocus
className="w-full text-sm focus:border-gray-500 focus:outline-none focus:ring-1 focus:ring-gray-500"
/>
</div>

{action && (
<div
className={`border-card-divider border-t-1 transition-colors hover:bg-gray-100 ${
focusedIndex === ACTION_INDEX ? 'bg-gray-100' : ''
}`}
>
<button
type="button"
className="h-full w-full cursor-pointer px-3 py-2 text-left text-sm text-text-primary"
onClick={selectAction}
>
{action.render()}
</button>
</div>
)}

<div className="max-h-56 overflow-y-auto border-card-divider border-t-1" ref={listRef}>
{filtered.map((option, index) => (
<button
type="button"
key={getOptionKey(option)}
onClick={() => selectOption(option)}
className={`flex w-full cursor-pointer items-center justify-between px-3 py-1.5 text-left text-sm transition-colors hover:bg-gray-100 ${
index === focusedIndex ? 'bg-gray-100' : ''
}`}
>
{renderOption(option)}
</button>
))}
{filtered.length === 0 && (
<div className="px-3 py-2 text-gray-500 text-sm">{emptyText}</div>
)}
</div>
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE "settings" ADD COLUMN "income_account_id" uuid;
ALTER TABLE "settings" ADD COLUMN "bank_account_id" uuid;
ALTER TABLE "settings" ADD COLUMN "expense_account_id" uuid;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "synced_invoices" ADD COLUMN "sales_account_id" uuid;
Loading
Loading