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:
co-authored by
Claude Opus 4.7
parent
d708a85d4c
commit
23664e79cb
@@ -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">
|
||||
|
||||
@@ -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 ? (
|
||||
<>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user