diff --git a/app/(dashboard)/transactions/page.tsx b/app/(dashboard)/transactions/page.tsx index 1280b1f2..3dd62ff3 100644 --- a/app/(dashboard)/transactions/page.tsx +++ b/app/(dashboard)/transactions/page.tsx @@ -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(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(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(null) + const [fyPeriod, setFyPeriod] = useState(null) + const [fyQuarter, setFyQuarter] = useState(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([]) @@ -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 && ( - {totalUncategorizedCount ?? uncategorizedTransactions.length} + {inboxBadgeCount} )} @@ -2779,12 +2925,51 @@ export default function TransactionsPage() { className="h-9 pl-10" /> - {/* 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 && ( -
+ {/* 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). */} +
+ + {fyPeriod && ( +
+ {QUARTERS.map((quarter) => { + const bounds = quarterBounds(fyPeriod, quarter) + const active = fyQuarter === quarter + return ( + + ) + })} +
+ )} + {sourceItems.length > 1 && ( handleSourceFilterChange(id as SourceFilter)} @@ -2795,8 +2980,8 @@ export default function TransactionsPage() { })()} items={sourceItems} /> -
- )} + )} +
{/* Content based on mode */} @@ -2815,10 +3000,16 @@ export default function TransactionsPage() { ) : mode === 'inbox' ? ( inboxItems.length === 0 ? ( - searchTerm || sourceFilter !== 'all' ? ( + searchTerm || sourceFilter !== 'all' || periodBounds ? ( ) : ( {t('footer_to_handle', { count: inboxItems.length })} )} + {mode === 'inbox' && pendingOutsideCount > 0 && ( + + {t('period_pending_outside', { count: pendingOutsideCount })}{' '} + + + )} diff --git a/components/common/FyPicker.tsx b/components/common/FyPicker.tsx index f863ae82..952f1111 100644 --- a/components/common/FyPicker.tsx +++ b/components/common/FyPicker.tsx @@ -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) } diff --git a/lib/transactions/__tests__/period-filter.test.ts b/lib/transactions/__tests__/period-filter.test.ts new file mode 100644 index 00000000..a8bafa33 --- /dev/null +++ b/lib/transactions/__tests__/period-filter.test.ts @@ -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) + }) +}) diff --git a/lib/transactions/period-filter.ts b/lib/transactions/period-filter.ts new file mode 100644 index 00000000..4e03e928 --- /dev/null +++ b/lib/transactions/period-filter.ts @@ -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 + +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 +} diff --git a/messages/en.json b/messages/en.json index 5ca8f297..3ef302c9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index c8b96a72..f20185f7 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",