* feat(settings): rename or re-date an existing räkenskapsår from the fiscal-year list (#2287) Inställningar > Bokföring > Räkenskapsår offered Lås, Nollställ and Skapa nytt räkenskapsår but no way to change the name or dates of a year that already exists, although PATCH /api/bookkeeping/fiscal-periods/[id] has supported both (name always on an open year, dates only while the year has no posted vouchers). The only surface over that route was the first-year date editor under Företag, so a later or backfilled year saved with the wrong name or dates (Aisen & Adison AB, #2286) could only be repaired in the database. Every open year row now gets a quiet Ändra action opening one dialog (Namn, Startdatum, Slutdatum) that posts only the changed fields to the existing route. Dates are read-only with the route's own reason when the year has posted vouchers (count from the entry-count endpoint); the name is always editable. Locked and closed years get no Ändra, matching the route's refusal and the row's chip. Route refusals are shown inline so the user can correct and retry. The name follows the dates while it still has the shape fiscalYearName() produces (new isDerivedFiscalYearName, the inverse predicate next to the one naming helper): the customer's "Räkenskapsår 2027" corrects itself to "Räkenskapsår 2022/2023" as the dates are fixed, and a hand-written name is never overwritten. Saving invalidates ref:fiscal-periods so every picker updates; the reset dialog's typed confirmation reads the name live from the reset snapshot, so a rename does not break it. No route change. New strings in both messages/sv.json and messages/en.json. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * fix(settings): fiscal-year edit dialog is not dirty on open when the stored name has stray whitespace "Changed" now compares the raw input with the stored name and sends the trimmed value only once the user has edited it; before, a stored name with leading or trailing whitespace enabled Spara on open with a trimmed name in the payload (CodeRabbit on #2337). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
306 lines
12 KiB
TypeScript
306 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useTranslations } from 'next-intl'
|
|
import { useState, useCallback } from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import {
|
|
DestructiveConfirmDialog,
|
|
useDestructiveConfirm,
|
|
} from '@/components/ui/destructive-confirm-dialog'
|
|
import { SettingsGroup } from '@/components/settings/SettingsRows'
|
|
import { useToast } from '@/components/ui/use-toast'
|
|
import { useCompany } from '@/contexts/CompanyContext'
|
|
import { useFiscalPeriods } from '@/lib/reference-data/hooks'
|
|
import { invalidateReferenceData } from '@/lib/reference-data/invalidate'
|
|
import { Plus, Lock, Unlock, Loader2, Eraser, Pencil } from 'lucide-react'
|
|
import { formatDate } from '@/lib/utils'
|
|
import type { FiscalPeriod } from '@/types'
|
|
import CreatePeriodDialog from '@/components/bookkeeping/CreatePeriodDialog'
|
|
import { FiscalYearResetDialog } from '@/components/settings/FiscalYearResetDialog'
|
|
import { FiscalYearEditDialog } from '@/components/settings/FiscalYearEditDialog'
|
|
import { suggestSeedDate } from '@/lib/bookkeeping/suggest-fiscal-period'
|
|
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
|
|
|
/** Status of a fiscal period, in legal precedence: closed > locked > open. */
|
|
function periodStatus(p: FiscalPeriod): 'closed' | 'locked' | 'open' {
|
|
if (p.is_closed) return 'closed'
|
|
if (p.locked_at) return 'locked'
|
|
return 'open'
|
|
}
|
|
|
|
// Open is the normal state and renders as muted text; only the deviations
|
|
// (locked/closed) get a chip (UI-migration convention 5).
|
|
const STATUS_VARIANT: Record<'closed' | 'locked', 'secondary' | 'warning'> = {
|
|
closed: 'secondary',
|
|
locked: 'warning',
|
|
}
|
|
|
|
export function FiscalYearsManager() {
|
|
const t = useTranslations('settings_bookkeeping')
|
|
const { toast } = useToast()
|
|
const { role } = useCompany()
|
|
const { dialogProps, confirm } = useDestructiveConfirm()
|
|
// Session-cached registry (lib/reference-data): the same list every
|
|
// picker renders, so a lock/unlock/reset here is visible everywhere the
|
|
// moment the cache is invalidated below.
|
|
const { periods, isLoading, error: periodsError } = useFiscalPeriods()
|
|
const hasError = !!periodsError && periods.length === 0
|
|
const [dialogOpen, setDialogOpen] = useState(false)
|
|
const [mutatingId, setMutatingId] = useState<string | null>(null)
|
|
const [resetTarget, setResetTarget] = useState<FiscalPeriod | null>(null)
|
|
const [editTarget, setEditTarget] = useState<FiscalPeriod | null>(null)
|
|
|
|
// Only owners/admins may change a period's lock state. The API enforces this
|
|
// too (requireWrite); this just hides controls a viewer/member can't use.
|
|
const canManage = role === 'owner' || role === 'admin'
|
|
|
|
const refreshPeriods = useCallback(() => invalidateReferenceData('ref:fiscal-periods'), [])
|
|
|
|
// Newest first: matches the API's ordering and reads most-recent-at-top.
|
|
const sorted = [...periods].sort((a, b) => b.period_start.localeCompare(a.period_start))
|
|
|
|
async function runLockAction(
|
|
period: FiscalPeriod,
|
|
action: 'lock' | 'unlock' | 'reopen-external',
|
|
) {
|
|
setMutatingId(period.id)
|
|
try {
|
|
const res = await fetch(`/api/bookkeeping/fiscal-periods/${period.id}/${action}`, {
|
|
method: 'POST',
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) {
|
|
// Surface the backend's message verbatim: e.g. "X affärstransaktion(er)
|
|
// saknar bokföring", which tells the user exactly what to fix first.
|
|
throw new Error(body?.error?.message || t('fy_action_error'))
|
|
}
|
|
toast({
|
|
title:
|
|
action === 'lock'
|
|
? t('fy_lock_success')
|
|
: action === 'unlock'
|
|
? t('fy_unlock_success')
|
|
: t('fy_reopen_success'),
|
|
})
|
|
await refreshPeriods()
|
|
} catch (err) {
|
|
toast({
|
|
title: t('fy_action_error'),
|
|
description: err instanceof Error ? getUserErrorMessage(err) : undefined,
|
|
variant: 'destructive',
|
|
})
|
|
} finally {
|
|
setMutatingId(null)
|
|
}
|
|
}
|
|
|
|
async function handleLock(period: FiscalPeriod) {
|
|
const ok = await confirm({
|
|
title: t('fy_lock_confirm_title'),
|
|
description: t('fy_lock_confirm_body', { name: period.name }),
|
|
confirmLabel: t('fy_action_lock'),
|
|
cancelLabel: t('fy_confirm_cancel'),
|
|
variant: 'warning',
|
|
})
|
|
if (ok) await runLockAction(period, 'lock')
|
|
}
|
|
|
|
async function handleUnlock(period: FiscalPeriod) {
|
|
const ok = await confirm({
|
|
title: t('fy_unlock_confirm_title'),
|
|
description: t('fy_unlock_confirm_body', { name: period.name }),
|
|
confirmLabel: t('fy_action_unlock'),
|
|
cancelLabel: t('fy_confirm_cancel'),
|
|
variant: 'warning',
|
|
})
|
|
if (ok) await runLockAction(period, 'unlock')
|
|
}
|
|
|
|
// Undo "klarmarkera" (year marked as closed in a previous bookkeeping
|
|
// system). Only offered while the closed state still comes from that mark:
|
|
// a year closed by a real year-end run keeps its closing entry and stays
|
|
// closed here.
|
|
async function handleReopen(period: FiscalPeriod) {
|
|
const ok = await confirm({
|
|
title: t('fy_reopen_confirm_title'),
|
|
description: t('fy_reopen_confirm_body', { name: period.name }),
|
|
confirmLabel: t('fy_action_reopen'),
|
|
cancelLabel: t('fy_confirm_cancel'),
|
|
variant: 'warning',
|
|
})
|
|
if (ok) await runLockAction(period, 'reopen-external')
|
|
}
|
|
|
|
return (
|
|
<SettingsGroup label={t('fy_heading')} help={t('fy_help')}>
|
|
{isLoading ? (
|
|
<div className="space-y-2 px-1 py-3">
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-4 w-40" />
|
|
</div>
|
|
) : hasError ? (
|
|
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_load_error')}</p>
|
|
) : sorted.length === 0 ? (
|
|
<p className="px-1 py-3 text-sm text-muted-foreground">{t('fy_empty')}</p>
|
|
) : (
|
|
// Period rows: flat hairline list, no cards.
|
|
sorted.map((p) => {
|
|
const status = periodStatus(p)
|
|
const isMutating = mutatingId === p.id
|
|
const closedExternally = status === 'closed' && p.closed_externally === true
|
|
const canReopen = canManage && closedExternally && !p.closing_entry_id
|
|
return (
|
|
<div key={p.id} className="flex items-center gap-3 border-b border-border px-1 py-3">
|
|
<div className="min-w-0 flex-1">
|
|
<span className="text-sm font-medium">{p.name}</span>
|
|
<span className="ml-2 text-sm text-muted-foreground tabular-nums">
|
|
{formatDate(p.period_start)} - {formatDate(p.period_end)}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-3">
|
|
{status === 'open' ? (
|
|
<span className="text-xs text-muted-foreground">{t('fy_status_open')}</span>
|
|
) : (
|
|
<Badge variant={STATUS_VARIANT[status]}>
|
|
{closedExternally ? t('fy_status_closed_external') : t(`fy_status_${status}`)}
|
|
</Badge>
|
|
)}
|
|
{/* Ändra is offered on open years only, like Lås and
|
|
Nollställ: the PATCH route refuses locked and closed years
|
|
outright, and the row already carries that chip. */}
|
|
{canManage && status === 'open' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
disabled={isMutating}
|
|
onClick={() => setEditTarget(p)}
|
|
>
|
|
<Pencil className="mr-1.5 h-4 w-4" />
|
|
{t('fy_action_edit')}
|
|
</Button>
|
|
)}
|
|
{canManage && status === 'open' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
disabled={isMutating}
|
|
onClick={() => handleLock(p)}
|
|
>
|
|
{isMutating ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Lock className="mr-1.5 h-4 w-4" />
|
|
{t('fy_action_lock')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{canManage && status === 'open' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-destructive hover:text-destructive"
|
|
disabled={isMutating}
|
|
onClick={() => setResetTarget(p)}
|
|
>
|
|
<Eraser className="mr-1.5 h-4 w-4" />
|
|
{t('fy_action_reset')}
|
|
</Button>
|
|
)}
|
|
{canManage && status === 'locked' && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
disabled={isMutating}
|
|
onClick={() => handleUnlock(p)}
|
|
>
|
|
{isMutating ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Unlock className="mr-1.5 h-4 w-4" />
|
|
{t('fy_action_unlock')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
{canReopen && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
disabled={isMutating}
|
|
onClick={() => handleReopen(p)}
|
|
>
|
|
{isMutating ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<>
|
|
<Unlock className="mr-1.5 h-4 w-4" />
|
|
{t('fy_action_reopen')}
|
|
</>
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
)}
|
|
|
|
{/* Trailing quiet action: create the next fiscal year. */}
|
|
<div className="px-1 pt-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="text-muted-foreground hover:text-foreground"
|
|
onClick={() => setDialogOpen(true)}
|
|
disabled={isLoading}
|
|
>
|
|
<Plus className="mr-1.5 h-4 w-4" />
|
|
{t('fy_create')}
|
|
</Button>
|
|
</div>
|
|
|
|
<CreatePeriodDialog
|
|
open={dialogOpen}
|
|
onOpenChange={setDialogOpen}
|
|
entryDate={suggestSeedDate(periods, new Date().toISOString().split('T')[0])}
|
|
periods={periods}
|
|
onCreated={refreshPeriods}
|
|
/>
|
|
|
|
{editTarget && (
|
|
<FiscalYearEditDialog
|
|
period={editTarget}
|
|
open={editTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setEditTarget(null)
|
|
}}
|
|
onSaved={refreshPeriods}
|
|
/>
|
|
)}
|
|
|
|
{resetTarget && (
|
|
<FiscalYearResetDialog
|
|
periodId={resetTarget.id}
|
|
periodName={resetTarget.name}
|
|
open={resetTarget !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setResetTarget(null)
|
|
}}
|
|
onReset={refreshPeriods}
|
|
/>
|
|
)}
|
|
|
|
<DestructiveConfirmDialog {...dialogProps} />
|
|
</SettingsGroup>
|
|
)
|
|
}
|