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" />