From aa72a75dfce8bce9c99c53c3febeb836163eec65 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:37:37 +0200 Subject: [PATCH] feat(bookkeeping): concept toolbar, template booking, confirm-first posting (UI migration PR 4) (#1123) * feat(bookkeeping): concept toolbar, template booking, confirm-first posting (UI migration PR 4) The Bokforing page adopts the concept (scene 9) on top of the PR 3 kit: - Toolbar in concept order with the FyPicker chip far right replacing the "Visar:" scope selector (same persisted scope, one-click change) - "Nytt verifikat" is a SplitButton with three remembered modes: Tomt verifikat (existing editor, voucher-number hint kept), Bokfor fran mall (new centered TemplateBookDialog: existing booking_template_library data MRU-ordered, date + editable amount recomputing the kontering live via applyTemplate, Balanserar row, direct booking + MRU touch), and Skapa med assistenten (existing agent-sheet path; suggestion lands in Granskning). Last-used mode persists via ui_state.create_mode - Draft posting goes through ConfirmDialog describing the outcome ("Bokfors som verifikat A-218: ...") with an indicative next-voucher preview; the success toast still shows the real number - "Underlag saknas" becomes the row's only warning chip (Badge warning) instead of the bare triangle icon; exempt rows keep the muted glyph - New lib/hooks/use-ui-state.ts: client read of ui_state to seed the split button's initial mode No backend, migration or RPC changes. VAT-split math is applyTemplate, already unit-tested in lib/bookkeeping/__tests__/template-library.test.ts; split-button persistence is tested in lib/ui-state. Co-Authored-By: Claude Fable 5 * fix(bookkeeping): use roundOre in TemplateBookDialog money math The antipattern ratchet caught two hand-rolled Math.round(x*100)/100; route them through lib/money roundOre like the rest of the codebase. Co-Authored-By: Claude Fable 5 * docs: PR 4 decisions Co-Authored-By: Claude Fable 5 * feat(bookkeeping): concept dry-table verifikat list (scene 9) The list itself adopts the concept, verified against the artifact's scene 9 markup: a borderless table (Verifikation / Datum / Beskrivning / Belopp) with hover-revealed selection checkboxes, hover-revealed chevron, and an animated grid-rows row expansion whose kontering renders as the concept's vlines sub-table (uppercase hairline heads, Summa row). Expansion actions become quiet underlined links (Visa detaljer, Skapa andringsverifikation, Aterfor (storno), Kopiera); posting keeps its pill + ConfirmDialog. Drafts get a row-level Bokfor button like the concept. All functionality preserved: batch "Inget underlag kravs" bar (above the table), attachment counts + preview, no-doc-required toggle, out-of- period + status badges, FX line amounts, sum footer, pagination. The density toggle is dropped: the table has one density by design. Fixes from verification: the list's i18n lives in the journal_list namespace (new keys moved there; they rendered as raw keys otherwise), and the sidebar brand Image gets explicit dimensions (Next dev warning). Co-Authored-By: Claude Fable 5 * fix(bookkeeping): bulkbar appears only when a verifikat is selected Concept behavior: no standing "Markera alla (62) / Markera alla utan underlag" bar. The batch bar is hidden until the first row is selected via its hover checkbox, then pops in with the count, the reason input, Undanta underlagskrav, and quiet actions for Markera alla, the filter-scoped bulk mark, and Avmarkera. All batch functionality kept, just no chrome until it is needed. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + app/(dashboard)/bookkeeping/page.tsx | 90 +- components/bookkeeping/JournalEntryList.tsx | 917 ++++++++---------- components/bookkeeping/TemplateBookDialog.tsx | 315 ++++++ components/dashboard/DashboardNav.tsx | 2 +- lib/hooks/use-ui-state.ts | 27 + messages/en.json | 30 +- messages/sv.json | 30 +- 8 files changed, 878 insertions(+), 534 deletions(-) create mode 100644 components/bookkeeping/TemplateBookDialog.tsx create mode 100644 lib/hooks/use-ui-state.ts diff --git a/DECISIONS.md b/DECISIONS.md index 53756d16..8e351ecd 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -339,3 +339,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-07-23] Frame layout (UI-migration PR 1) is md:-gated and the panel owns desktop scroll: mobile keeps document flow + bottom nav (concept is desktop-first), and since Next's window scroll-to-top never fires for an inner scroll container, MainContainer resets panel scroll on route change. Button default size drops fixed h-10 for natural pill height (7px/16px padding per locked convention 3); sm/lg/icon keep their heights. [2026-07-23] Nav PR 2: ui_state persisted as one jsonb bag on user_preferences (founder-approved migration 20260723120000) rather than per-preference columns: cosmetic, never load-bearing, grows with PR 3/4 split-button modes. Sidebar width driven by an inline --nav-w CSS variable on #dash-shell instead of a [data-nav-collapsed] attribute rule: the Tailwind 4/Lightning CSS pipeline silently dropped the top-level attribute-selector rule from compiled output, and the inline variable is pipeline-proof. Register/Bokslut folds default closed (concept tidiness), forced open by an active child route. Discord-community row skipped: no invite URL exists in the repo; add when one lands. [2026-07-23] PR 3 primitives: new --attn token pair (38 50% 34% light / 38 45% 62% dark) for the one-sentence AttnLine instead of reusing --warning: the warning tone fails WCAG AA as 12.5px body text on the page background; chips/charts keep --warning. Stagger applied via className on the five target pages' DataList/TableBody containers (plan item 7), not baked into the DataList primitive: remaining pages adopt it in their own migration PRs where their skeletons are aligned at the same time. +[2026-07-23] PR 4 template booking books directly through the normal journal-entries endpoint without the editor's account-activation retry flow: template accounts are standard BAS accounts that are active in practice, and an inactive-account error surfaces as a normal error toast pointing at the full editor. Voucher preview in the commit ConfirmDialog is indicative (voucher-sequences/next for today's period/default series); the atomic number assigned at commit is what the success toast shows. diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index 8fb5762e..76d3a61e 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -4,14 +4,16 @@ import { useState, useEffect, useMemo } from 'react' import dynamic from 'next/dynamic' import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' -import { Button } from '@/components/ui/button' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' import type { CopyPrefill } from '@/components/bookkeeping/NewJournalEntryDialog' import { DialogLoadingSkeleton } from '@/components/ui/dialog-loading-skeleton' -import AgentSparkleButton from '@/components/agent/AgentSparkleButton' +import { SplitButton } from '@/components/ui/split-button' +import { useAgentSheet } from '@/components/agent/AgentSheetProvider' +import { useUiState } from '@/lib/hooks/use-ui-state' +import { resolveInitialMode } from '@/lib/ui-state/client' import { useToast } from '@/components/ui/use-toast' -import { Plus } from 'lucide-react' +import { Plus, LayoutTemplate, Sparkles } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver' import type { JournalEntry, JournalEntryLine } from '@/types' @@ -20,6 +22,14 @@ const NewJournalEntryDialog = dynamic( () => import('@/components/bookkeeping/NewJournalEntryDialog'), { loading: DialogLoadingSkeleton }, ) +const TemplateBookDialog = dynamic( + () => import('@/components/bookkeeping/TemplateBookDialog'), + { loading: DialogLoadingSkeleton }, +) + +// SplitButton modes for "Nytt verifikat" (concept scene 9). The last-used +// mode persists per user in ui_state.create_mode.bookkeeping. +const CREATE_MODES = ['tomt', 'mall', 'assistent'] as const interface NextVoucher { next: number @@ -39,10 +49,13 @@ export default function BookkeepingPage() { const [refreshKey, setRefreshKey] = useState(0) const [showNewEntry, setShowNewEntry] = useState(false) + const [showTemplateDialog, setShowTemplateDialog] = useState(false) const [copyPrefill, setCopyPrefill] = useState(null) const [isLoadingCopy, setIsLoadingCopy] = useState(false) const [nextVoucher, setNextVoucher] = useState(null) const t = useTranslations('bookkeeping') + const { openAgentSheet } = useAgentSheet() + const { uiState, loaded: uiStateLoaded } = useUiState() // React to copy_from in URL: switch tab, fetch source entry, then clean URL. // useSearchParams keeps this reactive even when navigation happens within the @@ -131,35 +144,58 @@ export default function BookkeepingPage() { - - - + { + setCopyPrefill(null) + setShowNewEntry(true) + }, + }, + { + key: 'mall', + label: t('create_mall'), + icon: LayoutTemplate, + description: t('create_mall_desc'), + onSelect: () => setShowTemplateDialog(true), + }, + { + key: 'assistent', + label: t('create_with_assistant'), + icon: Sparkles, + description: t('create_assistent_desc'), + onSelect: () => + openAgentSheet({ + intentId: 'verifikation.draft', + contextRef: 'verifikation:new', + }), + }, + ]} + /> } /> + {showTemplateDialog && ( + setRefreshKey((k) => k + 1)} + /> + )} + {showNewEntry && ( (['date_desc', 'date_asc', 'voucher_asc', 'vo // Compact row density (support feedback: "kompakt visning av verifikat"). // Persisted per company, mirroring the sort key convention. -const DENSITY_STORAGE_KEY_PREFIX = 'Accounted:journal-density:' // Page-size selector. Persisted per company, mirroring the sort key convention. // 'all' fetches everything in the current scope (capped server-side at MAX_LIMIT); @@ -81,6 +78,35 @@ const PAGE_SIZE_VALUES = new Set(['20', '50', '100', 'all']) // Sentinel limit sent for "Alla". The route clamps this to its own MAX_LIMIT. const ALL_PAGE_SIZE = 100000 +// Concept table styles (scene 9 "dry-table"): borderless table on the +// panel, uppercase hairline heads, 13px rows. +const TH_CLASS = + 'px-4 py-2.5 text-left text-[11px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border whitespace-nowrap' +const TD_CLASS = 'px-4 py-[11px] border-b border-border align-top' +const VTH_CLASS = + 'py-2 pr-4 text-left text-[10.5px] font-medium uppercase tracking-[0.07em] text-muted-foreground border-b border-border' +const VTD_CLASS = 'py-[7px] pr-4 border-b border-border/60 align-top' +const QUIET_LINK_CLASS = + 'text-[12.5px] text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground' + +// Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on +// mount; the global reduced-motion rule collapses the transition. +function RowFoldout({ children }: { children: React.ReactNode }) { + const [open, setOpen] = useState(false) + useEffect(() => { + const raf = requestAnimationFrame(() => setOpen(true)) + return () => cancelAnimationFrame(raf) + }, []) + return ( +
+
{children}
+
+ ) +} + export default function JournalEntryList() { const router = useRouter() const { toast } = useToast() @@ -89,6 +115,10 @@ export default function JournalEntryList() { const t = useTranslations('journal_list') const [entries, setEntries] = useState([]) const [committingId, setCommittingId] = useState(null) + // Confirm-before-posting (convention 10): the draft the user is about to + // commit, plus the predicted voucher label ("A-218") for the dialog copy. + const [commitTarget, setCommitTarget] = useState(null) + const [commitVoucherPreview, setCommitVoucherPreview] = useState(null) const [loading, setLoading] = useState(true) const [expandedId, setExpandedId] = useState(null) const [count, setCount] = useState(0) @@ -128,7 +158,6 @@ export default function JournalEntryList() { const [draftCount, setDraftCount] = useState(0) const [pageSizeChoice, setPageSizeChoice] = useState('20') const [pageSizeHydrated, setPageSizeHydrated] = useState(false) - const [compact, setCompact] = useState(false) const showingAll = pageSizeChoice === 'all' const pageSize = showingAll ? ALL_PAGE_SIZE : Number(pageSizeChoice) @@ -235,25 +264,6 @@ export default function JournalEntryList() { setSortHydrated(true) }, [company?.id]) - // Restore the persisted row density (per company). Purely visual, so it - // doesn't gate the first fetch; the effect-read avoids an SSR mismatch. - useEffect(() => { - if (typeof window !== 'undefined') { - const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY_PREFIX + (company?.id ?? 'default')) - setCompact(stored === 'compact') - } - }, [company?.id]) - - const toggleDensity = () => { - const next = !compact - setCompact(next) - if (typeof window !== 'undefined') { - window.localStorage.setItem( - DENSITY_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), - next ? 'compact' : 'comfortable', - ) - } - } // Restore the persisted page-size choice (per company). Same hydration pattern // as the sort order, read in an effect to avoid an SSR mismatch, and gate the @@ -421,6 +431,21 @@ export default function JournalEntryList() { if (mode === 'drafts') setShowMissingOnly(false) } + // Open the confirm dialog and fetch the predicted voucher number. The + // prediction is indicative (numbers are assigned atomically at commit, + // and a draft in another period/series may land elsewhere); the success + // toast always shows the real one. + const openCommitConfirm = (entry: JournalEntry) => { + setCommitTarget(entry) + setCommitVoucherPreview(null) + fetch('/api/bookkeeping/voucher-sequences/next') + .then((r) => r.json()) + .then(({ data }) => { + if (data?.next != null) setCommitVoucherPreview(`${data.series}${data.next}`) + }) + .catch(() => {}) + } + const handleCommit = async (entryId: string) => { setCommittingId(entryId) try { @@ -901,33 +926,14 @@ export default function JournalEntryList() { - {/* Row density: comfortable vs compact, persisted per company. A toggle - (aria-pressed) rather than two modes buried in the filter dialog. */} - - {/* Active fiscal-year scope as a direct one-click picker, pushed to the - right of the control bar, keeps the räkenskapsår visible per BFL and - changeable in one click. Distinct from Filtrera (which now holds only - sort / series / date / underlag); previously both just opened the same - dialog. The selector persists the choice to localStorage; the first-load - scope resolution still happens authoritatively in the period effect. */} + {/* The page context picker (convention 8): fiscal-year scope as a + chip-dropdown far right in the toolbar, one click to change, + always visible per BFL. Persists to localStorage (same key as + before); first-load scope resolution still happens + authoritatively in the period effect. */} {periodHydrated && ( -
- {t('scope_label')} - +
+
)}
@@ -967,469 +973,343 @@ export default function JournalEntryList() { /> ) : ( - - {/* Batch-mark "Inget underlag krävs": select-all + contextual action bar, - rendered as the list header so it reads as part of the ledger rather - than a detached box above it. */} - {(eligibleEntries.length > 0 || selectedIds.size > 0) && ( - -
- - -
- {selectedIds.size > 0 ? ( -
- setBatchReason(e.target.value)} - placeholder={t('no_doc_required_reason_placeholder')} - list="batch-no-doc-suggestions" - maxLength={200} - className="h-8 text-xs sm:w-56" - disabled={batchSubmitting} - /> - - -
- - -
-
- ) : ( - // Filter-scoped: mark every missing-doc verifikat matching the active - // filters across all pages: scales to a post-import flood. - +
+ {/* Bulkbar (concept): hidden until at least one verifikat is + selected via the hover checkboxes, then it pops in with the + count and the batch actions. Select-all and the filter-scoped + bulk mark live inside it as quiet actions. */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size}{' '} + {t('bulkbar_selected')} + + setBatchReason(e.target.value)} + placeholder={t('no_doc_required_reason_placeholder')} + list="batch-no-doc-suggestions" + maxLength={200} + className="h-8 w-56 text-xs" + disabled={batchSubmitting} + /> + + + + {!allEligibleSelected && ( + )} - - )} - {filteredEntries.map((entry) => { - const isExpanded = expandedId === entry.id - const lines = (entry.lines || []) as JournalEntryLine[] - // Voucher total = sum of the debit side (= credit side when balanced). - const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) - const selectable = canWrite && isEligibleForExempt(entry) - - return ( - toggleExpand(entry.id)} - leading={ - selectable ? ( -
e.stopPropagation()}> - toggleSelect(entry.id)} - aria-label={t('batch_select_row')} - /> -
- ) : undefined - } - expandedContent={ - isExpanded ? ( - <> - {lines.length === 0 ? ( -

{t('no_lines')}

- ) : ( -
- - - - {t('account_column')} - {t('description_column')} - {t('debit')} - {t('credit')} - - - - {lines - .slice() - .sort((a, b) => a.sort_order - b.sort_order) - .map((line) => { - const accountName = getAccountDescription(line.account_number)?.name - const desc = line.line_description - const showDesc = desc - && desc.toLowerCase() !== accountName?.toLowerCase() - && desc.toLowerCase() !== entry.description?.toLowerCase() - const debit = Number(line.debit_amount) || 0 - const credit = Number(line.credit_amount) || 0 - const fx = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null - ? `${Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} ${line.currency}` - : null - return ( - - - - - - {showDesc ? desc : ''} - - - {debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} - {debit > 0 && fx && ( - {fx} - )} - - - {credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} - {credit > 0 && fx && ( - {fx} - )} - - - ) - })} - - - - {t('sum_label')} - - {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} - - - {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} - - - -
-
- )} - - {entry.notes && ( -

- {entry.notes} -

- )} - - handleAttachmentCountChange(entry.id, c)} - /> - - {entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && ( - { - setNoDocRequired((prev) => { - const next = new Map(prev) - if (exempted) next.set(entry.id, reason ?? null) - else next.delete(entry.id) - return next - }) - }} - /> - )} - -
- {entry.status === 'draft' && ( - - )} - - {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( - - )} - {canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( - - )} - -
- - ) : undefined - } + {/* Filter-scoped: mark every missing-doc verifikat matching the + active filters across all pages: scales to a post-import flood. */} + +
+ )} + +
+ + + + + + + + + + + + + {filteredEntries.map((entry) => { + const isExpanded = expandedId === entry.id + const lines = (entry.lines || []) as JournalEntryLine[] + // Voucher total = sum of the debit side (= credit side when balanced). + const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0) + const selectable = canWrite && isEligibleForExempt(entry) + + return ( + + { - e.preventDefault() - e.stopPropagation() - router.push(`/bookkeeping?copy_from=${entry.id}`) - }} + aria-expanded={isExpanded} + onClick={() => toggleExpand(entry.id)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() - e.stopPropagation() - router.push(`/bookkeeping?copy_from=${entry.id}`) + toggleExpand(entry.id) } }} > - - - - {/* Fixed-width attachment slot keeps the copy icon in a stable - column and the right edge aligned whether a row has a - paperclip, a warning, or nothing. */} - - {attachmentCounts[entry.id] ? ( - + + + + + + {isExpanded && ( + + + )} - - -
-

{entry.description}

- - {formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })} - -
- - - ) - })} - + + ) + })} + +
{t('th_voucher')}{t('th_date')}{t('th_description')}{t('th_amount')}
+ e.stopPropagation()} > - - - {attachmentCounts[entry.id]} - + {formatVoucher(entry)} + + + {formatDate(entry.entry_date)} + + + {entry.description} + {entry.out_of_period && ( + + {t('out_of_period_label')} + + )} + {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( + + )} - - ) : ( - NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( - noDocRequired.has(entry.id) ? ( - - - - ) : ( - - - - ) - ) - )} - - - - {/* Mobile: two rows */} -
-
- {isExpanded ? ( - - ) : ( - - )} - e.stopPropagation()} - > - {formatVoucher(entry)} - - - {formatDate(entry.entry_date)} - - {entry.out_of_period && ( - - {t('out_of_period_label')} - - )} - {(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && ( - - )} - - - {attachmentCounts[entry.id] ? ( -
+ {formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })} + + + {attachmentCounts[entry.id] ? ( + + ) : ( + NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( + noDocRequired.has(entry.id) ? ( + + + + ) : ( + + {t('missing_attachment_chip')} + + ) + ) + )} + {entry.status === 'draft' && ( + + )} + - - ) : ( - NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && ( - noDocRequired.has(entry.id) ? ( - - - - ) : ( - - - - ) - ) +
+ +
+ {lines.length === 0 ? ( +

{t('no_lines')}

+ ) : ( + + + + + + + + + + + {lines + .slice() + .sort((a, b) => a.sort_order - b.sort_order) + .map((line) => { + const accountName = getAccountDescription(line.account_number)?.name + const desc = line.line_description + const showDesc = desc + && desc.toLowerCase() !== accountName?.toLowerCase() + && desc.toLowerCase() !== entry.description?.toLowerCase() + const debit = Number(line.debit_amount) || 0 + const credit = Number(line.credit_amount) || 0 + const fx = line.currency && line.currency !== 'SEK' && line.amount_in_currency != null + ? `${Number(line.amount_in_currency).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} ${line.currency}` + : null + return ( + + + + + + + ) + })} + + + + + + +
{t('account_column')}{t('description_column')}{t('debit')}{t('credit')}
+ + + {showDesc ? desc : ''} + + {debit > 0 ? debit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} + {debit > 0 && fx && ( + {fx} + )} + + {credit > 0 ? credit.toLocaleString('sv-SE', { minimumFractionDigits: 2 }) : ''} + {credit > 0 && fx && ( + {fx} + )} +
{t('sum_label')} + {lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} + + {lines.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} +
+ )} + + {entry.notes && ( +

+ {entry.notes} +

+ )} + + handleAttachmentCountChange(entry.id, c)} + /> + + {entry.status === 'posted' && NEEDS_ATTACHMENT.has(entry.source_type) && ( + { + setNoDocRequired((prev) => { + const next = new Map(prev) + if (exempted) next.set(entry.id, reason ?? null) + else next.delete(entry.id) + return next + }) + }} + /> + )} + + {/* Quiet link actions (concept vact); posting keeps + its pill because it changes legal state. */} +
+ {entry.status === 'draft' && ( + + )} + + {t('show_details')} + + {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( + + )} + {canWrite && entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( + + )} + +
+
+
+
+
+
)} {/* Filter-scoped bulk "Inget underlag krävs" confirmation */} @@ -1503,6 +1383,39 @@ export default function JournalEntryList() { /> )} + {/* Confirm-before-posting for drafts (convention 10): describes the + outcome ("Bokförs som A-218 ...") before the commit runs. */} + {commitTarget && ( + { + if (!open && committingId === null) setCommitTarget(null) + }} + title={t('confirm_post_title')} + description={ + commitVoucherPreview + ? t('confirm_post_description', { + voucher: commitVoucherPreview, + description: commitTarget.description || '', + amount: formatCurrency( + ((commitTarget.lines || []) as JournalEntryLine[]).reduce( + (sum, l) => sum + (Number(l.debit_amount) || 0), + 0, + ), + ), + }) + : t('confirm_post_description_generic', { + description: commitTarget.description || '', + }) + } + confirmLabel={t('post')} + onConfirm={async () => { + await handleCommit(commitTarget.id) + setCommitTarget(null) + }} + /> + )} + {/* Reverse (storno) confirmation dialog */} {reverseEntryTarget && ( void + /** Fired after a verifikat is booked from a template. */ + onCreated: () => void +} + +/** Sum a side of the computed lines in öre-safe steps. */ +function sumSide(lines: FormLine[], side: 'debit_amount' | 'credit_amount'): number { + return lines.reduce((acc, l) => roundOre(acc + (parseFloat(l[side]) || 0)), 0) +} + +/** + * "Bokför från mall" (UI-migration plan PR 4, scene 9): a centered modal + * with the template list (existing booking_template_library data, MRU + * ordering from the API), then date + editable amount that recomputes the + * kontering live via applyTemplate, a "Balanserar" row, and direct booking + * (user action, so no Granskning detour). + */ +export default function TemplateBookDialog({ open, onOpenChange, onCreated }: Props) { + const t = useTranslations('bookkeeping') + const { toast } = useToast() + + const [templates, setTemplates] = useState(null) + const [periods, setPeriods] = useState([]) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const [entryDate, setEntryDate] = useState(() => new Date().toISOString().split('T')[0]) + const [amountInput, setAmountInput] = useState('') + const [submitting, setSubmitting] = useState(false) + + // Load templates + fiscal periods when the dialog opens. + useEffect(() => { + if (!open) return + let cancelled = false + ;(async () => { + const [tplRes, periodRes] = await Promise.all([ + fetch('/api/settings/booking-templates'), + fetch('/api/bookkeeping/fiscal-periods'), + ]) + if (cancelled) return + if (tplRes.ok) { + const { data } = await tplRes.json() + if (!cancelled) setTemplates(data ?? []) + } else { + setTemplates([]) + } + if (periodRes.ok) { + const { data } = await periodRes.json() + if (!cancelled) setPeriods(data ?? []) + } + })() + return () => { + cancelled = true + } + }, [open]) + + // Reset per open so yesterday's half-typed amount never leaks into today. + useEffect(() => { + if (open) return + setSelected(null) + setSearch('') + setAmountInput('') + setEntryDate(new Date().toISOString().split('T')[0]) + }, [open]) + + const amount = useMemo(() => { + const parsed = parseFloat(amountInput.replace(/\s/g, '').replace(',', '.')) + return Number.isFinite(parsed) && parsed > 0 ? roundOre(parsed) : 0 + }, [amountInput]) + + // The live kontering: recomputed from the template's line pattern on + // every amount change (momssplit etc. handled by applyTemplate). + const lines = useMemo( + () => (selected && amount > 0 ? applyTemplate(selected.lines, amount) : []), + [selected, amount], + ) + const totalDebit = sumSide(lines, 'debit_amount') + const totalCredit = sumSide(lines, 'credit_amount') + const balanced = lines.length >= 2 && totalDebit === totalCredit && totalDebit > 0 + + const filteredTemplates = (templates ?? []).filter((tpl) => + tpl.name.toLowerCase().includes(search.trim().toLowerCase()), + ) + + const periodForDate = periods.find( + (p) => p.period_start <= entryDate && entryDate <= p.period_end, + ) + + const handleBook = async () => { + if (!selected || !balanced) return + if (!periodForDate) { + toast({ title: t('tpl_no_period'), variant: 'destructive' }) + return + } + setSubmitting(true) + try { + const res = await fetch('/api/bookkeeping/journal-entries', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + fiscal_period_id: periodForDate.id, + entry_date: entryDate, + description: selected.name, + lines: lines.map((l) => ({ + account_number: l.account_number, + debit_amount: parseFloat(l.debit_amount) || 0, + credit_amount: parseFloat(l.credit_amount) || 0, + line_description: l.line_description || undefined, + })), + }), + }) + const result = await res.json() + if (!res.ok) { + toast({ + title: t('toast_post_failed'), + description: getErrorMessage(result, { context: 'journal_entry' }), + variant: 'destructive', + }) + return + } + // MRU ordering for the next open; fire-and-forget. + void fetch(`/api/settings/booking-templates/${selected.id}/touch`, { + method: 'POST', + }).catch(() => {}) + toast({ + title: t('toast_posted_title'), + description: t('toast_posted_description', { + voucher: formatVoucher(result.data ?? {}), + }), + }) + onOpenChange(false) + onCreated() + } catch { + toast({ title: t('toast_post_failed_generic'), variant: 'destructive' }) + } finally { + setSubmitting(false) + } + } + + return ( + !submitting && onOpenChange(next)}> + + + + {selected ? selected.name : t('tpl_dialog_title')} + + + + {!selected ? ( + <> +
+ + setSearch(e.target.value)} + placeholder={t('tpl_search_placeholder')} + className="w-full bg-transparent text-[13px] text-foreground placeholder:text-muted-foreground/60 focus:outline-none" + autoFocus + /> +
+
+ {templates === null ? ( +
+ +
+ ) : filteredTemplates.length === 0 ? ( +

+ {t('tpl_empty')} +

+ ) : ( + filteredTemplates.map((tpl) => ( + + )) + )} +
+ + ) : ( +
+ + +
+
+ + setEntryDate(e.target.value)} + /> +
+
+ + setAmountInput(e.target.value)} + autoFocus + className="tabular-nums" + /> +
+
+ + {/* Live kontering preview */} +
+ {lines.length === 0 ? ( +

+ {t('tpl_enter_amount')} +

+ ) : ( + <> + {lines.map((l, i) => ( +
+ + {l.account_number} + + {l.line_description} + + {l.debit_amount ? formatCurrency(parseFloat(l.debit_amount)) : ''} + + + {l.credit_amount ? formatCurrency(parseFloat(l.credit_amount)) : ''} + +
+ ))} +
+ + {balanced && } + {balanced ? t('tpl_balances') : t('tpl_not_balancing')} + + + {formatCurrency(totalDebit)} / {formatCurrency(totalCredit)} + +
+ + )} +
+ +
+ + +
+
+ )} +
+
+ ) +} diff --git a/components/dashboard/DashboardNav.tsx b/components/dashboard/DashboardNav.tsx index d54c7590..cc909dad 100644 --- a/components/dashboard/DashboardNav.tsx +++ b/components/dashboard/DashboardNav.tsx @@ -701,7 +701,7 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa alt="" width={26} height={26} - className="rounded-md" + className="h-[26px] w-[26px] rounded-md" />