feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker (#278)

* feat: multi-series SIE import, reusable FiscalYearSelector, library templates in picker

- SIE import preserves each voucher's source series (B/C/I/V/...), essential
  for Fortnox migrations where series carry semantic meaning (kundfakturor,
  inbetalningar, etc.). Target numbering still goes through next_voucher_number
  per series; source (series, number) is stored in the migration mapping for
  BFNAR 2013:2 audit trail.
- Execute route reads company_settings.default_voucher_series as the fallback
  for vouchers arriving without a series (SIE4I).
- Extract shared FiscalYearSelector component; adopt in /reports and
  /bookkeeping.
- Transaction TemplatePicker now surfaces user-created library templates
  (company + team scope) alongside the static registry, with a helper to
  convert simple library templates into the BookingTemplate shape.
- Exclude 8999 "Årets resultat" from income statement financial section and
  monthly breakdown so year-end closing entries don't cancel the net result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: skip Bokio SIE regression when fixtures are absent

/dev_docs is gitignored (contains anonymised customer exports), so the
integration test can't find its input files in CI. Gate the suite on
fixture presence so it still runs locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address Greptile review feedback

- convertLibraryToBookingTemplate: default entity_applicability to 'all'
  when the source template has no entity_type, so TemplatePicker doesn't
  silently hide it for companies with a set entity type.
- FiscalYearSelector: fire onReady in the no-company early-return branch
  so consumers (e.g. ReportsPage) don't get stuck in a loading skeleton
  while the company context is still hydrating.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-04-20 11:40:15 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent d708a85d4c
commit 23664e79cb
15 changed files with 1061 additions and 131 deletions
+9 -2
View File
@@ -7,10 +7,13 @@ import { Button } from '@/components/ui/button'
import JournalEntryList from '@/components/bookkeeping/JournalEntryList'
import JournalEntryForm from '@/components/bookkeeping/JournalEntryForm'
import ChartOfAccountsManager from '@/components/bookkeeping/ChartOfAccountsManager'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { Lock } from 'lucide-react'
export default function BookkeepingPage() {
const [refreshKey, setRefreshKey] = useState(0)
const [activeTab, setActiveTab] = useState('journal')
const [periodId, setPeriodId] = useState<string | null>(null)
return (
<div className="space-y-6">
@@ -29,7 +32,11 @@ export default function BookkeepingPage() {
</Button>
</div>
<Tabs defaultValue="journal">
{activeTab === 'journal' && (
<FiscalYearSelector value={periodId} onChange={setPeriodId} />
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="journal">Verifikationer</TabsTrigger>
<TabsTrigger value="new-entry">Ny verifikation</TabsTrigger>
@@ -37,7 +44,7 @@ export default function BookkeepingPage() {
</TabsList>
<TabsContent value="journal">
<JournalEntryList key={refreshKey} />
<JournalEntryList key={`${refreshKey}-${periodId ?? 'all'}`} periodId={periodId ?? undefined} />
</TabsContent>
<TabsContent value="new-entry">
+24 -51
View File
@@ -10,6 +10,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { Download, AlertCircle, ChevronDown, ChevronRight, ArrowRight } from 'lucide-react'
import { AccountNumber } from '@/components/ui/account-number'
import { useCompany } from '@/contexts/CompanyContext'
import { FiscalYearSelector } from '@/components/common/FiscalYearSelector'
import { NEDeclarationView } from '@/components/reports/NEDeclarationView'
import { INK2DeclarationView } from '@/components/reports/INK2DeclarationView'
import { BankReconciliationView } from '@/components/reports/BankReconciliationView'
@@ -19,7 +20,6 @@ import { SkatteverketPanel } from '@/components/reports/SkatteverketPanel'
import { IncomeExpenseChart } from '@/components/reports/IncomeExpenseChart'
import type { MonthlyDataPoint } from '@/components/reports/IncomeExpenseChart'
import type {
FiscalPeriod,
TrialBalanceRow,
IncomeStatementReport,
BalanceSheetReport,
@@ -46,7 +46,6 @@ const TAB_LABELS: Record<string, string> = {
}
export default function ReportsPage() {
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
const [selectedPeriod, setSelectedPeriod] = useState('')
const [activeTab, setActiveTab] = useState('trial-balance')
const [isLoadingInit, setIsLoadingInit] = useState(true)
@@ -79,22 +78,8 @@ export default function ReportsPage() {
setDrillDownTrail(drillDownTrail.slice(0, stepIndex))
}, [drillDownTrail])
async function fetchPeriods() {
const res = await fetch('/api/bookkeeping/fiscal-periods')
const { data } = await res.json()
const today = new Date().toISOString().split('T')[0]
const activePeriods = (data || []).filter((p: FiscalPeriod) => p.period_start <= today)
setPeriods(activePeriods)
if (activePeriods.length > 0) {
setSelectedPeriod(activePeriods[0].id)
}
}
useEffect(() => {
fetchPeriods().finally(() => {
setIsLoadingInit(false)
})
}, [])
// Period list is loaded by FiscalYearSelector; isLoadingInit flips to false
// via its onReady callback once the initial fetch completes.
const isEnskildFirma = company?.entity_type === 'enskild_firma'
const isAktiebolag = company?.entity_type === 'aktiebolag'
@@ -110,14 +95,29 @@ export default function ReportsPage() {
</div>
</div>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<FiscalYearSelector
value={selectedPeriod || null}
onChange={(id) => setSelectedPeriod(id || '')}
includeAllOption={false}
hideFuturePeriods
onReady={() => setIsLoadingInit(false)}
/>
{selectedPeriod && (
<Button
variant="outline"
onClick={() => {
window.open(`/api/reports/sie-export?period_id=${selectedPeriod}`, '_blank')
}}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner SIE-fil
</Button>
)}
</div>
{isLoadingInit ? (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<div>
<div className="h-4 bg-muted rounded w-24 animate-pulse mb-1" />
<div className="h-10 bg-muted rounded w-64 animate-pulse" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-2">
@@ -139,33 +139,6 @@ export default function ReportsPage() {
</div>
) : (
<>
<div className="flex flex-col sm:flex-row sm:items-end gap-4">
<div>
<Label>Räkenskapsår</Label>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="w-full mt-1 rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{periods.map((p) => (
<option key={p.id} value={p.id}>
{p.name} ({p.period_start} — {p.period_end})
</option>
))}
</select>
</div>
{selectedPeriod && (
<Button
variant="outline"
onClick={() => {
window.open(`/api/reports/sie-export?period_id=${selectedPeriod}`, '_blank')
}}
>
<Download className="h-4 w-4 mr-2" />
Ladda ner SIE-fil
</Button>
)}
</div>
{selectedPeriod ? (
<>
+19 -4
View File
@@ -27,6 +27,7 @@ import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from '@/components/transactions
import { getDefaultAccountForCategory, getDefaultVatTreatmentForCategory } from '@/lib/bookkeeping/category-mapping'
import { getTemplateById, type BookingTemplate } from '@/lib/bookkeeping/booking-templates'
import { isCounterpartyTemplateId, extractCounterpartyId } from '@/lib/bookkeeping/counterparty-templates'
import { isLibraryTemplateId } from '@/lib/bookkeeping/template-library'
import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/components/transactions/transaction-types'
import { useCompany } from '@/contexts/CompanyContext'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -681,7 +682,10 @@ export default function TransactionsPage() {
setTemplatePickerOpen(false)
const tx = templatePickerTransaction
if (!tx) return
setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId: template.id, linePattern: null })
// Library templates aren't validated server-side via template_id; the
// template's debit/credit + VAT drive the booking through account_override.
const templateId = isLibraryTemplateId(template.id) ? undefined : template.id
setQuickReview({ transaction: tx, category: template.fallback_category, label: template.name_sv, template, templateId, linePattern: null })
setQuickReviewOpen(true)
}
@@ -942,14 +946,25 @@ export default function TransactionsPage() {
</Dialog>
<QuickReviewDialog
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId)}
key={quickReview?.transaction.id ?? '' + String(quickReview?.category) + String(quickReview?.templateId) + String(quickReview?.template?.id)}
open={quickReviewOpen}
onOpenChange={setQuickReviewOpen}
transaction={quickReview?.transaction ?? null}
category={quickReview?.category ?? null}
categoryLabel={quickReview?.label ?? ''}
defaultAccount={quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''}
defaultVat={quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'}
defaultAccount={
// For library templates (no templateId but a template object), use the
// template's debit account as the default; otherwise fall back to the
// category's default account.
!quickReview?.templateId && quickReview?.template
? quickReview.template.debit_account
: quickReview?.category ? getDefaultAccountForCategory(quickReview.category) : ''
}
defaultVat={
!quickReview?.templateId && quickReview?.template
? (quickReview.template.vat_treatment ?? 'none')
: quickReview?.category ? (getDefaultVatTreatmentForCategory(quickReview.category) ?? 'none') : 'none'
}
entityType={entityType as EntityType}
template={quickReview?.template ?? null}
templateId={quickReview?.templateId}
+14 -4
View File
@@ -44,12 +44,22 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Ingen fil bifogad. Gå tillbaka och ladda upp filen igen.' }, { status: 400 })
}
// Parse options
const options = optionsJson ? JSON.parse(optionsJson) : {
// Parse options. The voucherSeries option is only a fallback for vouchers
// that arrive without a series (SIE4I subsystem files); the import engine
// preserves each #VER's source series per voucher.
const parsedOptions = optionsJson ? JSON.parse(optionsJson) : null
const { data: companySettings } = await supabase
.from('company_settings')
.select('default_voucher_series')
.eq('company_id', companyId)
.maybeSingle()
const companyDefaultSeries = companySettings?.default_voucher_series || 'B'
const options = parsedOptions ?? {
createFiscalPeriod: true,
importOpeningBalances: true,
importTransactions: true,
voucherSeries: 'B',
voucherSeries: companyDefaultSeries,
}
// Read and decode file
@@ -207,7 +217,7 @@ export async function POST(request: Request) {
createFiscalPeriod: options.createFiscalPeriod,
importOpeningBalances: options.importOpeningBalances,
importTransactions: options.importTransactions,
voucherSeries: options.voucherSeries || 'B',
voucherSeries: options.voucherSeries || companyDefaultSeries,
}
)