feat(transactions): filter /transactions by rakenskapsar and kvartal (#1545)
* feat(transactions): filter /transactions by rakenskapsar and kvartal User request: booking a specific period (including brutet rakenskapsar, e.g. July-June) meant scrolling past every other year's transactions. - New FyPicker chip in the toolbar scopes both the inbox and history views to a fiscal year; quarter chips (Q1-Q4, fiscal-year aligned) appear once a year is selected. Clicking the active quarter widens back to the year. - Bounds are pushed into the Supabase queries (window, pending backlog, badge count, load-more) so pagination and the Att bokfora count stay consistent with the visible list; skattekonto rows are bounded client-side. - Scope persists under a page-local localStorage key, deliberately separate from the shared report scope so a year picked on a report page never silently hides pending inbox rows. - lib/transactions/period-filter.ts derives quarter bounds from fiscal period dates (handles brutet, shortened and extended years); unit tested. - FyPicker gains an optional storageKeyPrefix prop; default unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): never hide pending rows behind the period filter Swedish accounting review on PR #1545: scoping the pending-backlog fetch and badge count to the period made unbooked rows outside the selected year vanish from the inbox worklist (BFL 5 kap: pending affarshandelser must stay visible until booked). - Pending-backlog fetch and the DB pending count are unscoped again; only the history window pages server-side within the period. - The inbox applies the period client-side over the complete backlog; the tab badge counts pending rows inside the scope. - When pending rows (bank or skattekonto) fall outside the scope, the footer says how many and offers Visa alla, which clears the filter and its persisted value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): guard list fetches against stale cross-scope responses CodeRabbit on PR #1545: - fetchTransactions/loadMoreTransactions now carry a fetch generation; a response applies only if no newer fetch (scope change, realtime refresh, load-more) started meanwhile, so a slow pre-filter request can no longer overwrite the active period scope's window, paging offsets, or loading skeleton. - FyPicker restore effect includes storageKeyPrefix in its deps. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): label quarter chips as fiscal-year quarters Swedish accounting review note on PR #1545: Q1-Q4 follow the company's rakenskapsar, which on a brutet rakenskapsar differs from the calendar quarters that momsdeklaration periods use. Say so in the group's aria-label and hover title so the chips are not mistaken for VAT periods. 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
a07dcf4a55
commit
b9bf60234d
@@ -21,6 +21,8 @@ import { Loader2, Search } from 'lucide-react'
|
||||
import TransactionStatusBar from '@/components/transactions/TransactionStatusBar'
|
||||
import BankSyncStatusChip from '@/components/transactions/BankSyncStatusChip'
|
||||
import { ContextPicker, type ContextPickerItem } from '@/components/common/ContextPicker'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ALL_YEARS_VALUE } from '@/components/common/FiscalYearSelector'
|
||||
import { AttnLine } from '@/components/ui/attn-line'
|
||||
import BankSyncNowButton from '@/components/transactions/BankSyncNowButton'
|
||||
import BankSyncSinceLastVisit from '@/components/transactions/BankSyncSinceLastVisit'
|
||||
@@ -63,6 +65,14 @@ import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, Su
|
||||
import type { SuggestedTemplate } from '@/lib/transactions/category-suggestions'
|
||||
import { isImportedTransaction } from '@/lib/transactions/origin'
|
||||
import { computeJeUnderlagStatus, type JeUnderlagStatus } from '@/lib/transactions/underlag-status'
|
||||
import {
|
||||
QUARTERS,
|
||||
isWithinBounds,
|
||||
quarterBounds,
|
||||
resolvePeriodBounds,
|
||||
type Quarter,
|
||||
} from '@/lib/transactions/period-filter'
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
function InlineDialogContentLoading() {
|
||||
return (
|
||||
@@ -114,6 +124,11 @@ type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
|
||||
|
||||
const SOURCE_FILTER_STORAGE_KEY = 'Accounted:transaction-source-filter:v1'
|
||||
|
||||
// Page-local fiscal-year scope (FyPicker appends the company id). Deliberately
|
||||
// NOT the shared report scope (Accounted:fiscal-year:): a year picked on a
|
||||
// report page must never silently hide pending inbox rows here, and vice versa.
|
||||
const PERIOD_FILTER_STORAGE_PREFIX = 'Accounted:transactions-fy-scope:v1:'
|
||||
|
||||
// Journal-entry ids get interpolated into the supplier-invoice .or() filter
|
||||
// string in the underlag-badge effect below. They come from journal_entries.id
|
||||
// (DB-sourced), but this guard keeps the interpolated list UUID-only, matching
|
||||
@@ -377,6 +392,12 @@ export default function TransactionsPage() {
|
||||
const pagedCountRef = useRef(0)
|
||||
const [pagedThroughDate, setPagedThroughDate] = useState<string | null>(null)
|
||||
|
||||
// Monotonic token for list fetches: a response applies only if no newer
|
||||
// fetch (scope change, realtime refresh, load-more) started after it.
|
||||
// Without it, a slow pre-filter request resolving late would overwrite the
|
||||
// active period scope's window and paging state with stale rows.
|
||||
const fetchGenerationRef = useRef(0)
|
||||
|
||||
// True uncategorized count from DB (not limited by pagination)
|
||||
const [totalUncategorizedCount, setTotalUncategorizedCount] = useState<number | null>(null)
|
||||
|
||||
@@ -429,6 +450,51 @@ export default function TransactionsPage() {
|
||||
// localStorage may be unavailable. The in-memory filter still works.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Period filter (rakenskapsar + optional quarter within it). Quarters
|
||||
// follow the fiscal year, so a brutet rakenskapsar (July-June) gets
|
||||
// Q1 = Jul-Sep. FyPicker owns the persistence under the page-local key;
|
||||
// the quarter is session-only.
|
||||
const [fyPeriodId, setFyPeriodId] = useState<string | null>(null)
|
||||
const [fyPeriod, setFyPeriod] = useState<FiscalPeriod | null>(null)
|
||||
const [fyQuarter, setFyQuarter] = useState<Quarter | null>(null)
|
||||
const periodBounds = useMemo(
|
||||
() => resolvePeriodBounds(fyPeriod, fyQuarter),
|
||||
[fyPeriod, fyQuarter],
|
||||
)
|
||||
|
||||
const handlePeriodChange = useCallback((periodId: string | null, period?: FiscalPeriod | null) => {
|
||||
setFyPeriodId(periodId)
|
||||
setFyPeriod(period ?? null)
|
||||
setFyQuarter(null)
|
||||
// Batch selections may reference rows the new scope hides; every batch
|
||||
// action operates on "what you see", so drop them.
|
||||
setSelectedIds(new Set())
|
||||
setSkvSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
const handleQuarterChange = useCallback((quarter: Quarter | null) => {
|
||||
setFyQuarter(quarter)
|
||||
setSelectedIds(new Set())
|
||||
setSkvSelectedIds(new Set())
|
||||
}, [])
|
||||
|
||||
// Footer "Visa alla" escape hatch: clears the period scope AND its
|
||||
// persisted value (FyPicker only writes storage from its own dropdown).
|
||||
const clearPeriodFilter = useCallback(() => {
|
||||
setFyPeriodId(null)
|
||||
setFyPeriod(null)
|
||||
setFyQuarter(null)
|
||||
setSelectedIds(new Set())
|
||||
setSkvSelectedIds(new Set())
|
||||
if (companyId) {
|
||||
try {
|
||||
window.localStorage.setItem(PERIOD_FILTER_STORAGE_PREFIX + companyId, ALL_YEARS_VALUE)
|
||||
} catch {
|
||||
// localStorage may be unavailable; the in-memory state is cleared.
|
||||
}
|
||||
}
|
||||
}, [companyId])
|
||||
// Registered cash accounts (cash_accounts): the account chooser's rows,
|
||||
// with PSD2 balances when the bank reports them.
|
||||
const [cashAccounts, setCashAccounts] = useState<CashAccount[]>([])
|
||||
@@ -489,6 +555,9 @@ export default function TransactionsPage() {
|
||||
const query = searchTerm.trim().toLowerCase()
|
||||
if (sourceFilter !== 'skatteverket') {
|
||||
for (const tx of uncategorizedTransactions) {
|
||||
// The refetch already narrows state server-side; this check makes the
|
||||
// filter correct immediately on change, before the refetch lands.
|
||||
if (!isWithinBounds(tx.date, periodBounds)) continue
|
||||
if (
|
||||
sourceFilter.startsWith('acct:') &&
|
||||
tx.cash_account_id !== sourceFilter.slice('acct:'.length)
|
||||
@@ -512,6 +581,8 @@ export default function TransactionsPage() {
|
||||
for (const r of skvRows) {
|
||||
if (r.journal_entry_id) continue
|
||||
if (exitingIds.has(r.id)) continue
|
||||
// SKV rows live client-side only, so the period filter applies here.
|
||||
if (!isWithinBounds(r.transaktionsdatum, periodBounds)) continue
|
||||
if (
|
||||
query &&
|
||||
!r.transaktionstext?.toLowerCase().includes(query) &&
|
||||
@@ -529,7 +600,7 @@ export default function TransactionsPage() {
|
||||
if (a.source !== b.source) return a.source === 'bank' ? -1 : 1
|
||||
return 0
|
||||
})
|
||||
}, [exitingIds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
|
||||
}, [exitingIds, periodBounds, searchTerm, skvRows, sourceFilter, uncategorizedTransactions])
|
||||
|
||||
// History shows only the contiguous newest-first window: the older pending
|
||||
// rows merged in for the inbox would otherwise render as sparse, gap-ridden
|
||||
@@ -537,12 +608,46 @@ export default function TransactionsPage() {
|
||||
// the boundary may slip in; they are real rows of that date, so harmless.
|
||||
const historyTransactions = useMemo(
|
||||
() =>
|
||||
pagedThroughDate
|
||||
? transactions.filter((t) => t.date >= pagedThroughDate)
|
||||
: transactions,
|
||||
[transactions, pagedThroughDate],
|
||||
transactions.filter(
|
||||
(t) =>
|
||||
(!pagedThroughDate || t.date >= pagedThroughDate) &&
|
||||
// Server-side scope catches up on refetch; this keeps the view
|
||||
// correct in the transition frame after a filter change.
|
||||
isWithinBounds(t.date, periodBounds),
|
||||
),
|
||||
[transactions, pagedThroughDate, periodBounds],
|
||||
)
|
||||
|
||||
// SKV rows shown in the history view, narrowed to the active period. The
|
||||
// list component filters by search/source itself but knows nothing about
|
||||
// period bounds.
|
||||
const skvRowsInScope = useMemo(
|
||||
() => skvRows.filter((r) => isWithinBounds(r.transaktionsdatum, periodBounds)),
|
||||
[skvRows, periodBounds],
|
||||
)
|
||||
|
||||
// Tab badge: DB-true pending count normally; with a period filter active,
|
||||
// the pending rows inside the scope (complete, since the pending backlog
|
||||
// fetch is unscoped and fully in state).
|
||||
const inboxBadgeCount = useMemo(() => {
|
||||
if (!periodBounds) return totalUncategorizedCount ?? uncategorizedTransactions.length
|
||||
return uncategorizedTransactions.filter((tx) => isWithinBounds(tx.date, periodBounds)).length
|
||||
}, [periodBounds, totalUncategorizedCount, uncategorizedTransactions])
|
||||
|
||||
// Pending work the active period filter hides (bank + skattekonto). BFL
|
||||
// 5 kap: pending affarshandelser must never disappear silently, so the
|
||||
// footer names this count and offers a one-click way back to everything.
|
||||
const pendingOutsideCount = useMemo(() => {
|
||||
if (!periodBounds) return 0
|
||||
const bankOutside = uncategorizedTransactions.filter(
|
||||
(tx) => !isWithinBounds(tx.date, periodBounds),
|
||||
).length
|
||||
const skvOutside = skvRows.filter(
|
||||
(r) => !r.journal_entry_id && !isWithinBounds(r.transaktionsdatum, periodBounds),
|
||||
).length
|
||||
return bankOutside + skvOutside
|
||||
}, [periodBounds, skvRows, uncategorizedTransactions])
|
||||
|
||||
// Account chooser (concept scene 10): the source picker doubles as a
|
||||
// balance readout. The total sums only SEK ledgers (mixing currencies into
|
||||
// one figure would be a lie); null hides the annotation entirely.
|
||||
@@ -723,17 +828,29 @@ export default function TransactionsPage() {
|
||||
|
||||
const fetchTransactions = useCallback(async (showLoading = false, includeSkvRows = false) => {
|
||||
if (!companyId) return
|
||||
const generation = ++fetchGenerationRef.current
|
||||
if (showLoading) setIsLoading(true)
|
||||
if (includeSkvRows) void loadSkvRows()
|
||||
try {
|
||||
// Only the history window is narrowed server-side by the period filter.
|
||||
// The pending-backlog fetch and the pending count below stay UNSCOPED on
|
||||
// purpose (Swedish accounting review, PR #1545): BFL 5 kap requires
|
||||
// pending affarshandelser to be booked promptly, so every pending row
|
||||
// must stay in state regardless of the filter. The inbox applies the
|
||||
// period client-side and the footer surfaces what falls outside it.
|
||||
let windowQuery = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
if (periodBounds) {
|
||||
windowQuery = windowQuery.gte('date', periodBounds.start).lte('date', periodBounds.end)
|
||||
}
|
||||
|
||||
const [{ data: txData, error: txError }, { count: uncatCount }, pendingRows] = await Promise.all([
|
||||
supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
// The id tie-breaker keeps offset paging deterministic when many
|
||||
// rows share a date; without it .range() pages can skip or repeat
|
||||
// same-date rows.
|
||||
// The id tie-breaker keeps offset paging deterministic when many
|
||||
// rows share a date; without it .range() pages can skip or repeat
|
||||
// same-date rows.
|
||||
windowQuery
|
||||
.order('date', { ascending: false })
|
||||
.order('id', { ascending: true })
|
||||
.limit(PAGE_SIZE),
|
||||
@@ -766,6 +883,11 @@ export default function TransactionsPage() {
|
||||
}),
|
||||
])
|
||||
|
||||
// A newer fetch (scope change, refresh) started while this one was in
|
||||
// flight: its results own the state now, so this response must not
|
||||
// apply. Toasts are skipped too; the newer request reports its own fate.
|
||||
if (fetchGenerationRef.current !== generation) return
|
||||
|
||||
if (txError) {
|
||||
toast({ title: t('load_failed_title'), description: t('load_failed_description'), variant: 'destructive' })
|
||||
return
|
||||
@@ -783,6 +905,10 @@ export default function TransactionsPage() {
|
||||
const allRows = [...rows, ...olderPending].sort((a, b) => b.date.localeCompare(a.date))
|
||||
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, allRows)
|
||||
|
||||
// Re-check after the second await: a scope change during the match
|
||||
// enrichment must also discard this response.
|
||||
if (fetchGenerationRef.current !== generation) return
|
||||
|
||||
const transactionsWithInvoices: TransactionWithInvoice[] = allRows.map((t) => ({
|
||||
...t,
|
||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||
@@ -798,9 +924,13 @@ export default function TransactionsPage() {
|
||||
setHasMore(rows.length >= PAGE_SIZE)
|
||||
|
||||
} finally {
|
||||
if (showLoading) setIsLoading(false)
|
||||
// Only the newest request may touch the skeleton: a stale one must not
|
||||
// clear what a newer showLoading request just put up. The newest always
|
||||
// clears it (even non-showLoading refreshes, whose superseded
|
||||
// predecessor may have left it up).
|
||||
if (fetchGenerationRef.current === generation) setIsLoading(false)
|
||||
}
|
||||
}, [companyId, loadSkvRows, supabase, t, toast])
|
||||
}, [companyId, loadSkvRows, periodBounds, supabase, t, toast])
|
||||
|
||||
const refreshTransactions = useCallback(async () => {
|
||||
if (!companyId) return
|
||||
@@ -823,21 +953,30 @@ export default function TransactionsPage() {
|
||||
|
||||
async function loadMoreTransactions() {
|
||||
if (!companyId) return
|
||||
// Claims the generation: a scope change mid-request discards this page
|
||||
// instead of appending old-scope rows and corrupting the paging offsets.
|
||||
const generation = ++fetchGenerationRef.current
|
||||
setIsLoadingMore(true)
|
||||
// Offset counts only the contiguous newest-first window, not the older
|
||||
// pending rows merged into state for the inbox.
|
||||
const offset = pagedCountRef.current
|
||||
const { data: txData, error: txError } = await supabase
|
||||
let pageQuery = supabase
|
||||
.from('transactions')
|
||||
.select('*')
|
||||
.eq('company_id', companyId)
|
||||
// Same period scope as the initial window fetch: mixed-scope pages would
|
||||
// corrupt the offset bookkeeping.
|
||||
if (periodBounds) {
|
||||
pageQuery = pageQuery.gte('date', periodBounds.start).lte('date', periodBounds.end)
|
||||
}
|
||||
const { data: txData, error: txError } = await pageQuery
|
||||
// Same stable order as the initial window fetch: offset paging over a
|
||||
// date-only order can skip or repeat same-date rows between pages.
|
||||
.order('date', { ascending: false })
|
||||
.order('id', { ascending: true })
|
||||
.range(offset, offset + PAGE_SIZE - 1)
|
||||
|
||||
if (txError || !txData) {
|
||||
if (fetchGenerationRef.current !== generation || txError || !txData) {
|
||||
setIsLoadingMore(false)
|
||||
return
|
||||
}
|
||||
@@ -848,6 +987,13 @@ export default function TransactionsPage() {
|
||||
|
||||
const { invoiceMap, supplierInvoiceMap } = await fetchPotentialMatches(supabase, txData)
|
||||
|
||||
// Same staleness rule after the enrichment await: the offsets above were
|
||||
// written under this generation, but a newer fetch has already reset them.
|
||||
if (fetchGenerationRef.current !== generation) {
|
||||
setIsLoadingMore(false)
|
||||
return
|
||||
}
|
||||
|
||||
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
|
||||
...t,
|
||||
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
|
||||
@@ -2750,9 +2896,9 @@ export default function TransactionsPage() {
|
||||
}`}
|
||||
>
|
||||
{t('mode_inbox')}
|
||||
{(totalUncategorizedCount ?? uncategorizedTransactions.length) > 0 && (
|
||||
{inboxBadgeCount > 0 && (
|
||||
<span className="rounded-full bg-secondary px-1.5 text-[10px] font-medium tabular-nums">
|
||||
{totalUncategorizedCount ?? uncategorizedTransactions.length}
|
||||
{inboxBadgeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -2779,12 +2925,51 @@ export default function TransactionsPage() {
|
||||
className="h-9 pl-10"
|
||||
/>
|
||||
</div>
|
||||
{/* Account chooser (convention 8): the one context chip, far right,
|
||||
shared by both view modes. Per-cash-account rows with balances
|
||||
(concept scene 10); hidden only when there is nothing beyond
|
||||
"Alla källor" to choose. */}
|
||||
{sourceItems.length > 1 && (
|
||||
<div className="ml-auto">
|
||||
{/* Far-right context group, shared by both view modes: period scope
|
||||
(rakenskapsar chip + quarter chips once a year is chosen) and the
|
||||
account chooser (concept scene 10). Approved deviation from
|
||||
convention 8's single chip: booking is period work, so the period
|
||||
scope earns the second chip (user request 2026-08-12). */}
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||||
<FyPicker
|
||||
value={fyPeriodId}
|
||||
onChange={handlePeriodChange}
|
||||
storageKeyPrefix={PERIOD_FILTER_STORAGE_PREFIX}
|
||||
/>
|
||||
{fyPeriod && (
|
||||
<div
|
||||
className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]"
|
||||
role="group"
|
||||
// Quarters follow the rakenskapsar, NOT the calendar: on a
|
||||
// brutet rakenskapsar these are not momsdeklaration quarters.
|
||||
// The label says so to keep VAT reconciliation off this chip.
|
||||
aria-label={t('period_quarter_group')}
|
||||
title={t('period_quarter_group')}
|
||||
>
|
||||
{QUARTERS.map((quarter) => {
|
||||
const bounds = quarterBounds(fyPeriod, quarter)
|
||||
const active = fyQuarter === quarter
|
||||
return (
|
||||
<button
|
||||
key={quarter}
|
||||
type="button"
|
||||
disabled={!bounds}
|
||||
aria-pressed={active}
|
||||
// Clicking the active quarter widens back to the full year.
|
||||
onClick={() => handleQuarterChange(active ? null : quarter)}
|
||||
className={`rounded-md px-3 py-[5px] text-[12.5px] tabular-nums transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-40 ${
|
||||
active
|
||||
? 'border border-border bg-card font-medium text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{`Q${quarter}`}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{sourceItems.length > 1 && (
|
||||
<ContextPicker
|
||||
value={sourceFilter}
|
||||
onChange={(id) => handleSourceFilterChange(id as SourceFilter)}
|
||||
@@ -2795,8 +2980,8 @@ export default function TransactionsPage() {
|
||||
})()}
|
||||
items={sourceItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content based on mode */}
|
||||
@@ -2815,10 +3000,16 @@ export default function TransactionsPage() {
|
||||
</DataList>
|
||||
) : mode === 'inbox' ? (
|
||||
inboxItems.length === 0 ? (
|
||||
searchTerm || sourceFilter !== 'all' ? (
|
||||
searchTerm || sourceFilter !== 'all' || periodBounds ? (
|
||||
<DataListEmpty
|
||||
title="Inga träffar"
|
||||
description={searchTerm ? t('no_search_results') : t('source_empty')}
|
||||
description={
|
||||
searchTerm
|
||||
? t('no_search_results')
|
||||
: sourceFilter !== 'all'
|
||||
? t('source_empty')
|
||||
: t('period_empty')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<InboxZeroState
|
||||
@@ -2969,7 +3160,7 @@ export default function TransactionsPage() {
|
||||
) : (
|
||||
<TransactionHistoryList
|
||||
transactions={historyTransactions}
|
||||
skvRows={skvRows}
|
||||
skvRows={skvRowsInScope}
|
||||
searchTerm={searchTerm}
|
||||
sourceFilter={sourceFilter}
|
||||
jeUnderlagStatus={jeUnderlagStatus}
|
||||
@@ -2993,6 +3184,14 @@ export default function TransactionsPage() {
|
||||
{mode === 'inbox' && (
|
||||
<span className="tabular-nums">{t('footer_to_handle', { count: inboxItems.length })}</span>
|
||||
)}
|
||||
{mode === 'inbox' && pendingOutsideCount > 0 && (
|
||||
<span className="tabular-nums">
|
||||
{t('period_pending_outside', { count: pendingOutsideCount })}{' '}
|
||||
<button type="button" className={QUIET_LINK_CLASS} onClick={clearPeriodFilter}>
|
||||
{t('period_show_all')}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<BankSyncStatusChip />
|
||||
<BankSyncNowButton />
|
||||
<BankSyncSinceLastVisit />
|
||||
|
||||
@@ -35,6 +35,13 @@ interface FyPickerProps {
|
||||
/** Server-loaded periods for the first render, scoped to initialCompanyId. */
|
||||
initialPeriods?: FiscalPeriod[]
|
||||
initialCompanyId?: string | null
|
||||
/**
|
||||
* localStorage prefix for the persisted selection (companyId is appended).
|
||||
* Defaults to the report-wide shared scope; pass a page-specific prefix
|
||||
* when the page's scope must not follow (or steer) the shared one, e.g.
|
||||
* the transactions inbox, where a narrowed scope hides pending rows.
|
||||
*/
|
||||
storageKeyPrefix?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -61,6 +68,7 @@ export function FyPicker({
|
||||
onReady,
|
||||
initialPeriods,
|
||||
initialCompanyId,
|
||||
storageKeyPrefix = STORAGE_KEY_PREFIX,
|
||||
className,
|
||||
}: FyPickerProps) {
|
||||
const { company } = useCompany()
|
||||
@@ -108,7 +116,7 @@ export function FyPicker({
|
||||
const pick = fetched.find((p) => p.period_end < today) ?? fetched[0]
|
||||
if (pick) onChange(pick.id, pick)
|
||||
} else {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY_PREFIX + company.id)
|
||||
const stored = window.localStorage.getItem(storageKeyPrefix + company.id)
|
||||
if (stored === ALL_YEARS_VALUE) {
|
||||
if (includeAllOption) onChange(null, null)
|
||||
else if (fetched.length > 0) onChange(fetched[0].id, fetched[0])
|
||||
@@ -128,12 +136,12 @@ 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, initialCompanyId, initialPeriods])
|
||||
}, [company?.id, hideFuturePeriods, includeAllOption, preferLatestEnded, initialCompanyId, initialPeriods, storageKeyPrefix])
|
||||
|
||||
const handleChange = (id: string) => {
|
||||
const nextId = id === ALL_YEARS_VALUE ? null : id
|
||||
if (company?.id && typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(STORAGE_KEY_PREFIX + company.id, nextId ?? ALL_YEARS_VALUE)
|
||||
window.localStorage.setItem(storageKeyPrefix + company.id, nextId ?? ALL_YEARS_VALUE)
|
||||
}
|
||||
onChange(nextId, nextId ? periods.find((p) => p.id === nextId) ?? null : null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
quarterBounds,
|
||||
resolvePeriodBounds,
|
||||
isWithinBounds,
|
||||
QUARTERS,
|
||||
} from '@/lib/transactions/period-filter'
|
||||
|
||||
const calendarYear = { period_start: '2026-01-01', period_end: '2026-12-31' }
|
||||
// Brutet rakenskapsar: the reporter's July-June case.
|
||||
const brokenYear = { period_start: '2025-07-01', period_end: '2026-06-30' }
|
||||
// Shortened first year (6 months).
|
||||
const shortYear = { period_start: '2026-01-01', period_end: '2026-06-30' }
|
||||
// Extended first year (18 months).
|
||||
const extendedYear = { period_start: '2025-01-01', period_end: '2026-06-30' }
|
||||
|
||||
describe('quarterBounds', () => {
|
||||
it('splits a calendar fiscal year into calendar quarters', () => {
|
||||
expect(quarterBounds(calendarYear, 1)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(calendarYear, 2)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
expect(quarterBounds(calendarYear, 3)).toEqual({ start: '2026-07-01', end: '2026-09-30' })
|
||||
expect(quarterBounds(calendarYear, 4)).toEqual({ start: '2026-10-01', end: '2026-12-31' })
|
||||
})
|
||||
|
||||
it('follows the fiscal year for a brutet rakenskapsar (July-June)', () => {
|
||||
expect(quarterBounds(brokenYear, 1)).toEqual({ start: '2025-07-01', end: '2025-09-30' })
|
||||
expect(quarterBounds(brokenYear, 2)).toEqual({ start: '2025-10-01', end: '2025-12-31' })
|
||||
expect(quarterBounds(brokenYear, 3)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(brokenYear, 4)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('returns null for quarters beyond a shortened period', () => {
|
||||
expect(quarterBounds(shortYear, 1)).toEqual({ start: '2026-01-01', end: '2026-03-31' })
|
||||
expect(quarterBounds(shortYear, 2)).toEqual({ start: '2026-04-01', end: '2026-06-30' })
|
||||
expect(quarterBounds(shortYear, 3)).toBeNull()
|
||||
expect(quarterBounds(shortYear, 4)).toBeNull()
|
||||
})
|
||||
|
||||
it('clamps a quarter end that would pass period_end', () => {
|
||||
const fiveMonths = { period_start: '2026-01-01', period_end: '2026-05-31' }
|
||||
expect(quarterBounds(fiveMonths, 2)).toEqual({ start: '2026-04-01', end: '2026-05-31' })
|
||||
})
|
||||
|
||||
it('lets Q4 absorb the tail of an extended fiscal year', () => {
|
||||
expect(quarterBounds(extendedYear, 4)).toEqual({ start: '2025-10-01', end: '2026-06-30' })
|
||||
})
|
||||
|
||||
it('covers every day of a regular period across the four quarters', () => {
|
||||
for (const period of [calendarYear, brokenYear]) {
|
||||
const bounds = QUARTERS.map((q) => quarterBounds(period, q))
|
||||
expect(bounds[0]?.start).toBe(period.period_start)
|
||||
expect(bounds[3]?.end).toBe(period.period_end)
|
||||
for (let i = 1; i < 4; i++) {
|
||||
const prevEnd = bounds[i - 1]?.end
|
||||
const nextStart = bounds[i]?.start
|
||||
expect(prevEnd).toBeDefined()
|
||||
expect(nextStart).toBeDefined()
|
||||
// Next quarter starts the day after the previous one ends.
|
||||
const followingDay = new Date(`${prevEnd}T00:00:00Z`)
|
||||
followingDay.setUTCDate(followingDay.getUTCDate() + 1)
|
||||
expect(nextStart).toBe(followingDay.toISOString().slice(0, 10))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('handles a period start that is not the first of a month', () => {
|
||||
const midMonth = { period_start: '2026-01-15', period_end: '2027-01-14' }
|
||||
expect(quarterBounds(midMonth, 1)).toEqual({ start: '2026-01-15', end: '2026-04-14' })
|
||||
expect(quarterBounds(midMonth, 2)).toEqual({ start: '2026-04-15', end: '2026-07-14' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolvePeriodBounds', () => {
|
||||
it('returns null without a period', () => {
|
||||
expect(resolvePeriodBounds(null, null)).toBeNull()
|
||||
expect(resolvePeriodBounds(null, 2)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the whole period without a quarter', () => {
|
||||
expect(resolvePeriodBounds(brokenYear, null)).toEqual({
|
||||
start: '2025-07-01',
|
||||
end: '2026-06-30',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns quarter bounds with a quarter', () => {
|
||||
expect(resolvePeriodBounds(calendarYear, 3)).toEqual({
|
||||
start: '2026-07-01',
|
||||
end: '2026-09-30',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for a quarter outside the period', () => {
|
||||
expect(resolvePeriodBounds(shortYear, 4)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isWithinBounds', () => {
|
||||
const bounds = { start: '2026-01-01', end: '2026-03-31' }
|
||||
|
||||
it('accepts everything when no bounds are set', () => {
|
||||
expect(isWithinBounds('1999-01-01', null)).toBe(true)
|
||||
})
|
||||
|
||||
it('includes both endpoints', () => {
|
||||
expect(isWithinBounds('2026-01-01', bounds)).toBe(true)
|
||||
expect(isWithinBounds('2026-03-31', bounds)).toBe(true)
|
||||
})
|
||||
|
||||
it('excludes dates outside the bounds', () => {
|
||||
expect(isWithinBounds('2025-12-31', bounds)).toBe(false)
|
||||
expect(isWithinBounds('2026-04-01', bounds)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { FiscalPeriod } from '@/types'
|
||||
|
||||
/** Inclusive ISO date bounds (yyyy-MM-dd) for a period filter. */
|
||||
export interface PeriodBounds {
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export type Quarter = 1 | 2 | 3 | 4
|
||||
|
||||
export const QUARTERS: Quarter[] = [1, 2, 3, 4]
|
||||
|
||||
type PeriodDates = Pick<FiscalPeriod, 'period_start' | 'period_end'>
|
||||
|
||||
function toIso(year: number, month: number, day: number): string {
|
||||
return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// Month arithmetic on ISO date strings. Day-of-month overflow clamps to the
|
||||
// last day of the target month (2026-01-31 + 1 month = 2026-02-28), matching
|
||||
// how fiscal quarters are counted from the period start.
|
||||
function addMonths(iso: string, months: number): string {
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const total = y * 12 + (m - 1) + months
|
||||
const year = Math.floor(total / 12)
|
||||
const monthIndex = total - year * 12
|
||||
const lastDay = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate()
|
||||
return toIso(year, monthIndex + 1, Math.min(d, lastDay))
|
||||
}
|
||||
|
||||
function addDays(iso: string, days: number): string {
|
||||
const date = new Date(`${iso}T00:00:00Z`)
|
||||
date.setUTCDate(date.getUTCDate() + days)
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/**
|
||||
* Date bounds for one quarter of a fiscal period. Quarters follow the fiscal
|
||||
* year, not the calendar: Q1 starts at period_start, so a brutet rakenskapsar
|
||||
* (e.g. July to June) gets Q1 = Jul-Sep. Rules for irregular period lengths:
|
||||
* - A quarter whose start falls after period_end does not exist (returns
|
||||
* null); the caller disables that choice.
|
||||
* - Q4 always runs to period_end, so an extended first year (up to 18 months)
|
||||
* keeps every transaction reachable through some quarter.
|
||||
* - A quarter end never extends past period_end.
|
||||
*/
|
||||
export function quarterBounds(period: PeriodDates, quarter: Quarter): PeriodBounds | null {
|
||||
const start = addMonths(period.period_start, (quarter - 1) * 3)
|
||||
if (start > period.period_end) return null
|
||||
if (quarter === 4) return { start, end: period.period_end }
|
||||
const end = addDays(addMonths(period.period_start, quarter * 3), -1)
|
||||
return { start, end: end > period.period_end ? period.period_end : end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds for the active period filter: the whole fiscal period, one of its
|
||||
* quarters, or null when no filter is applied (or the quarter does not exist
|
||||
* within the period).
|
||||
*/
|
||||
export function resolvePeriodBounds(
|
||||
period: PeriodDates | null,
|
||||
quarter: Quarter | null,
|
||||
): PeriodBounds | null {
|
||||
if (!period) return null
|
||||
if (quarter === null) return { start: period.period_start, end: period.period_end }
|
||||
return quarterBounds(period, quarter)
|
||||
}
|
||||
|
||||
/** True when an ISO date falls inside the bounds. No bounds = everything. */
|
||||
export function isWithinBounds(date: string, bounds: PeriodBounds | null): boolean {
|
||||
if (!bounds) return true
|
||||
return date >= bounds.start && date <= bounds.end
|
||||
}
|
||||
@@ -5282,6 +5282,10 @@
|
||||
"search_placeholder": "Search transactions...",
|
||||
"no_search_results": "No transactions match your search.",
|
||||
"source_empty": "The selected source has no transactions. Choose All to show the other sources.",
|
||||
"period_empty": "The selected period has no transactions. Choose All fiscal years to show everything.",
|
||||
"period_quarter_group": "Quarter of the fiscal year (not calendar quarter)",
|
||||
"period_pending_outside": "{count, plural, one {# transaction to record outside the selected period.} other {# transactions to record outside the selected period.}}",
|
||||
"period_show_all": "Show all",
|
||||
"skv_reconnect_title": "The Skatteverket connection needs to be renewed",
|
||||
"skv_reconnect_body": "Tax account transactions are not fetched until you reconnect with BankID and approve all permissions.",
|
||||
"skv_reconnect_cta": "Reconnect",
|
||||
|
||||
@@ -5282,6 +5282,10 @@
|
||||
"search_placeholder": "Sök transaktion...",
|
||||
"no_search_results": "Inga transaktioner matchar din sökning.",
|
||||
"source_empty": "Den valda källan har inga transaktioner. Välj Alla för att visa övriga källor.",
|
||||
"period_empty": "Den valda perioden har inga transaktioner. Välj Alla räkenskapsår för att visa allt.",
|
||||
"period_quarter_group": "Räkenskapsårets kvartal (inte kalenderkvartal)",
|
||||
"period_pending_outside": "{count, plural, one {# transaktion att bokföra utanför vald period.} other {# transaktioner att bokföra utanför vald period.}}",
|
||||
"period_show_all": "Visa alla",
|
||||
"skv_reconnect_title": "Anslutningen till Skatteverket behöver förnyas",
|
||||
"skv_reconnect_body": "Skattekontots transaktioner hämtas inte förrän du anslutit igen med BankID och godkänt alla behörigheter.",
|
||||
"skv_reconnect_cta": "Anslut igen",
|
||||
|
||||
Reference in New Issue
Block a user