Files
accounted/components/bookkeeping/CreatePeriodDialog.tsx
T
Mattsson 0ca9c25aba Add/user feedback (#679)
* 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>
2026-06-05 17:26:40 +02:00

273 lines
9.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
)
}