'use client' import { useState, useEffect } from 'react' import { useRouter } from 'next/navigation' 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 { 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 [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) // 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() { 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 }), }) const result = await res.json() if (!res.ok) { const error = new Error('Failed to move entry') as Error & { body?: unknown; status?: number } error.body = result error.status = res.status throw error } 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 ( Rätta datum {/* Explanation */}

Flytta verifikationen till rätt datum

En bokförd verifikation kan inte ändras direkt. Raderna behålls oförändrade: istället skapas automatiskt:

  1. En stornoverifikation som nollställer originalet i sin period
  2. En ny verifikation med samma rader, bokförd på det nya datumet
{/* 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.

)}
) }