'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 } 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 { 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(null) const [resetTarget, setResetTarget] = useState(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 ( {isLoading ? (
) : hasError ? (

{t('fy_load_error')}

) : sorted.length === 0 ? (

{t('fy_empty')}

) : ( // 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 (
{p.name} {formatDate(p.period_start)} - {formatDate(p.period_end)}
{status === 'open' ? ( {t('fy_status_open')} ) : ( {closedExternally ? t('fy_status_closed_external') : t(`fy_status_${status}`)} )} {canManage && status === 'open' && ( )} {canManage && status === 'open' && ( )} {canManage && status === 'locked' && ( )} {canReopen && ( )}
) }) )} {/* Trailing quiet action: create the next fiscal year. */}
{resetTarget && ( { if (!open) setResetTarget(null) }} onReset={refreshPeriods} /> )}
) }