Compare commits

..

2 Commits

6 changed files with 198 additions and 105 deletions

View File

@ -27,6 +27,30 @@ const SPOTLIGHT_CACHE_TTL_MS = 10_000
const spotlightCache = new Map() const spotlightCache = new Map()
const runningSpotlightFetches = new Map() const runningSpotlightFetches = new Map()
const createEmptyUserSettings = () => ({
viewMode: {},
filterSidebarVisibility: {},
columnVisibility: {},
collapseState: {}
})
const normalizeUserSettingsCategory = (category) =>
category && typeof category === 'object' && !Array.isArray(category)
? category
: {}
const normalizeUserSettings = (settings = {}) => ({
viewMode: normalizeUserSettingsCategory(settings?.viewMode),
filterSidebarVisibility: normalizeUserSettingsCategory(
settings?.filterSidebarVisibility
),
columnVisibility: normalizeUserSettingsCategory(settings?.columnVisibility),
collapseState: normalizeUserSettingsCategory(settings?.collapseState)
})
const emitWithAcknowledgement = (socket, eventName, data) =>
new Promise((resolve) => socket.emit(eventName, data, resolve))
const stableStringify = (value) => { const stableStringify = (value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return `[${value.map(stableStringify).join(',')}]` return `[${value.map(stableStringify).join(',')}]`
@ -77,6 +101,8 @@ const ApiServerProvider = ({ children }) => {
const [showErrorModal, setShowErrorModal] = useState(false) const [showErrorModal, setShowErrorModal] = useState(false)
const [errorModalContent, setErrorModalContent] = useState('') const [errorModalContent, setErrorModalContent] = useState('')
const [retryCallback, setRetryCallback] = useState(null) const [retryCallback, setRetryCallback] = useState(null)
const [userSettings, setUserSettings] = useState(createEmptyUserSettings)
const [userSettingsLoaded, setUserSettingsLoaded] = useState(false)
const subscribedCallbacksRef = useRef(new Map()) const subscribedCallbacksRef = useRef(new Map())
const subscribedLockCallbacksRef = useRef(new Map()) const subscribedLockCallbacksRef = useRef(new Map())
const notificationListenersRef = useRef(new Set()) const notificationListenersRef = useRef(new Set())
@ -129,6 +155,68 @@ const ApiServerProvider = ({ children }) => {
notificationListenersRef.current.delete(callback) notificationListenersRef.current.delete(callback)
}, []) }, [])
const getUserSettings = useCallback(async (socket = socketRef.current) => {
if (!socket?.connected) {
return null
}
try {
const result = await emitWithAcknowledgement(
socket,
'getUserSettings',
{}
)
if (!result?.success) {
throw new Error(result?.error || 'Unable to get user settings')
}
const settings = normalizeUserSettings(result.settings)
setUserSettings(settings)
return settings
} catch (err) {
logger.error('Failed to get user settings:', err)
setUserSettings(createEmptyUserSettings())
return null
} finally {
setUserSettingsLoaded(true)
}
}, [])
const updateUserSettings = useCallback(
async (category, key, value) => {
setUserSettings((currentSettings) => ({
...currentSettings,
[category]: {
...currentSettings[category],
[key]: value
}
}))
const socket = socketRef.current
if (!socket?.connected) {
return null
}
try {
const result = await emitWithAcknowledgement(
socket,
'updateUserSettings',
{ category, key, value }
)
if (!result?.success) {
throw new Error(result?.error || 'Unable to update user settings')
}
return result.settings
} catch (err) {
logger.error('Failed to update user settings:', err)
messageApi.error('Failed to save user settings')
await getUserSettings(socket)
return null
}
},
[getUserSettings, messageApi]
)
const connectToServer = useCallback(() => { const connectToServer = useCallback(() => {
if (token && authenticated == true) { if (token && authenticated == true) {
logger.debug('Token is available, connecting to api server...') logger.debug('Token is available, connecting to api server...')
@ -143,7 +231,20 @@ const ApiServerProvider = ({ children }) => {
newSocket.on('connect', () => { newSocket.on('connect', () => {
logger.debug('Api Server connected') logger.debug('Api Server connected')
newSocket.emit('authenticate', { token: token }, () => { newSocket.emit('authenticate', { token: token }, async (result) => {
if (result?.valid !== true) {
setConnecting(false)
setError('Api Server authentication failed')
newSocket.disconnect()
return
}
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
await getUserSettings(newSocket)
if (!newSocket.connected) {
return
}
setConnecting(false) setConnecting(false)
setConnected(true) setConnected(true)
setError(null) setError(null)
@ -180,6 +281,8 @@ const ApiServerProvider = ({ children }) => {
setError('Api Server disconnected') setError('Api Server disconnected')
clearSubscriptions() clearSubscriptions()
setConnected(false) setConnected(false)
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
}) })
newSocket.on('connect_error', (err) => { newSocket.on('connect_error', (err) => {
@ -188,6 +291,8 @@ const ApiServerProvider = ({ children }) => {
setError('Api Server connection error') setError('Api Server connection error')
clearSubscriptions() clearSubscriptions()
setConnected(false) setConnected(false)
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
}) })
newSocket.on('error', (err) => { newSocket.on('error', (err) => {
@ -197,7 +302,14 @@ const ApiServerProvider = ({ children }) => {
socketRef.current = newSocket socketRef.current = newSocket
} }
}, [token, authenticated, messageApi, handleLockUpdate, clearSubscriptions]) }, [
token,
authenticated,
messageApi,
handleLockUpdate,
clearSubscriptions,
getUserSettings
])
useEffect(() => { useEffect(() => {
if (token && authenticated == true) { if (token && authenticated == true) {
@ -206,6 +318,8 @@ const ApiServerProvider = ({ children }) => {
logger.debug('Token not available, disconnecting api server...') logger.debug('Token not available, disconnecting api server...')
socketRef.current.disconnect() socketRef.current.disconnect()
socketRef.current = null socketRef.current = null
setUserSettings(createEmptyUserSettings())
setUserSettingsLoaded(false)
} }
// Clean up function // Clean up function
@ -1831,6 +1945,10 @@ const ApiServerProvider = ({ children }) => {
error, error,
connecting, connecting,
connected, connected,
userSettings,
userSettingsLoaded,
getUserSettings,
updateUserSettings,
lockObject, lockObject,
unlockObject, unlockObject,
fetchObjectLock, fetchObjectLock,

View File

@ -1,29 +1,20 @@
import { useState, useEffect } from 'react' import { useCallback, useContext } from 'react'
import { ApiServerContext } from '../context/ApiServerContext'
const useCollapseState = (componentName, defaultState = {}) => { const useCollapseState = (componentName, defaultState = {}) => {
const getInitialState = () => { const { userSettings, updateUserSettings } = useContext(ApiServerContext)
const stored = sessionStorage.getItem(`${componentName}_collapseState`) const collapseState =
if (stored) { userSettings.collapseState[componentName] ?? defaultState
return JSON.parse(stored)
}
return defaultState
}
const [collapseState, setCollapseState] = useState(getInitialState) const updateCollapseState = useCallback(
(key, value) => {
useEffect(() => { updateUserSettings('collapseState', componentName, {
sessionStorage.setItem( ...collapseState,
`${componentName}_collapseState`,
JSON.stringify(collapseState)
)
}, [collapseState, componentName])
const updateCollapseState = (key, value) => {
setCollapseState((prev) => ({
...prev,
[key]: value [key]: value
})) })
} },
[collapseState, componentName, updateUserSettings]
)
return [collapseState, updateCollapseState] return [collapseState, updateCollapseState]
} }

View File

@ -1,13 +1,10 @@
import { useState, useEffect } from 'react' import { useCallback, useContext, useMemo } from 'react'
import { getModelByName } from '../../../database/ObjectModels' import { getModelByName } from '../../../database/ObjectModels'
import { ApiServerContext } from '../context/ApiServerContext'
const useColumnVisibility = (type, customColumns) => { const useColumnVisibility = (type, customColumns) => {
const getInitialVisibility = () => { const { userSettings, updateUserSettings } = useContext(ApiServerContext)
const storageKey = `${type}_columnVisibility` const defaultVisibility = useMemo(() => {
const stored = sessionStorage.getItem(storageKey)
if (stored) {
return JSON.parse(stored)
}
// Default visibility - all columns visible // Default visibility - all columns visible
if (customColumns && Array.isArray(customColumns)) { if (customColumns && Array.isArray(customColumns)) {
return customColumns.reduce((acc, col) => { return customColumns.reduce((acc, col) => {
@ -28,23 +25,20 @@ const useColumnVisibility = (type, customColumns) => {
} }
return acc return acc
}, {}) }, {})
} }, [customColumns, type])
const [columnVisibility, setColumnVisibility] = useState(getInitialVisibility) const columnVisibility =
userSettings.columnVisibility[type] ?? defaultVisibility
useEffect(() => { const updateColumnVisibility = useCallback(
sessionStorage.setItem( (key, value) => {
`${type}_columnVisibility`, updateUserSettings('columnVisibility', type, {
JSON.stringify(columnVisibility) ...columnVisibility,
)
}, [columnVisibility, type])
const updateColumnVisibility = (key, value) => {
setColumnVisibility((prev) => ({
...prev,
[key]: value [key]: value
})) })
} },
[columnVisibility, type, updateUserSettings]
)
return [columnVisibility, updateColumnVisibility] return [columnVisibility, updateColumnVisibility]
} }

View File

@ -1,19 +1,21 @@
import { useState, useEffect } from 'react' import { useCallback, useContext } from 'react'
import { ApiServerContext } from '../context/ApiServerContext'
const useFilterSidebarVisibility = (componentName, defaultValue = false) => { const useFilterSidebarVisibility = (componentName, defaultValue = false) => {
const getInitialVisibility = () => { const { userSettings, updateUserSettings } = useContext(ApiServerContext)
const stored = sessionStorage.getItem(`${componentName}_filterSidebarVisibility`) const showFilterSidebar =
if (stored !== null) { userSettings.filterSidebarVisibility[componentName] ?? defaultValue
return stored === 'true'
}
return defaultValue
}
const [showFilterSidebar, setShowFilterSidebar] = useState(getInitialVisibility) const setShowFilterSidebar = useCallback(
(nextValue) => {
useEffect(() => { const value =
sessionStorage.setItem(`${componentName}_filterSidebarVisibility`, showFilterSidebar) typeof nextValue === 'function'
}, [showFilterSidebar, componentName]) ? nextValue(showFilterSidebar)
: nextValue
updateUserSettings('filterSidebarVisibility', componentName, value)
},
[componentName, showFilterSidebar, updateUserSettings]
)
return [showFilterSidebar, setShowFilterSidebar] return [showFilterSidebar, setShowFilterSidebar]
} }

View File

@ -1,16 +1,18 @@
import { useState, useEffect } from 'react' import { useCallback, useContext } from 'react'
import { ApiServerContext } from '../context/ApiServerContext'
const useViewMode = (componentName, defaultMode = 'list') => { const useViewMode = (componentName, defaultMode = 'list') => {
const getInitialViewMode = () => { const { userSettings, updateUserSettings } = useContext(ApiServerContext)
const stored = sessionStorage.getItem(`${componentName}_viewMode`) const viewMode = userSettings.viewMode[componentName] ?? defaultMode
return stored ? stored : defaultMode
}
const [viewMode, setViewMode] = useState(getInitialViewMode) const setViewMode = useCallback(
(nextMode) => {
useEffect(() => { const mode =
sessionStorage.setItem(`${componentName}_viewMode`, viewMode) typeof nextMode === 'function' ? nextMode(viewMode) : nextMode
}, [viewMode, componentName]) updateUserSettings('viewMode', componentName, mode)
},
[componentName, updateUserSettings, viewMode]
)
return [viewMode, setViewMode] return [viewMode, setViewMode]
} }

View File

@ -186,6 +186,13 @@ export const Payment = {
readOnly: true, readOnly: true,
columnWidth: 250 columnWidth: 250
}, },
{
name: 'postedAt',
label: 'Posted At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'invoice', name: 'invoice',
label: 'Invoice', label: 'Invoice',
@ -195,6 +202,13 @@ export const Payment = {
showHyperlink: true, showHyperlink: true,
columnWidth: 200 columnWidth: 200
}, },
{
name: 'authorisedAt',
label: 'Authorised At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'payTo', name: 'payTo',
label: 'Pay To', label: 'Pay To',
@ -211,27 +225,6 @@ export const Payment = {
return objectData?.invoice?.from return objectData?.invoice?.from
} }
}, },
{
name: 'paymentDate',
label: 'Payment Date',
type: 'dateTime',
required: true,
columnWidth: 175
},
{
name: 'postedAt',
label: 'Posted At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{
name: 'authorisedAt',
label: 'Authorised At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'declinedAt', name: 'declinedAt',
label: 'Declined At', label: 'Declined At',
@ -239,13 +232,6 @@ export const Payment = {
readOnly: true, readOnly: true,
columnWidth: 175 columnWidth: 175
}, },
{
name: 'cancelledAt',
label: 'Cancelled At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'amount', name: 'amount',
label: 'Amount', label: 'Amount',
@ -255,19 +241,19 @@ export const Payment = {
required: true, required: true,
columnWidth: 150 columnWidth: 150
}, },
{
name: 'cancelledAt',
label: 'Cancelled At',
type: 'dateTime',
readOnly: true,
columnWidth: 175
},
{ {
name: 'paymentMethod', name: 'paymentMethod',
label: 'Payment Method', label: 'Payment Method',
type: 'string', type: 'string',
required: false, required: false,
columnWidth: 150 columnWidth: 150
},
{
name: 'notes',
label: 'Notes',
type: 'text',
required: false,
columnWidth: 200
} }
], ],
stats: [ stats: [