'use client' import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' import { useTranslations } from 'next-intl' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { AlertTriangle, Lock, ArrowRight } from 'lucide-react' import { formatDate } from '@/lib/utils' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import type { JournalEntry } from '@/types' interface Props { entry: JournalEntry open: boolean onOpenChange: (open: boolean) => void onMoved: () => void } type PeriodStatus = { status: 'open' | 'locked' | 'closed' period_id: string | null lock_date: string | null period_name: string | null } const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/ export default function RecordateEntryDialog({ entry, open, onOpenChange, onMoved }: Props) { const { toast } = useToast() const router = useRouter() const t = useTranslations('journal_detail') const [newDate, setNewDate] = useState(entry.entry_date) const [preview, setPreview] = useState(null) const [previewLoading, setPreviewLoading] = useState(false) const [previewError, setPreviewError] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) // Non-null when the server refused with CORRECTION_CHAIN_TOO_DEEP: holds the // reported chain depth and opens the bypass confirm ("Flytta ändå"). const [deepChainDepth, setDeepChainDepth] = useState(null) // Reset to the original date each time the dialog opens. useEffect(() => { if (open) { setNewDate(entry.entry_date) setPreview(null) setPreviewError(null) } }, [open, entry.entry_date]) // Resolve the target period status whenever a valid, changed date is entered. useEffect(() => { if (!open) return if (!ISO_DATE.test(newDate) || newDate === entry.entry_date) { setPreview(null) setPreviewError(null) return } let cancelled = false setPreviewLoading(true) setPreviewError(null) const handle = setTimeout(async () => { try { const res = await fetch( `/api/bookkeeping/fiscal-periods/period-status?date=${encodeURIComponent(newDate)}` ) if (!res.ok) throw new Error('period_status_failed') const { data } = await res.json() if (!cancelled) { setPreview((data as PeriodStatus) ?? null) setPreviewError(null) } } catch { if (!cancelled) { setPreview(null) setPreviewError('Kunde inte kontrollera perioden. Försök igen.') } } finally { if (!cancelled) setPreviewLoading(false) } }, 250) return () => { cancelled = true clearTimeout(handle) } }, [newDate, open, entry.entry_date]) const dateChanged = ISO_DATE.test(newDate) && newDate !== entry.entry_date const targetOpen = preview?.status === 'open' && !!preview?.period_id const noCoveringPeriod = preview?.status === 'open' && !preview?.period_id // Soft, non-blocking advisory when moving into a past date: the moms for // that period may already have been filed. const today = new Date().toISOString().slice(0, 10) const movingIntoPast = dateChanged && newDate < today const canSubmit = dateChanged && targetOpen && !isSubmitting async function handleSubmit(allowDeepChain = false) { if (!canSubmit) return setIsSubmitting(true) try { const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/recordate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ new_entry_date: newDate, ...(allowDeepChain ? { allow_deep_chain: true } : {}), }), }) const result = await res.json() if (!res.ok) { // Chain-depth guard: open the bypass confirm instead of a dead-end // toast. "Flytta ändå" resubmits with allow_deep_chain=true. const structured = (result as { error?: { code?: string; details?: { depth?: number } } })?.error if (structured?.code === 'CORRECTION_CHAIN_TOO_DEEP') { setDeepChainDepth(structured.details?.depth ?? 3) return } const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number } error.body = result error.status = res.status throw error } setDeepChainDepth(null) const correctedId = result.data?.corrected?.id toast({ title: 'Verifikationen flyttad', description: 'En storno och en rättelse med rätt datum har bokförts.', action: correctedId ? ( ) : undefined, }) onOpenChange(false) onMoved() } catch (err) { const anyErr = err as { body?: unknown; status?: number } toast({ title: 'Kunde inte flytta verifikationen', description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }), variant: 'destructive', }) } finally { setIsSubmitting(false) } } return ( {/* Convention 7: the how-it-works copy lives behind the "?", not in the dialog flow. */}
Rätta datum

Raderna behålls oförändrade: en stornoverifikation nollställer originalet i sin period, och en ny verifikation bokförs med samma rader på det nya datumet.

Spårbarheten ligger i stornokedjan: originalet, stornoverifikationen och den nya verifikationen förblir synliga i bokföringen och länkade till varandra.

{/* Original */}
{formatVoucher(entry)} {formatDate(entry.entry_date)} Original

{entry.description}

{/* New date */}
setNewDate(e.target.value)} className="tabular-nums" /> {/* Target period feedback */} {dateChanged && (
{previewLoading && Kontrollerar period…} {!previewLoading && previewError && ( {previewError} )} {!previewLoading && !previewError && targetOpen && ( Flyttas till {preview?.period_name ?? 'rätt räkenskapsår'} )} {!previewLoading && noCoveringPeriod && ( Det finns ingen räkenskapsperiod som täcker datumet. Skapa eller öppna räkenskapsåret först. )} {!previewLoading && preview?.status === 'closed' && ( Räkenskapsåret är stängt (bokslut) och kan inte återöppnas. Bokför rättelsen i innevarande period istället. )} {!previewLoading && preview?.status === 'locked' && ( Perioden är låst{preview?.lock_date ? ` t.o.m. ${formatDate(preview.lock_date)}` : ''}. Lås upp perioden för att flytta verifikationen dit. )}
)} {/* Soft advisory: moving into a past period */} {targetOpen && movingIntoPast && (

Om momsen för perioden redan är inlämnad kan du behöva lämna en rättad momsdeklaration.

)}
{/* Chain-depth guard confirm: the server refused because this entry already sits deep in a rättelse chain. Advisory, never a dead end: "Flytta ändå" resubmits with allow_deep_chain=true. */} { if (!next) setDeepChainDepth(null) }}> {t('deep_chain_title')}

{t('deep_chain_body', { depth: deepChainDepth ?? 3 })}

) }