0ca9c25aba
* feat(bookkeeping): make blocked fiscal-year creation actionable When creating a new räkenskapsår is blocked because a prior period is still open, the "Skapa räkenskapsår" dialog no longer dead-ends on an English toast. The API now returns the canonical bilingual error envelope with the blocking periods (id/name/dates) under details, and the dialog renders a Swedish panel that locks them inline (reversible locked_at) via the existing /lock endpoint and retries creation. The guard rule is unchanged and remains BFL-compliant: BFL 6 kap allows löpande bokföring of the new year in parallel with the prior year's bokslut, so a lock (not a full close) is sufficient and reversible. - Add PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS structured error code - Return envelope + details.blockingPeriods from the 409 (was English string) - CreatePeriodDialog: inline "lås och skapa" panel + lock-and-retry - Update route tests for the new envelope shape Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(ui): prevent mouse wheel from mutating number inputs A focused <input type="number"> would change its value on scroll, silently turning e.g. a 20000 salary into 19998. Blur number inputs on wheel so the page scrolls instead of editing the value. Applied at the Input primitive so all number fields are protected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(salary): auto-derive skattetabell and kolumn for employees Replace the opaque manual "Skattetabell (29-42)" and "Kolumn (1-6)" inputs on the employee form with a self-deriving flow: the user picks their folkbokföringskommun from a searchable dropdown and the tax table fills itself in, while the column derives from the personnummer we already collect. - Add a searchable municipality picker (MunicipalityCombobox) backed by a new cached GET /api/salary/tax-tables/kommuner endpoint. - Wrap the whole "Skatt" card in a self-contained EmployeeTaxCard used by both the create and edit pages, with InfoTooltips and named column options. - deriveTaxColumn(): auto-select column 1 for under-66 employees; leave the ambiguous 66+ case (pension vs working senior) to a clearly-named manual choice. - Fix fetchKommunTaxRates() to page through all ~1300 församling rows instead of a single 500-row page (which silently dropped ~200 kommuner, incl. Göteborg) and normalize the uppercase names to title case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(import): correct CSV amount-column guess and surface skipped rows Manual CSV column-mapping auto-guess walked each data row right-to-left and picked the first numeric cell as the amount, so on the common ...;Belopp;Saldo layout it grabbed the trailing running-balance column. Extract the guess into a pure, tested suggestColumnMapping(): match header labels first (belopp/amount -> amount, saldo/balance -> balance), auto-fill the balance field, and fall back to value heuristics that skip the balance column and prefer a column carrying negative values. Also surface stats.skipped_rows + parse warnings in BankFileConfirmStep - the manual-mapping path skips the preview step that was the only place they showed, so skipped rows were silently dropped from view. Add a unit test reproducing the Saldo-as-amount regression. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: add "Save as draft" functionality for invoices - Implemented a new feature to allow users to save invoices as unnumbered drafts without generating an invoice number until finalized. - Added a `save_as_draft` flag to the CreateInvoiceInput schema to handle draft saving logic. - Updated the invoice creation API to skip number allocation when saving as a draft. - Introduced a new endpoint for finalizing drafts, which allocates an invoice number and emits an `invoice.created` event. - Enhanced the UI to include a "Save as draft" button, with loading states and tooltips. - Updated tests to cover the new draft saving and finalization logic, including race conditions for concurrent modifications. - Added relevant error handling for draft finalization and deletion scenarios. * feat(employee): add employment start and end date fields to employee forms * feat: enhance invoice and salary run handling with improved validation and event logging --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
273 lines
9.6 KiB
TypeScript
273 lines
9.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, Lock } from 'lucide-react'
|
||
import type { FiscalPeriod } from '@/types'
|
||
|
||
interface Props {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
entryDate: string
|
||
periods: FiscalPeriod[]
|
||
onCreated: () => void
|
||
}
|
||
|
||
/** A prior period that must be locked before a new fiscal year can be created. */
|
||
interface BlockingPeriod {
|
||
id: string
|
||
name: string
|
||
period_start: string
|
||
period_end: string
|
||
}
|
||
|
||
/** Read a user-facing message from either a legacy string error or the
|
||
* canonical { code, message } envelope. */
|
||
function errorMessage(err: unknown, fallback = 'Ett oväntat fel uppstod.'): string {
|
||
if (typeof err === 'string') return err
|
||
if (err && typeof err === 'object' && typeof (err as { message?: unknown }).message === 'string') {
|
||
return (err as { message: string }).message
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
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
|
||
// Use UTC throughout — local-time Date math + toISOString() shifts dates by
|
||
// the timezone offset (e.g. CET produces 2024-12-31 → 2025-12-30).
|
||
const end = new Date(earliest.period_start + 'T00:00:00Z')
|
||
end.setUTCDate(end.getUTCDate() - 1)
|
||
|
||
const start = new Date(end)
|
||
start.setUTCMonth(start.getUTCMonth() - 11)
|
||
start.setUTCDate(1)
|
||
|
||
const startStr = start.toISOString().split('T')[0]
|
||
const endStr = end.toISOString().split('T')[0]
|
||
const startYear = start.getUTCFullYear()
|
||
const endYear = end.getUTCFullYear()
|
||
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:00Z')
|
||
start.setUTCDate(start.getUTCDate() + 1)
|
||
|
||
const end = new Date(start)
|
||
end.setUTCMonth(end.getUTCMonth() + 12)
|
||
end.setUTCDate(0) // Last day of previous month
|
||
|
||
const startStr = start.toISOString().split('T')[0]
|
||
const endStr = end.toISOString().split('T')[0]
|
||
const startYear = start.getUTCFullYear()
|
||
const endYear = end.getUTCFullYear()
|
||
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)
|
||
const [isLocking, setIsLocking] = useState(false)
|
||
// Set when creation is blocked because a prior räkenskapsår is still open.
|
||
// The user can lock these inline and retry without leaving the dialog.
|
||
const [blockingPeriods, setBlockingPeriods] = useState<BlockingPeriod[]>([])
|
||
|
||
// 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)
|
||
setBlockingPeriods([])
|
||
}
|
||
|
||
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) {
|
||
const err = result?.error
|
||
// Blocked by an open prior year — surface an inline "lås och försök
|
||
// igen" path instead of a dead-end toast.
|
||
if (
|
||
err &&
|
||
typeof err === 'object' &&
|
||
err.code === 'PERIOD_CREATE_BLOCKED_BY_OPEN_PERIODS'
|
||
) {
|
||
const blocking = (err.details?.blockingPeriods ?? []) as BlockingPeriod[]
|
||
setBlockingPeriods(blocking)
|
||
return
|
||
}
|
||
toast({
|
||
title: 'Kunde inte skapa räkenskapsår',
|
||
description: errorMessage(err),
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
|
||
toast({ title: 'Räkenskapsår skapat', description: `${name} har skapats.` })
|
||
setBlockingPeriods([])
|
||
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)
|
||
}
|
||
}
|
||
|
||
// Lock each blocking prior year (reversible locked_at), then retry creation.
|
||
const handleLockAndRetry = async () => {
|
||
setIsLocking(true)
|
||
try {
|
||
for (const p of blockingPeriods) {
|
||
const res = await fetch(`/api/bookkeeping/fiscal-periods/${p.id}/lock`, {
|
||
method: 'POST',
|
||
})
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}))
|
||
// An already-locked period is fine — keep going.
|
||
if (body?.error?.code === 'PERIOD_LOCK_ALREADY_LOCKED') continue
|
||
toast({
|
||
title: `Kunde inte låsa ${p.name}`,
|
||
description: errorMessage(body?.error),
|
||
variant: 'destructive',
|
||
})
|
||
return
|
||
}
|
||
}
|
||
setBlockingPeriods([])
|
||
await handleCreate()
|
||
} catch {
|
||
toast({
|
||
title: 'Kunde inte låsa räkenskapsåret',
|
||
description: 'Ett nätverksfel uppstod. Försök igen.',
|
||
variant: 'destructive',
|
||
})
|
||
} finally {
|
||
setIsLocking(false)
|
||
}
|
||
}
|
||
|
||
const isBlocked = blockingPeriods.length > 0
|
||
const busy = isSubmitting || isLocking
|
||
|
||
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>
|
||
|
||
{isBlocked && (
|
||
<div className="rounded-lg border border-warning/20 bg-warning/5 p-3 text-sm flex gap-2">
|
||
<Lock className="h-4 w-4 text-warning flex-shrink-0 mt-0.5" />
|
||
<div className="space-y-2">
|
||
<div className="space-y-1">
|
||
<p className="font-medium">Föregående räkenskapsår är öppet</p>
|
||
<p className="text-muted-foreground">
|
||
Du måste låsa föregående räkenskapsår innan du kan skapa ett nytt.
|
||
Låsningen är vändbar — du kan låsa upp året igen för att bokföra
|
||
bokslutsposter.
|
||
</p>
|
||
</div>
|
||
<ul className="space-y-0.5 text-muted-foreground">
|
||
{blockingPeriods.map((p) => (
|
||
<li key={p.id} className="tabular-nums">
|
||
{p.name} ({p.period_start} – {p.period_end})
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
|
||
Avbryt
|
||
</Button>
|
||
{isBlocked ? (
|
||
<Button onClick={handleLockAndRetry} disabled={busy}>
|
||
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
{blockingPeriods.length > 1 ? 'Lås åren och skapa' : 'Lås året och skapa'}
|
||
</Button>
|
||
) : (
|
||
<Button onClick={handleCreate} disabled={busy || !name || !periodStart || !periodEnd}>
|
||
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
Skapa
|
||
</Button>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|