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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* docs: PR 4 decisions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5b5ee8e429
commit
aa72a75dfc
@@ -339,3 +339,4 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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.
|
||||
|
||||
@@ -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<CopyPrefill | null>(null)
|
||||
const [isLoadingCopy, setIsLoadingCopy] = useState(false)
|
||||
const [nextVoucher, setNextVoucher] = useState<NextVoucher | null>(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() {
|
||||
<PageHeader
|
||||
title={t('title')}
|
||||
action={
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<Button
|
||||
className="w-full sm:w-auto"
|
||||
onClick={() => {
|
||||
setCopyPrefill(null)
|
||||
setShowNewEntry(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('tab_new_entry')}
|
||||
{nextVoucher && (
|
||||
<span className="ml-1 text-primary-foreground/70 tabular-nums">
|
||||
({nextVoucher.series}{nextVoucher.next})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<AgentSparkleButton
|
||||
intentId="verifikation.draft"
|
||||
contextRef="verifikation:new"
|
||||
label={t('create_with_assistant')}
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
/>
|
||||
</div>
|
||||
<SplitButton
|
||||
// Remount once ui_state loads so the primary face re-resolves
|
||||
// to the persisted last-used mode.
|
||||
key={uiStateLoaded ? 'loaded' : 'initial'}
|
||||
persistKey="bookkeeping"
|
||||
initialModeKey={resolveInitialMode(uiState, 'bookkeeping', CREATE_MODES, 'tomt')}
|
||||
options={[
|
||||
{
|
||||
key: 'tomt',
|
||||
label: nextVoucher
|
||||
? `${t('create_tomt')} (${nextVoucher.series}${nextVoucher.next})`
|
||||
: t('create_tomt'),
|
||||
icon: Plus,
|
||||
description: t('create_tomt_desc'),
|
||||
onSelect: () => {
|
||||
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',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<JournalEntryList key={refreshKey} />
|
||||
|
||||
{showTemplateDialog && (
|
||||
<TemplateBookDialog
|
||||
open
|
||||
onOpenChange={setShowTemplateDialog}
|
||||
onCreated={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showNewEntry && (
|
||||
<NewJournalEntryDialog
|
||||
open
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn, formatCurrency } from '@/lib/utils'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import {
|
||||
applyTemplate,
|
||||
getTemplateScope,
|
||||
SCOPE_LABELS,
|
||||
TEMPLATE_CATEGORY_LABELS,
|
||||
} from '@/lib/bookkeeping/template-library'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { ArrowLeft, Check, ChevronRight, Loader2, Search } from 'lucide-react'
|
||||
import type { BookingTemplateLibrary, FiscalPeriod } from '@/types'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => 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<BookingTemplateLibrary[] | null>(null)
|
||||
const [periods, setPeriods] = useState<FiscalPeriod[]>([])
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<BookingTemplateLibrary | null>(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<FormLine[]>(
|
||||
() => (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 (
|
||||
<Dialog open={open} onOpenChange={(next) => !submitting && onOpenChange(next)}>
|
||||
<DialogContent className="sm:min-w-[460px] sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="font-display text-lg tracking-tight">
|
||||
{selected ? selected.name : t('tpl_dialog_title')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{!selected ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 border-b border-border/60 pb-2">
|
||||
<Search className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
</div>
|
||||
<div className="-mx-2 max-h-80 overflow-y-auto px-1">
|
||||
{templates === null ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<p className="px-2.5 py-6 text-center text-[13px] text-muted-foreground">
|
||||
{t('tpl_empty')}
|
||||
</p>
|
||||
) : (
|
||||
filteredTemplates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => setSelected(tpl)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2.5 text-left transition-colors hover:bg-secondary/60"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-[13px] text-foreground">
|
||||
{tpl.name}
|
||||
</span>
|
||||
<span className="block truncate text-[11px] text-muted-foreground">
|
||||
{TEMPLATE_CATEGORY_LABELS[tpl.category] ?? tpl.category}
|
||||
{' · '}
|
||||
{SCOPE_LABELS[getTemplateScope(tpl)]}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronRight className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/60" />
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
{t('tpl_back')}
|
||||
</button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-date">{t('tpl_date_label')}</Label>
|
||||
<Input
|
||||
id="tpl-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tpl-amount">{t('tpl_amount_label')}</Label>
|
||||
<Input
|
||||
id="tpl-amount"
|
||||
inputMode="decimal"
|
||||
placeholder="0,00"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
autoFocus
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live kontering preview */}
|
||||
<div className="rounded-lg border border-border">
|
||||
{lines.length === 0 ? (
|
||||
<p className="px-3 py-4 text-center text-[13px] text-muted-foreground">
|
||||
{t('tpl_enter_amount')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{lines.map((l, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-3 border-b border-border/60 px-3 py-2 text-[13px] last:border-b-0"
|
||||
>
|
||||
<span className="w-12 font-mono text-muted-foreground">
|
||||
{l.account_number}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{l.line_description}</span>
|
||||
<span className="w-24 text-right tabular-nums">
|
||||
{l.debit_amount ? formatCurrency(parseFloat(l.debit_amount)) : ''}
|
||||
</span>
|
||||
<span className="w-24 text-right tabular-nums text-muted-foreground">
|
||||
{l.credit_amount ? formatCurrency(parseFloat(l.credit_amount)) : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 py-2 text-[12.5px]',
|
||||
balanced ? 'text-success' : 'text-destructive',
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{balanced && <Check className="h-3.5 w-3.5" />}
|
||||
{balanced ? t('tpl_balances') : t('tpl_not_balancing')}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatCurrency(totalDebit)} / {formatCurrency(totalCredit)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={submitting}>
|
||||
{t('tpl_cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void handleBook()} disabled={!balanced || submitting}>
|
||||
{submitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{t('tpl_book')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
</Link>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import useSWR from 'swr'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import type { UserUiState } from '@/types'
|
||||
|
||||
/**
|
||||
* Client-side read of user_preferences.ui_state (nav folds, split-button
|
||||
* last-used modes). RLS scopes the select to the caller's own row. Pages
|
||||
* use this to seed SplitButton's initial mode; writes go through
|
||||
* lib/ui-state/client (POST /api/user/ui-state).
|
||||
*
|
||||
* Errors (including a not-yet-migrated ui_state column) degrade to an
|
||||
* empty state: the UI falls back to its defaults.
|
||||
*/
|
||||
export function useUiState() {
|
||||
const { data } = useSWR<UserUiState>('user-ui-state', async () => {
|
||||
const supabase = createClient()
|
||||
const { data: row } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('ui_state')
|
||||
.maybeSingle()
|
||||
return (row?.ui_state as UserUiState) ?? {}
|
||||
})
|
||||
|
||||
return { uiState: data, loaded: data !== undefined }
|
||||
}
|
||||
+28
-2
@@ -3819,7 +3819,16 @@
|
||||
"bulk_mark_none": "No entries without documents match the filter.",
|
||||
"bulk_mark_body": "{count} entries without a supporting document match the current filter. They will be marked \"No supporting document required\". You can undo this per entry afterwards.",
|
||||
"bulk_mark_confirm": "Mark {count}",
|
||||
"bulk_cancel": "Cancel"
|
||||
"bulk_cancel": "Cancel",
|
||||
"th_voucher": "Voucher",
|
||||
"th_date": "Date",
|
||||
"th_description": "Description",
|
||||
"th_amount": "Amount",
|
||||
"missing_attachment_chip": "Missing document",
|
||||
"confirm_post_title": "Post voucher",
|
||||
"confirm_post_description": "Will be posted as voucher {voucher}: {description}, {amount}. A posted voucher cannot be edited, only corrected or reversed.",
|
||||
"confirm_post_description_generic": "The voucher \"{description}\" will be posted with the next available voucher number and can then only be corrected or reversed.",
|
||||
"bulkbar_selected": "selected"
|
||||
},
|
||||
"attachment_preview_sheet": {
|
||||
"title": "Attachments",
|
||||
@@ -4562,7 +4571,24 @@
|
||||
"copy_fetch_failed": "Could not fetch source voucher.",
|
||||
"copy_banner_title": "Copy of voucher {label}",
|
||||
"copy_banner_unknown_label": "(unknown number)",
|
||||
"copy_banner_body": "A new, standalone voucher will be created with its own voucher series and number. This is NOT a correction or reversal of the original: use \"Skapa ändringsverifikation\" if you want to correct the source voucher."
|
||||
"copy_banner_body": "A new, standalone voucher will be created with its own voucher series and number. This is NOT a correction or reversal of the original: use \"Skapa ändringsverifikation\" if you want to correct the source voucher.",
|
||||
"create_tomt": "Blank voucher",
|
||||
"create_tomt_desc": "Open the editor directly",
|
||||
"create_mall": "Book from template",
|
||||
"create_mall_desc": "Pick a template and amount",
|
||||
"create_assistent_desc": "The suggestion lands in Review",
|
||||
"tpl_dialog_title": "Book from template",
|
||||
"tpl_search_placeholder": "Search templates...",
|
||||
"tpl_empty": "No matching templates",
|
||||
"tpl_back": "Choose another template",
|
||||
"tpl_date_label": "Date",
|
||||
"tpl_amount_label": "Amount (incl. VAT)",
|
||||
"tpl_enter_amount": "Enter an amount to compute the entry",
|
||||
"tpl_balances": "Balances",
|
||||
"tpl_not_balancing": "Does not balance",
|
||||
"tpl_book": "Post",
|
||||
"tpl_cancel": "Cancel",
|
||||
"tpl_no_period": "No fiscal year covers the chosen date"
|
||||
},
|
||||
"articles": {
|
||||
"title": "Articles",
|
||||
|
||||
+28
-2
@@ -3819,7 +3819,16 @@
|
||||
"bulk_mark_none": "Inga verifikat utan underlag matchar filtret.",
|
||||
"bulk_mark_body": "{count} verifikat som saknar underlag matchar nuvarande filter. De markeras som \"Inget underlag krävs\". Du kan ångra per verifikat efteråt.",
|
||||
"bulk_mark_confirm": "Markera {count}",
|
||||
"bulk_cancel": "Avbryt"
|
||||
"bulk_cancel": "Avbryt",
|
||||
"th_voucher": "Verifikation",
|
||||
"th_date": "Datum",
|
||||
"th_description": "Beskrivning",
|
||||
"th_amount": "Belopp",
|
||||
"missing_attachment_chip": "Underlag saknas",
|
||||
"confirm_post_title": "Bokför verifikat",
|
||||
"confirm_post_description": "Bokförs som verifikat {voucher}: {description}, {amount}. Verifikatet kan inte ändras i efterhand, bara rättas eller stornas.",
|
||||
"confirm_post_description_generic": "Verifikatet \"{description}\" bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
|
||||
"bulkbar_selected": "markerade"
|
||||
},
|
||||
"attachment_preview_sheet": {
|
||||
"title": "Bilagor",
|
||||
@@ -4562,7 +4571,24 @@
|
||||
"copy_fetch_failed": "Källverifikatet kunde inte hämtas.",
|
||||
"copy_banner_title": "Kopia av verifikat {label}",
|
||||
"copy_banner_unknown_label": "(okänt nummer)",
|
||||
"copy_banner_body": "Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer. Detta är inte en rättelse eller storno av originalet: använd \"Skapa ändringsverifikation\" om du vill korrigera källverifikatet."
|
||||
"copy_banner_body": "Ett nytt, fristående verifikat skapas med egen verifikationsserie och nummer. Detta är inte en rättelse eller storno av originalet: använd \"Skapa ändringsverifikation\" om du vill korrigera källverifikatet.",
|
||||
"create_tomt": "Tomt verifikat",
|
||||
"create_tomt_desc": "Öppna editorn direkt",
|
||||
"create_mall": "Bokför från mall",
|
||||
"create_mall_desc": "Välj mall och belopp",
|
||||
"create_assistent_desc": "Förslaget landar i Granskning",
|
||||
"tpl_dialog_title": "Bokför från mall",
|
||||
"tpl_search_placeholder": "Sök mall...",
|
||||
"tpl_empty": "Inga mallar matchar",
|
||||
"tpl_back": "Välj en annan mall",
|
||||
"tpl_date_label": "Datum",
|
||||
"tpl_amount_label": "Belopp (inkl. moms)",
|
||||
"tpl_enter_amount": "Ange ett belopp så räknas konteringen fram",
|
||||
"tpl_balances": "Balanserar",
|
||||
"tpl_not_balancing": "Balanserar inte",
|
||||
"tpl_book": "Bokför",
|
||||
"tpl_cancel": "Avbryt",
|
||||
"tpl_no_period": "Det finns inget räkenskapsår som täcker det valda datumet"
|
||||
},
|
||||
"articles": {
|
||||
"title": "Artiklar",
|
||||
|
||||
Reference in New Issue
Block a user