UNPKG

@accounter/client

Version:
105 lines (94 loc) 4.04 kB
import { useCallback, useMemo, useState } from 'react'; export type ChargeMatchItemStatus = 'pending' | 'matched' | 'skipped'; export type UseChargeMatchQueue<TItem extends { id: string }> = { /** The queue of base charges under review, in queue order */ items: TItem[]; /** Index of the currently viewed base charge */ activeIndex: number; /** The currently viewed base charge, or null once the queue is exhausted */ activeItem: TItem | null; /** True when every item in the queue was matched or skipped */ isDone: boolean; /** Session-only status per item id (no backend persistence) */ statusById: Record<string, ChargeMatchItemStatus>; /** * Manually pin a base charge as the active item — used to jump to an upcoming * charge or revisit a skipped one. Passing an id not in the queue is a no-op. */ selectItem: (id: string) => void; /** Flag an item as skipped and advance to the next one */ skipItem: (id: string) => void; /** * Report the merge outcome for an item: on success it is flagged as matched * and the queue advances; on failure nothing changes so the user can retry */ acceptItemStatus: (id: string, success: boolean) => void; }; /** * Local (session-only) state management for the charge matching review queue: * tracks the active item and each item's pending/matched/skipped status. * * The active item is derived as the first still-pending item rather than a * stored index, so the user's position and session statuses survive query * refetches — including refetches where the item set changes (e.g. a merged * charge dropping out of the queue, or filters changing the page). */ export function useChargeMatchQueue<TItem extends { id: string }>( items: TItem[], ): UseChargeMatchQueue<TItem> { const [overrides, setOverrides] = useState<Record<string, ChargeMatchItemStatus>>({}); // A manually pinned base charge takes precedence over the derived first-pending // item, letting the user jump to an upcoming charge or revisit a skipped one. const [selectedId, setSelectedId] = useState<string | null>(null); const statusById = useMemo(() => { const statuses: Record<string, ChargeMatchItemStatus> = {}; for (const item of items) { statuses[item.id] = overrides[item.id] ?? 'pending'; } return statuses; }, [items, overrides]); const firstPendingIndex = useMemo( () => items.findIndex(item => (overrides[item.id] ?? 'pending') === 'pending'), [items, overrides], ); const derivedIndex = firstPendingIndex === -1 ? items.length : firstPendingIndex; // Honor a manual selection while it still points at an item in the queue; // otherwise fall back to the first-pending item const selectedIndex = selectedId === null ? -1 : items.findIndex(item => item.id === selectedId); const activeIndex = selectedIndex === -1 ? derivedIndex : selectedIndex; const selectItem = useCallback( (id: string) => { // Ignore ids not in the queue, and matched charges (already merged on the // backend, so there is nothing left to review) if (!items.some(item => item.id === id) || (overrides[id] ?? 'pending') === 'matched') { return; } setSelectedId(id); }, [items, overrides], ); const skipItem = useCallback((id: string) => { setOverrides(prev => ({ ...prev, [id]: 'skipped' })); // Drop any manual pin so the queue advances to the next pending charge setSelectedId(null); }, []); const acceptItemStatus = useCallback((id: string, success: boolean) => { if (!success) { // Merge failed: stay on the same charge so the user can retry or skip return; } setOverrides(prev => ({ ...prev, [id]: 'matched' })); // Drop any manual pin so the queue advances to the next pending charge setSelectedId(null); }, []); return { items, activeIndex, activeItem: items[activeIndex] ?? null, isDone: derivedIndex >= items.length, statusById, selectItem, skipItem, acceptItemStatus, }; }