Files
accounted/components/common/FyPicker.tsx
T
MattssonandClaude Fable 5 b9bf60234d 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>
2026-08-13 01:21:31 +02:00

188 lines
6.8 KiB
TypeScript

'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useCompany } from '@/contexts/CompanyContext'
import { ContextPicker } from '@/components/common/ContextPicker'
import {
STORAGE_KEY_PREFIX,
ALL_YEARS_VALUE,
} from '@/components/common/FiscalYearSelector'
import type { FiscalPeriod } from '@/types'
interface FyPickerProps {
/** Current selection. `null` means "all years": no filter applied. */
value: string | null
/**
* Called with the selected period id (or null for "all years") and the
* matching FiscalPeriod so callers avoid an extra fetch.
*/
onChange: (periodId: string | null, period?: FiscalPeriod | null) => void
/** Include an "Alla räkenskapsår" option that clears the filter. */
includeAllOption?: boolean
/** Only show periods that have started (Reports-style filter). */
hideFuturePeriods?: boolean
/**
* Auto-select the most recently ENDED period on load instead of restoring
* the shared per-company scope or falling back to the newest started one.
* For filing surfaces (helårsmoms): only an ended räkenskapsår can be
* declared, so the newest started period is the one default that is always
* wrong there. Manual picks still work and are still persisted.
*/
preferLatestEnded?: boolean
/** Fires once after the initial period load completes. */
onReady?: () => void
/** 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
}
function preparePeriods(periods: FiscalPeriod[], hideFuturePeriods: boolean): FiscalPeriod[] {
const today = new Date().toISOString().split('T')[0]
return periods
.filter((p) => !hideFuturePeriods || p.period_start <= today)
.sort((a, b) => b.period_start.localeCompare(a.period_start))
}
/**
* Fiscal-year context picker (UI-migration plan PR 3): the chip-dropdown
* "Räkenskapsår 2026" with a check on the active choice and closed/locked
* years annotated. Same controlled API and per-company localStorage
* persistence as FiscalYearSelector, which it replaces page by page from
* PR 4 on.
*/
export function FyPicker({
value,
onChange,
includeAllOption = true,
hideFuturePeriods = false,
preferLatestEnded = false,
onReady,
initialPeriods,
initialCompanyId,
storageKeyPrefix = STORAGE_KEY_PREFIX,
className,
}: FyPickerProps) {
const { company } = useCompany()
const t = useTranslations('fiscal_year')
const canUseInitial = initialCompanyId === company?.id && initialPeriods !== undefined
const [periods, setPeriods] = useState<FiscalPeriod[]>(() =>
canUseInitial ? preparePeriods(initialPeriods, hideFuturePeriods) : [],
)
const [loaded, setLoaded] = useState(canUseInitial)
useEffect(() => {
if (!company?.id) {
onReady?.()
return
}
let cancelled = false
;(async () => {
let fetched: FiscalPeriod[]
if (initialCompanyId === company.id && initialPeriods !== undefined) {
fetched = preparePeriods(initialPeriods, hideFuturePeriods)
} else {
const res = await fetch('/api/bookkeeping/fiscal-periods')
if (!res.ok) {
if (!cancelled) {
setLoaded(true)
onReady?.()
}
return
}
const { data } = await res.json()
fetched = preparePeriods(data || [], hideFuturePeriods)
}
if (cancelled) return
setPeriods(fetched)
setLoaded(true)
// Restore last selection (same key as FiscalYearSelector so pages keep
// their scope when the picker swaps in).
if (value === null && 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).
const today = new Date().toISOString().split('T')[0]
const pick = fetched.find((p) => p.period_end < today) ?? fetched[0]
if (pick) onChange(pick.id, pick)
} else {
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])
} else if (stored && fetched.some((p) => p.id === stored)) {
onChange(stored, fetched.find((p) => p.id === stored) ?? null)
} else if (!includeAllOption && fetched.length > 0) {
onChange(fetched[0].id, fetched[0])
}
}
}
onReady?.()
})()
return () => {
cancelled = true
}
// 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, storageKeyPrefix])
const handleChange = (id: string) => {
const nextId = id === ALL_YEARS_VALUE ? null : id
if (company?.id && typeof window !== 'undefined') {
window.localStorage.setItem(storageKeyPrefix + company.id, nextId ?? ALL_YEARS_VALUE)
}
onChange(nextId, nextId ? periods.find((p) => p.id === nextId) ?? null : null)
}
const annotationFor = (p: FiscalPeriod) =>
p.locked_at ? t('badge_locked').toLowerCase() : p.is_closed ? t('badge_closed').toLowerCase() : undefined
const selected = value ? periods.find((p) => p.id === value) : null
// Real period names often already read "Räkenskapsår 2026"; only prefix
// the label when the name is a bare year/name so the chip never doubles up.
const chipLabel = (p: FiscalPeriod) =>
p.name.toLowerCase().includes(t('label').toLowerCase())
? p.name
: `${t('label')} ${p.name}`
const triggerLabel = selected
? chipLabel(selected)
: includeAllOption
? t('all_years')
: loaded
? t('placeholder')
: t('loading')
const items = [
...(includeAllOption ? [{ id: ALL_YEARS_VALUE, label: t('all_years') }] : []),
...periods.map((p) => ({
id: p.id,
label: p.name,
annotation: annotationFor(p),
})),
]
return (
<ContextPicker
items={items}
value={value ?? (includeAllOption ? ALL_YEARS_VALUE : null)}
onChange={handleChange}
triggerLabel={triggerLabel}
disabled={!loaded || periods.length === 0}
ariaLabel={t('label')}
className={className}
/>
)
}