Files
accounted/components/bookkeeping/CreatePeriodDialog.tsx
T
Mattsson ade4ad5971 Fiscal period and multi bank (#228)
* feat: add fiscal period backward chaining and entry date validation

Support creating fiscal periods before the earliest existing period
(backward chaining) for backfill scenarios, alongside the existing
forward chaining. The engine now validates that entry dates fall within
the selected fiscal period, with a Swedish error message. The journal
entry form auto-selects the matching period and shows a warning with
a CreatePeriodDialog when no period covers the entry date.


* feat: support multi-bank-account for imports and reconciliation

Plumb a configurable settlement account through the entire bank import
pipeline — mapping engine, transaction entries, ingest, and
reconciliation — so secondary bank accounts (e.g. 1931, 1932) work
correctly instead of hardcoding 1930. Adds a get_unlinked_bank_lines
RPC that generalizes the existing get_unlinked_1930_lines with a
fallback for backwards compatibility. The bank file import UI now shows
a bank account selector when multiple 19xx accounts exist. Also adds
default_vat_code/sru_code to account creation and fixes uploadDocument
argument order in enable-banking sync.
2026-04-13 11:13:02 +02:00

169 lines
5.6 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useToast } from '@/components/ui/use-toast'
import { Loader2 } from 'lucide-react'
import type { FiscalPeriod } from '@/types'
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
entryDate: string
periods: FiscalPeriod[]
onCreated: () => void
}
function computeSuggestedPeriod(entryDate: string, periods: FiscalPeriod[]) {
if (periods.length === 0) {
// No periods at all — suggest a calendar year period around the entry date
const year = entryDate.split('-')[0]
return {
name: `FY ${year}`,
period_start: `${year}-01-01`,
period_end: `${year}-12-31`,
}
}
const sorted = [...periods].sort((a, b) => a.period_start.localeCompare(b.period_start))
const earliest = sorted[0]
const latest = sorted[sorted.length - 1]
if (entryDate < earliest.period_start) {
// Backward: end = day before earliest start, start = 12 months back, 1st of month
const end = new Date(earliest.period_start + 'T00:00:00')
end.setDate(end.getDate() - 1)
const start = new Date(end)
start.setMonth(start.getMonth() - 11)
start.setDate(1)
const startStr = start.toISOString().split('T')[0]
const endStr = end.toISOString().split('T')[0]
const startYear = start.getFullYear()
const endYear = end.getFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
return { name, period_start: startStr, period_end: endStr }
}
// Forward: start = day after latest end, end = 12 months later (last day of month)
const start = new Date(latest.period_end + 'T00:00:00')
start.setDate(start.getDate() + 1)
const end = new Date(start)
end.setMonth(end.getMonth() + 12)
end.setDate(0) // Last day of previous month
const startStr = start.toISOString().split('T')[0]
const endStr = end.toISOString().split('T')[0]
const startYear = start.getFullYear()
const endYear = end.getFullYear()
const name = startYear === endYear ? `FY ${startYear}` : `FY ${startYear}/${endYear}`
return { name, period_start: startStr, period_end: endStr }
}
export default function CreatePeriodDialog({ open, onOpenChange, entryDate, periods, onCreated }: Props) {
const { toast } = useToast()
const suggested = useMemo(() => computeSuggestedPeriod(entryDate, periods), [entryDate, periods])
const [name, setName] = useState(suggested.name)
const [periodStart, setPeriodStart] = useState(suggested.period_start)
const [periodEnd, setPeriodEnd] = useState(suggested.period_end)
const [isSubmitting, setIsSubmitting] = useState(false)
// Reset form when suggested values change (dialog reopened with new date)
const [lastSuggested, setLastSuggested] = useState(suggested)
if (suggested.name !== lastSuggested.name || suggested.period_start !== lastSuggested.period_start) {
setName(suggested.name)
setPeriodStart(suggested.period_start)
setPeriodEnd(suggested.period_end)
setLastSuggested(suggested)
}
const handleCreate = async () => {
setIsSubmitting(true)
try {
const res = await fetch('/api/bookkeeping/fiscal-periods', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, period_start: periodStart, period_end: periodEnd }),
})
const result = await res.json()
if (!res.ok) {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: result.error || 'Ett oväntat fel uppstod.',
variant: 'destructive',
})
return
}
toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` })
onOpenChange(false)
onCreated()
} catch {
toast({
title: 'Kunde inte skapa räkenskapsår',
description: 'Ett nätverksfel uppstod. Försök igen.',
variant: 'destructive',
})
} finally {
setIsSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Skapa räkenskapsår</DialogTitle>
<DialogDescription>
Det finns inget räkenskapsår som täcker datumet {entryDate}. Skapa ett nytt nedan.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div>
<Label>Namn</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} className="mt-1" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Startdatum</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="mt-1" />
</div>
<div>
<Label>Slutdatum</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="mt-1" />
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
Avbryt
</Button>
<Button onClick={handleCreate} disabled={isSubmitting || !name || !periodStart || !periodEnd}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Skapa
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}