feat: implement fiscal period date fields component and validation logic (#301)

* feat: implement fiscal period date fields component and validation logic

* feat: update fiscal period validation and naming logic
This commit is contained in:
Mattsson
2026-04-21 17:08:38 +02:00
committed by GitHub
parent 08991218ee
commit 885dd8a2e4
8 changed files with 504 additions and 257 deletions
@@ -0,0 +1,180 @@
'use client'
import { useMemo, type ReactNode } from 'react'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { CalendarDays } from 'lucide-react'
import {
monthsBetween,
parseDateParts,
validatePeriodDuration,
} from '@/lib/bookkeeping/validate-period-duration'
import type { EntityType } from '@/types'
const monthNames = [
'januari', 'februari', 'mars', 'april', 'maj', 'juni',
'juli', 'augusti', 'september', 'oktober', 'november', 'december',
]
function formatSwedishDate(dateStr: string): string {
const { year, month, day } = parseDateParts(dateStr)
return `${day} ${monthNames[month - 1]} ${year}`
}
function endsOnDec31(end: string): boolean {
const e = parseDateParts(end)
return e.month === 12 && e.day === 31
}
/**
* Map validatePeriodDuration's English messages to user-facing Swedish copy.
*/
function toSwedishError(msg: string): string {
if (msg.includes('after period start')) return 'Slutdatum måste vara efter startdatum.'
if (msg.includes('1st of a month')) return 'Startdatum måste vara den första i månaden.'
if (msg.includes('last day of a month')) return 'Slutdatum måste vara den sista i månaden.'
if (msg.includes('exceeds maximum 18 months')) return 'Räkenskapsåret får vara högst 18 månader (BFL 3 kap.).'
if (msg.includes('at least 6 months')) return 'Första räkenskapsåret måste vara minst 6 månader (BFL 3 kap.).'
return msg
}
export interface FiscalPeriodValidation {
/** User-facing Swedish error, or null if valid */
error: string | null
/** Integer month count, or null if inputs are incomplete/invalid */
months: number | null
/** True if inputs are complete enough to render the summary */
canSummarise: boolean
}
/**
* Shared validation for the first fiscal period — used by both onboarding Step 3
* and the settings FiscalPeriodEditor. Returns Swedish error copy.
*/
export function validateFirstPeriod(
startDate: string,
endDate: string,
entityType: EntityType | undefined
): FiscalPeriodValidation {
if (!startDate || !endDate) {
return { error: null, months: null, canSummarise: false }
}
if (endDate <= startDate) {
return {
error: 'Slutdatum måste vara efter startdatum.',
months: null,
canSummarise: false,
}
}
const baseError = validatePeriodDuration(startDate, endDate, { isFirstPeriod: true })
if (baseError) {
return {
error: toSwedishError(baseError),
months: monthsBetween(startDate, endDate),
canSummarise: true,
}
}
if (entityType === 'enskild_firma' && !endsOnDec31(endDate)) {
return {
error: 'Enskild firma måste ha slutdatum 31 december (BFL 3 kap.).',
months: monthsBetween(startDate, endDate),
canSummarise: true,
}
}
return {
error: null,
months: monthsBetween(startDate, endDate),
canSummarise: true,
}
}
interface FiscalPeriodDateFieldsProps {
startDate: string
onStartDateChange: (value: string) => void
startHelpText?: string
/**
* Render the end-date control. Onboarding passes its AB end-month <Select>
* + computed-option <Select>; settings passes a native <input type="date">.
*/
endDateSlot: ReactNode
/** The raw end-date string (used for summary + validation). */
endDate: string
entityType: EntityType | undefined
/** Label for the summary card. Defaults to "Ditt första räkenskapsår". */
summaryTitle?: string
/** Optional override for start date <Label> content. */
startLabel?: ReactNode
}
/**
* Shared first-fiscal-period date entry: day-level start (native date input),
* caller-provided end-date control, and a Swedish summary card with inline
* validation errors (6–18 months, EF calendar-year rule, last-day-of-month).
*
* Used by onboarding Step 3 and the settings FiscalPeriodEditor so the two
* screens stay in lockstep.
*/
export function FiscalPeriodDateFields({
startDate,
onStartDateChange,
startHelpText = 'Första räkenskapsåret kan börja valfri dag.',
endDateSlot,
endDate,
entityType,
summaryTitle = 'Ditt första räkenskapsår',
startLabel = 'Startdatum',
}: FiscalPeriodDateFieldsProps) {
const validation = useMemo(
() => validateFirstPeriod(startDate, endDate, entityType),
[startDate, endDate, entityType],
)
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fiscal-period-start">{startLabel}</Label>
<Input
id="fiscal-period-start"
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
/>
<p className="text-xs text-muted-foreground">{startHelpText}</p>
</div>
{endDateSlot}
{validation.canSummarise && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
{summaryTitle}
</div>
{startDate && endDate && (
<p className="text-sm text-muted-foreground">
{formatSwedishDate(startDate)} – {formatSwedishDate(endDate)}
</p>
)}
{validation.months !== null && (
<p
className={`text-xs ${validation.error ? 'text-destructive' : 'text-muted-foreground'}`}
>
{validation.months} månader
{validation.error ? ` — ${validation.error}` : ''}
</p>
)}
{validation.error && validation.months === null && (
<p className="text-xs text-destructive">{validation.error}</p>
)}
</div>
)}
{validation.error && !validation.canSummarise && (
<p className="text-xs text-destructive">{validation.error}</p>
)}
</div>
)
}
+103 -187
View File
@@ -13,11 +13,15 @@ import { InfoTooltip } from '@/components/ui/info-tooltip'
import { Loader2, ArrowRight, ArrowLeft, Check, CalendarDays } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import {
FiscalPeriodDateFields,
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
import type { EntityType } from '@/types'
const schema = z.object({
@@ -71,11 +75,6 @@ const monthNames = [
'Juli', 'Augusti', 'September', 'Oktober', 'November', 'December',
]
function formatSwedishDate(dateStr: string): string {
const { year, month, day } = parseDateParts(dateStr)
return `${day} ${monthNames[month - 1].toLowerCase()} ${year}`
}
/**
* Get the last day of a given month (1-indexed).
*/
@@ -131,7 +130,7 @@ function getABFirstYearEndDates(
// Try ending in the same year or next year
for (const endYear of [startYear, startYear + 1, startYear + 2]) {
const months = (endYear - startYear) * 12 + (endMonth - startMonth) + 1
if (months >= 1 && months <= 18) {
if (months >= 6 && months <= 18) {
const day = lastDayOfMonth(endYear, endMonth)
const endDate = `${endYear}-${String(endMonth).padStart(2, '0')}-${String(day).padStart(2, '0')}`
options.push({
@@ -176,26 +175,8 @@ export default function Step3TaxRegistration({
const isFirstYear = watch('is_first_fiscal_year')
const firstYearStart = watch('first_year_start')
const firstYearEnd = watch('first_year_end')
const fiscalYearEndMonth = watch('fiscal_year_end_month')
// State for first-year start date selectors (day/month/year)
const [startDay, setStartDay] = useState<number>(
initialData.first_year_start
? parseDateParts(initialData.first_year_start).day
: 1
)
const [startMonth, setStartMonth] = useState<number>(
initialData.first_year_start
? parseDateParts(initialData.first_year_start).month
: 0
)
const [startYear, setStartYear] = useState<number>(
initialData.first_year_start
? parseDateParts(initialData.first_year_start).year
: 0
)
// State for AB first-year end month selector
const [abEndMonth, setAbEndMonth] = useState<number>(
initialData.first_year_end
@@ -226,6 +207,20 @@ export default function Step3TaxRegistration({
let firstEnd: string | undefined
if (data.is_first_fiscal_year && data.first_year_start && data.first_year_end) {
// Validate the 6–18 month BFL 3 kap. window + EF calendar-year rule
const validation = validateFirstPeriod(
data.first_year_start,
data.first_year_end,
entityType,
)
if (validation.error) {
toast({
title: 'Räkenskapsåret är inte giltigt',
description: validation.error,
variant: 'destructive',
})
return
}
// Derive start month from end date
const endMonth = parseDateParts(data.first_year_end).month
fiscalYearStartMonth = endMonth === 12 ? 1 : endMonth + 1
@@ -395,173 +390,94 @@ export default function Step3TaxRegistration({
{/* First fiscal year options */}
{isFirstYear && (
<div className="space-y-4 rounded-lg bg-muted/50 p-4">
<div className="space-y-2">
<Label>Startdatum</Label>
<Controller
name="first_year_start"
control={control}
render={({ field }) => {
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 7 }, (_, i) => currentYear - 5 + i)
const updateField = (day: number, month: number, year: number) => {
if (month && year) {
const maxDay = lastDayOfMonth(year, month)
const clampedDay = Math.min(day, maxDay)
field.onChange(`${year}-${String(month).padStart(2, '0')}-${String(clampedDay).padStart(2, '0')}`)
}
}
const handleYearChange = (year: number) => {
setStartYear(year)
updateField(startDay, startMonth, year)
}
const handleMonthChange = (month: number) => {
setStartMonth(month)
// Clamp day if needed when month changes
if (startYear) {
const maxDay = lastDayOfMonth(startYear, month)
if (startDay > maxDay) setStartDay(maxDay)
}
updateField(startDay, month, startYear)
}
const handleDayChange = (day: number) => {
setStartDay(day)
updateField(day, startMonth, startYear)
}
const maxDays = startMonth && startYear
? lastDayOfMonth(startYear, startMonth)
: 31
return (
<div className="grid grid-cols-3 gap-2">
<Select
value={startYear ? startYear.toString() : ''}
onValueChange={(v) => { if (v) handleYearChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="År" />
</SelectTrigger>
<SelectContent>
{years.map((y) => (
<SelectItem key={y} value={y.toString()}>{y}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={startMonth ? startMonth.toString() : ''}
onValueChange={(v) => { if (v) handleMonthChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="Månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>{name}</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={startDay.toString()}
onValueChange={(v) => { if (v) handleDayChange(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="Dag" />
</SelectTrigger>
<SelectContent>
{Array.from({ length: maxDays }, (_, i) => i + 1).map((d) => (
<SelectItem key={d} value={d.toString()}>{d}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}}
/>
<p className="text-xs text-muted-foreground">
Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag.
</p>
{errors.first_year_start && (
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
)}
</div>
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>Räkenskapsåret slutar (månad)</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => { if (v) setAbEndMonth(parseInt(v)) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>Slutdatum</Label>
<Controller
name="first_year_start"
control={control}
render={({ field: startField }) => (
<Controller
name="first_year_end"
control={control}
render={({ field }) => (
<Select
value={field.value || ''}
onValueChange={(v) => { if (v) field.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj slutdatum" />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
render={({ field: endField }) => (
<FiscalPeriodDateFields
startDate={startField.value || ''}
onStartDateChange={(v) => {
startField.onChange(v)
// Reset end when start changes — its valid options depend on start
if (endField.value) endField.onChange('')
}}
startHelpText="Datumet företaget registrerades. Första räkenskapsåret kan börja valfri dag."
endDate={endField.value || ''}
entityType={entityType}
endDateSlot={
<>
{/* AB: end month selector */}
{!isEF && parsedStart && (
<div className="space-y-2">
<Label>Räkenskapsåret slutar (månad)</Label>
<Select
value={abEndMonth.toString()}
onValueChange={(v) => {
if (v) {
setAbEndMonth(parseInt(v))
if (endField.value) endField.onChange('')
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj månad" />
</SelectTrigger>
<SelectContent>
{monthNames.map((name, i) => (
<SelectItem key={i + 1} value={(i + 1).toString()}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{/* End date selector (options depend on entity type + start) */}
{parsedStart && firstYearEndOptions.length > 0 && (
<div className="space-y-2">
<Label>Slutdatum</Label>
<Select
value={endField.value || ''}
onValueChange={(v) => { if (v) endField.onChange(v) }}
>
<SelectTrigger>
<SelectValue placeholder="Välj slutdatum" />
</SelectTrigger>
<SelectContent>
{firstYearEndOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.first_year_end && (
<p className="text-xs text-destructive">{errors.first_year_end.message}</p>
)}
</div>
)}
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
</p>
)}
{errors.first_year_start && (
<p className="text-xs text-destructive">{errors.first_year_start.message}</p>
)}
</>
}
/>
)}
/>
{errors.first_year_end && (
<p className="text-xs text-destructive">{errors.first_year_end.message}</p>
)}
</div>
)}
{parsedStart && firstYearEndOptions.length === 0 && (
<p className="text-sm text-destructive">
Ingen giltig slutperiod hittades. Kontrollera startdatumet.
</p>
)}
{firstYearStart && firstYearEnd && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Ditt första räkenskapsår
</div>
<p className="text-sm text-muted-foreground">
{formatSwedishDate(firstYearStart)} &ndash; {formatSwedishDate(firstYearEnd)}
</p>
<p className="text-xs text-muted-foreground">
{monthsBetween(firstYearStart, firstYearEnd)} månader
</p>
</div>
)}
)}
/>
</div>
)}
+39 -66
View File
@@ -1,6 +1,6 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useState } from 'react'
import { useCompany } from '@/contexts/CompanyContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -10,8 +10,12 @@ import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { Loader2, CalendarDays, Info, Lock } from 'lucide-react'
import { monthsBetween, parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import { Loader2, Info, Lock } from 'lucide-react'
import { parseDateParts } from '@/lib/bookkeeping/validate-period-duration'
import {
FiscalPeriodDateFields,
validateFirstPeriod,
} from '@/components/bookkeeping/FiscalPeriodDateFields'
import type { FiscalPeriod } from '@/types'
function formatSwedishDate(dateStr: string): string {
@@ -91,17 +95,11 @@ export function FiscalPeriodEditor() {
}
}, [company])
const durationMonths = useMemo(() => {
if (!startDate || !endDate || endDate <= startDate) return null
return monthsBetween(startDate, endDate)
}, [startDate, endDate])
const efCalendarYearInvalid = useMemo(() => {
if (!isEF || !startDate || !endDate) return false
return !isCalendarYear({ period_start: startDate, period_end: endDate })
}, [isEF, startDate, endDate])
const exceedsMaxDuration = durationMonths !== null && durationMonths > 18
const validation = validateFirstPeriod(
startDate,
endDate,
company?.entity_type,
)
const isBlocked =
!!period && (period.locked_at || period.is_closed || (postedCount ?? 0) > 0)
@@ -126,7 +124,12 @@ export function FiscalPeriodEditor() {
setIsSaving(true)
try {
const newName = `Räkenskapsår ${parseDateParts(endDate).year}`
const startYear = parseDateParts(startDate).year
const endYear = parseDateParts(endDate).year
const newName =
startYear === endYear
? `Räkenskapsår ${startYear}`
: `Räkenskapsår ${startYear}/${endYear}`
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -201,55 +204,27 @@ export function FiscalPeriodEditor() {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fp_start">Startdatum</Label>
<Input
id="fp_start"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Första räkenskapsåret kan börja valfri dag.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="fp_end">Slutdatum</Label>
<Input
id="fp_end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Måste vara sista dagen i en månad.
</p>
</div>
</div>
{startDate && endDate && endDate > startDate && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4 space-y-1">
<div className="flex items-center gap-2 text-sm font-medium">
<CalendarDays className="h-4 w-4 text-primary" />
Föreslaget räkenskapsår
<FiscalPeriodDateFields
startDate={startDate}
onStartDateChange={setStartDate}
endDate={endDate}
entityType={company?.entity_type}
summaryTitle="Föreslaget räkenskapsår"
endDateSlot={
<div className="space-y-2">
<Label htmlFor="fp_end">Slutdatum</Label>
<Input
id="fp_end"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Måste vara sista dagen i en månad.
</p>
</div>
<p className="text-sm text-muted-foreground">
{formatSwedishDate(startDate)} &ndash; {formatSwedishDate(endDate)}
</p>
{durationMonths !== null && (
<p className={`text-xs ${exceedsMaxDuration ? 'text-destructive' : 'text-muted-foreground'}`}>
{durationMonths} månader
{exceedsMaxDuration && ' — över 18 månader är inte tillåtet (BFL 3 kap.)'}
</p>
)}
{efCalendarYearInvalid && (
<p className="text-xs text-destructive">
Enskild firma måste använda kalenderår (1 januari &ndash; 31 december).
</p>
)}
</div>
)}
}
/>
<div className="flex justify-end gap-2">
<Button
@@ -268,9 +243,7 @@ export function FiscalPeriodEditor() {
isSaving ||
!startDate ||
!endDate ||
endDate <= startDate ||
exceedsMaxDuration ||
efCalendarYearInvalid
validation.error !== null
}
>
{isSaving ? (