Files
accounted/components/bookkeeping/NewJournalEntryDialog.tsx
T
Jakob Wennberg e5fba9471e feat(bookkeeping): verifikationsserie dropdown, wider Ny verifikat modal, balancing amount on focus (#2120)
Three fixes to the Ny verifikation modal.

1. Verifikationsserie is a closed dropdown, not a one-letter free-text
   field. Ships Fortnox's table verbatim from their Systemdokumentation
   (A Redovisning, B Kundfakturor, C Inbetalningar från kunder,
   D Leverantörsfakturor, E Utbetalningar till leverantörer, F Kassa,
   G Avskrivning, H Periodisering, I Bokslut, J Revisor, K Lön,
   L Kontantfaktura, M Momsrapport). A is deliberately Redovisning, not
   Kundfakturor: it is the general series manual entries land in, and
   migration 20260526120700 ships every source_type defaulting to 'A', so
   every existing company's A series already holds everything. Any letter
   the company already configured is appended so no existing value can
   fall out of the picker.

2. The modal goes from sm:max-w-3xl to sm:max-w-5xl lg:max-w-6xl.

3. Tabbing or clicking into an untouched amount proposes the outstanding
   difference, pre-selected so typing replaces it. Only fires when the row
   has an account, both amounts are empty, and the difference belongs on
   that side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DnoyZCbfm2geRbwtUpi6XN
2026-09-01 20:05:36 +02:00

133 lines
5.0 KiB
TypeScript

'use client'
import { useTranslations } from 'next-intl'
import { Copy, Link2, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
export interface CopyPrefill {
sourceId: string
sourceVoucherLabel: string
lines: FormLine[]
description: string
notes: string
}
/** Prefill for the skattekonto "Skapa verifikat manuellt" deep link. */
export interface SkvLinkPrefill {
transactionId: string
/** Row summary for the banner: date, text and amount, built by the caller. */
bannerLabel: string
lines: FormLine[]
description: string
date: string
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
/** Fired after a verifikat is created/saved as draft. */
onCreated: () => void
/** Fired with the created entry's id (both posted and draft saves). */
onEntryCreated?: (entryId: string) => void
/** When set, the form is pre-filled from a copied verifikat. */
copyPrefill?: CopyPrefill | null
/** When set, the form is pre-filled from a skattekonto row and the caller
* links the created entry back to it. copyPrefill wins if both are set. */
skvPrefill?: SkvLinkPrefill | null
/** True while the copy source is being fetched. */
isLoading?: boolean
}
/**
* "Ny verifikat" as a modal: the dialog you type a manual voucher into,
* instead of an inline tab. Wraps the standalone JournalEntryForm; the form's
* own review/confirm dialogs stack on top of this one.
*/
export default function NewJournalEntryDialog({
open,
onOpenChange,
onCreated,
onEntryCreated,
copyPrefill,
skvPrefill,
isLoading,
}: Props) {
const t = useTranslations('bookkeeping')
const activeSkvPrefill = copyPrefill ? null : (skvPrefill ?? null)
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
// Wide: the konteringsrader table (konto, radtext, debet, kredit,
// saldo) is the point of this dialog, and at 3xl the amount columns
// were squeezed against the account search. Capped at 6xl so it stays
// a dialog on an ultrawide screen.
className="sm:max-w-5xl lg:max-w-6xl max-h-[95dvh] sm:max-h-[92vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental backdrop click or a
// stray Escape (easy to hit across multiple windows/screens, or when you
// only meant to dismiss a combobox dropdown). Closing is explicit: the
// header X. This also stops nested popovers (AccountCombobox, date
// pickers) and the form's own confirm dialogs from collapsing the parent
// when they portal outside it.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">{t('loading_source_voucher')}</span>
</div>
) : (
<>
{copyPrefill && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<Copy className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">
{t('copy_banner_title', {
label: copyPrefill.sourceVoucherLabel || t('copy_banner_unknown_label'),
})}
</p>
<p className="text-muted-foreground mt-0.5">{t('copy_banner_body')}</p>
</div>
</div>
)}
{activeSkvPrefill && (
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-3 text-sm">
<Link2 className="h-4 w-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="flex-1">
<p className="font-medium">
{t('skv_link_banner_title', { label: activeSkvPrefill.bannerLabel })}
</p>
<p className="text-muted-foreground mt-0.5">{t('skv_link_banner_body')}</p>
</div>
</div>
)}
<JournalEntryForm
key={copyPrefill?.sourceId ?? activeSkvPrefill?.transactionId ?? 'fresh'}
bare
onCreated={onCreated}
onEntryCreated={onEntryCreated}
initialLines={copyPrefill?.lines ?? activeSkvPrefill?.lines}
initialDate={activeSkvPrefill?.date}
initialDescription={copyPrefill?.description ?? activeSkvPrefill?.description}
initialNotes={copyPrefill?.notes}
/>
</>
)}
</DialogContent>
</Dialog>
)
}