feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side (#1840)
* feat(bookkeeping): dashboard deep link filters verifikat utan underlag server-side The dashboard "Verifikat utan underlag" card (and the push-notification link) pointed at /bookkeeping?missingUnderlag=true, but nothing read the param: the user landed on the plain unfiltered ledger. The existing "Visa saknade underlag" toggle also only filtered the already-fetched page, so it could not represent the badge count across pages. - lib/bookkeeping/missing-underlag.ts: shared resolver of "posted verifikat lacking underlag" (document-requiring source types, no current-version document, no anchored supplier-invoice reference per BFL 5 kap 7 §, no exemption), extracted from the bulk "Inget underlag krävs" route so list, bulk remedy and dashboard badge share one predicate. - GET /api/bookkeeping/journal-entries?missing_underlag=true: resolves the full missing set server-side, applies the active sort stack, pages it, and returns the full-set count, fetching page rows in id chunks so the "Alla" page size cannot blow the PostgREST URL limit. - JournalEntryList: the toggle is now server-backed (refetch on change, honest count in the dialog badge); client-side re-filtering against late-arriving attachment counts removed. Deep-link arrival turns the filter on and scopes the visit to all fiscal years in memory only, matching the all-years badge count without touching the saved preference. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): harden the saknade-underlag filter after skeptic review Three skeptic subagents refuted the first cut; this fixes every confirmed finding in one pass: - FyPicker: new suppressAutoRestore prop. The deep-link visit opens as "Alla räkenskapsår" in memory, and FyPicker's on-load restore of the persisted year (value === null) snapped the scope back right after load, desyncing the list from the all-years badge that launched it. Manual picks still persist as usual. - Voucher-label search: the resolver now carries the same parseVoucher OR-branch as the direct list path, so searching "A209" with the filter on finds verifikat A209 instead of silently returning 0 rows. - Staleness while the filter is on: batch exempt, the single-row "Inget underlag krävs" toggle, and a row gaining its first underlag now refetch in place so fixed rows leave the filtered list and the count stays honest (the pre-server-filter behavior). The attachment-driven refetch is guarded per entry id against predicate-disagreement loops. - Drafts view: the filter switch is disabled there; the predicate is posted-only and the badge would mislabel the draft count. - Perf: the bulk-exempt route resolves ids only, skipping the per-row total_amount computed column on its full post-import candidate scan. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(bookkeeping): keep the underlag resolver statically checkable The skeptic-fix commit tripped the phantom-column scanner ceiling (tests/schema/no-phantom-columns.test.ts, 382 > 380): a computed select() string and a runtime-built .or() are expressions the scanner cannot resolve against the schema. Restructured instead of raising the ceiling: the idOnly/full column choice is two literal select() calls behind a lazy branch, and a voucher-label search fans out to two statically-checkable candidate queries (description ilike, series+number eq) unioned by id, same semantics as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
2e669a64e3
commit
0676f5a564
@@ -194,6 +194,7 @@ const ALL_PAGE_SIZE = 100000
|
||||
export default function JournalEntryList({
|
||||
pristineSlot,
|
||||
refreshToken,
|
||||
initialShowMissingOnly = false,
|
||||
}: {
|
||||
pristineSlot?: ReactNode
|
||||
/**
|
||||
@@ -204,6 +205,14 @@ export default function JournalEntryList({
|
||||
* selection, pagination and scroll position.
|
||||
*/
|
||||
refreshToken?: number
|
||||
/**
|
||||
* Deep-link arrival (dashboard "Verifikat utan underlag" card,
|
||||
* push-notification link): start with the "Visa saknade underlag" filter on
|
||||
* and, for this visit only, scope to all fiscal years so the visible set
|
||||
* matches the all-years dashboard count. The saved fiscal-year preference
|
||||
* is not overwritten.
|
||||
*/
|
||||
initialShowMissingOnly?: boolean
|
||||
} = {}) {
|
||||
const router = useRouter()
|
||||
const { toast } = useToast()
|
||||
@@ -241,7 +250,7 @@ export default function JournalEntryList({
|
||||
// (BFL 5 kap 5 §), not only on the detail page.
|
||||
const [rattelseFlags, setRattelseFlags] = useState<Set<string>>(new Set())
|
||||
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(initialShowMissingOnly)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [batchReason, setBatchReason] = useState('')
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
||||
@@ -257,6 +266,9 @@ export default function JournalEntryList({
|
||||
const [sortHydrated, setSortHydrated] = useState(false)
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [periodHydrated, setPeriodHydrated] = useState(false)
|
||||
// One-shot: a deep-link arrival scopes the first period resolution to all
|
||||
// years (see the period effect below) without touching the saved preference.
|
||||
const deepLinkAllYearsRef = useRef(initialShowMissingOnly)
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
const [dateFrom, setDateFrom] = useState('')
|
||||
const [dateTo, setDateTo] = useState('')
|
||||
@@ -450,6 +462,17 @@ export default function JournalEntryList({
|
||||
return
|
||||
}
|
||||
|
||||
// Deep-link arrival with the missing-underlag filter: scope this visit to
|
||||
// all fiscal years (in memory only) so the list can show the same set the
|
||||
// all-years dashboard badge counted. Consumed once; picking a year in the
|
||||
// FyPicker afterwards works and persists as usual.
|
||||
if (deepLinkAllYearsRef.current) {
|
||||
deepLinkAllYearsRef.current = false
|
||||
setPeriodId(null)
|
||||
setPeriodHydrated(true)
|
||||
return
|
||||
}
|
||||
|
||||
const stored =
|
||||
typeof window !== 'undefined'
|
||||
? window.localStorage.getItem(FISCAL_YEAR_STORAGE_KEY_PREFIX + company.id)
|
||||
@@ -536,6 +559,11 @@ export default function JournalEntryList({
|
||||
if (dateFrom) params.set('date_from', dateFrom)
|
||||
if (dateTo) params.set('date_to', dateTo)
|
||||
if (seriesFilter !== 'all') params.set('series', seriesFilter)
|
||||
// Server-side filter: the missing-underlag predicate spans documents,
|
||||
// supplier-invoice references and exemptions, so the server resolves it
|
||||
// across ALL pages (count included). Filtering the fetched page here
|
||||
// could only ever show this page's missing rows.
|
||||
if (showMissingOnly) params.set('missing_underlag', 'true')
|
||||
}
|
||||
if (search) params.set('search', search)
|
||||
|
||||
@@ -572,7 +600,10 @@ export default function JournalEntryList({
|
||||
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
||||
// a stale count of 0. Every other case refreshes the badge in the background.
|
||||
if (loadedEntries.length === 0 && listMode === 'committed') {
|
||||
const unscopedQuery = !periodId && !dateFrom && !dateTo && seriesFilter === 'all' && !search
|
||||
// A missing-underlag-filtered total of 0 says nothing about whether
|
||||
// the ledger has entries, so that mode never short-circuits the probe.
|
||||
const unscopedQuery =
|
||||
!periodId && !dateFrom && !dateTo && seriesFilter === 'all' && !search && !showMissingOnly
|
||||
await Promise.all([
|
||||
fetchDraftCount(),
|
||||
unscopedQuery
|
||||
@@ -642,7 +673,7 @@ export default function JournalEntryList({
|
||||
useEffect(() => {
|
||||
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
|
||||
fetchEntries()
|
||||
}, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
|
||||
}, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, showMissingOnly, sortHydrated, periodHydrated, pageSizeHydrated])
|
||||
|
||||
// Parent-driven in-place refresh (see the refreshToken prop). Skips the
|
||||
// mount value: the main effect above owns the initial fetch, and a token
|
||||
@@ -658,8 +689,33 @@ export default function JournalEntryList({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refreshToken, sortHydrated, periodHydrated, pageSizeHydrated])
|
||||
|
||||
// Render-time ref mirrors so the stable [] callback below can read current
|
||||
// state and call the current fetchEntries (same pattern as
|
||||
// JournalEntryAttachments' onCountChangeRef).
|
||||
const showMissingOnlyRef = useRef(showMissingOnly)
|
||||
showMissingOnlyRef.current = showMissingOnly
|
||||
const fetchEntriesRef = useRef(fetchEntries)
|
||||
fetchEntriesRef.current = fetchEntries
|
||||
// Entry ids that already triggered a filter refetch after gaining underlag:
|
||||
// if the server still included the row after that refetch (predicate
|
||||
// disagreement), a remount would re-fire the callback and loop forever.
|
||||
const refetchedAfterAttachRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
||||
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
||||
// With the server-side saknade-underlag filter on, a listed row that just
|
||||
// received its first underlag no longer belongs to the filtered set:
|
||||
// refetch in place so it leaves the list, as the old client-side filter
|
||||
// did. Listed rows arrive with zero underlag by definition, so a count
|
||||
// above zero here can only follow a user action.
|
||||
if (
|
||||
count > 0 &&
|
||||
showMissingOnlyRef.current &&
|
||||
!refetchedAfterAttachRef.current.has(entryId)
|
||||
) {
|
||||
refetchedAfterAttachRef.current.add(entryId)
|
||||
void fetchEntriesRef.current({ preserveSelection: true })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
@@ -797,6 +853,10 @@ export default function JournalEntryList({
|
||||
title: t('batch_no_doc_done_title'),
|
||||
description: t('batch_no_doc_done_description', { count: body.data?.exempted ?? ids.length }),
|
||||
})
|
||||
// With the server-side saknade-underlag filter on, the exempted rows no
|
||||
// longer belong to the filtered set: refetch so they leave the list and
|
||||
// the count updates (the pre-server-filter behavior).
|
||||
if (showMissingOnly) await fetchEntries()
|
||||
} catch {
|
||||
toast({ title: t('no_doc_required_save_failed'), variant: 'destructive' })
|
||||
} finally {
|
||||
@@ -866,15 +926,13 @@ export default function JournalEntryList({
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = showMissingOnly
|
||||
? entries.filter(
|
||||
(e) =>
|
||||
NEEDS_ATTACHMENT.has(e.source_type) &&
|
||||
!attachmentCounts[e.id] &&
|
||||
e.status === 'posted' &&
|
||||
!noDocRequired.has(e.id)
|
||||
)
|
||||
: entries
|
||||
// The missing-underlag filter is applied SERVER-side (missing_underlag=true
|
||||
// in fetchEntries): the server's set spans all pages and includes reference
|
||||
// -aware document checks the client can't see. Re-filtering here against the
|
||||
// late-arriving attachmentCounts could hide rows the server included and
|
||||
// desync the visible rows from the returned count, so the fetched page is
|
||||
// rendered as-is.
|
||||
const filteredEntries = entries
|
||||
|
||||
// Detail-pager context: the loaded page as rendered, written when the user
|
||||
// opens a verifikat. Server-paginated, so prev/next spans this page only.
|
||||
@@ -1199,17 +1257,23 @@ export default function JournalEntryList({
|
||||
|
||||
{/* Visa saknade underlag */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Committed view only: the predicate is posted-only, so in the
|
||||
drafts view the toggle would just mislabel the draft count. */}
|
||||
<Switch
|
||||
id="missing-attachments"
|
||||
checked={showMissingOnly}
|
||||
onCheckedChange={setShowMissingOnly}
|
||||
disabled={listMode === 'drafts'}
|
||||
onCheckedChange={(on) => {
|
||||
setShowMissingOnly(on)
|
||||
setPage(0)
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="missing-attachments" className="text-sm cursor-pointer">
|
||||
{t('show_missing')}
|
||||
</Label>
|
||||
{showMissingOnly && (
|
||||
<Badge variant="secondary" className="text-xs tabular-nums">
|
||||
{filteredEntries.length}
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -1250,7 +1314,15 @@ export default function JournalEntryList({
|
||||
authoritatively in the period effect. */}
|
||||
{periodHydrated && (
|
||||
<div className="sm:ml-auto">
|
||||
<FyPicker value={periodId} onChange={handlePeriodChange} />
|
||||
{/* suppressAutoRestore on deep-link visits: the arrival scope is a
|
||||
deliberate in-memory "Alla räkenskapsår" (matches the all-years
|
||||
dashboard badge); FyPicker's on-load restore of the persisted
|
||||
year would otherwise snap the scope back right after load. */}
|
||||
<FyPicker
|
||||
value={periodId}
|
||||
onChange={handlePeriodChange}
|
||||
suppressAutoRestore={initialShowMissingOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1690,6 +1762,13 @@ export default function JournalEntryList({
|
||||
else next.delete(entry.id)
|
||||
return next
|
||||
})
|
||||
// Server-side saknade-underlag filter on: an
|
||||
// exempted row leaves the filtered set, so
|
||||
// refetch in place (keeps expansion state
|
||||
// semantics of a background refresh).
|
||||
if (exempted && showMissingOnly) {
|
||||
void fetchEntries({ preserveSelection: true })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,17 @@ interface FyPickerProps {
|
||||
* without any cross-session hazard.
|
||||
*/
|
||||
requireExplicitChoice?: boolean
|
||||
/**
|
||||
* Skip ONLY the on-load restore of a persisted selection (and its
|
||||
* newest-period fallback) while keeping manual picks persisted as usual.
|
||||
* For deep-link visits that arrive with a deliberate transient scope (e.g.
|
||||
* /bookkeeping?missingUnderlag=true opens as "Alla räkenskapsår" to match
|
||||
* the all-years dashboard count): without this, the restore fires on
|
||||
* `value === null` and snaps the scope back to the stored year right after
|
||||
* load. Unlike requireExplicitChoice this does not change labels or
|
||||
* persistence semantics.
|
||||
*/
|
||||
suppressAutoRestore?: boolean
|
||||
/** Fires once after the initial period load completes. */
|
||||
onReady?: () => void
|
||||
/** Server-loaded periods for the first render, scoped to initialCompanyId. */
|
||||
@@ -83,6 +94,7 @@ export function FyPicker({
|
||||
hideFuturePeriods = false,
|
||||
preferLatestEnded = false,
|
||||
requireExplicitChoice = false,
|
||||
suppressAutoRestore = false,
|
||||
onReady,
|
||||
initialPeriods,
|
||||
initialCompanyId,
|
||||
@@ -132,7 +144,7 @@ export function FyPicker({
|
||||
// ALL_YEARS-stored fallback, newest-period, preferLatestEnded), and a
|
||||
// per-branch gate already missed one of them once. Nothing auto-fires;
|
||||
// the picker stays empty until a human picks.
|
||||
if (value === null && !requireExplicitChoice && typeof window !== 'undefined') {
|
||||
if (value === null && !requireExplicitChoice && !suppressAutoRestore && typeof window !== 'undefined') {
|
||||
if (preferLatestEnded) {
|
||||
// Filing surfaces: ignore the shared scope memory and open on the
|
||||
// most recently ended period (fetched is sorted newest-first).
|
||||
@@ -160,7 +172,7 @@ export function FyPicker({
|
||||
// onReady is a lifecycle callback: fire once per load, not on parent
|
||||
// re-renders that re-create it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, initialCompanyId, initialPeriods, storageKeyPrefix])
|
||||
}, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, requireExplicitChoice, suppressAutoRestore, initialCompanyId, initialPeriods, storageKeyPrefix])
|
||||
|
||||
const handleChange = (id: string) => {
|
||||
const nextId = id === ALL_YEARS_VALUE ? null : id
|
||||
|
||||
Reference in New Issue
Block a user