Fix/mcp and req (#753)

* feat(api): test-mode API keys force dry-run on the v1 REST API

A key created with mode='test' (prefix gnubok_sk_test_) binds to the real
company, but the v1 wrapper forces dry_run on every write so nothing is
persisted or sent. Mutations on endpoints that can't be simulated
(dryRunSupported=false or unregistered) are refused with 403
TEST_KEY_WRITE_BLOCKED — fail-closed. Reads pass through unchanged and every
test-key response carries X-Gnubok-Mode: test. Live keys are unaffected
(mode defaults to 'live').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): company default "Vår referens" + per-line sales-account override

Add company_settings.default_our_reference (settings form, schema, type); the
invoice editor pre-fills our_reference from it on new invoices only, never
overwriting an edited draft. Separately, add an optional per-line
försäljningskonto (class-3) override in the editor — left blank, the engine
still derives the revenue account from the VAT rate, and reverse-charge/export
lines ignore the override.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(invoices): render a Swish payment QR on invoice PDFs

Build the Swish "Type C" QR payload offline (no Swish API call) and embed it as
a PNG in the invoice PDF payment box when Swish display is enabled, the invoice
is in SEK, and the amount is positive. Also surface the invoice number in the
payment box. Wired through every PDF render path: send, mark-sent and pdf
routes (both legacy and v1), the recurring-schedule sender, and the staged-send
commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): draft exclusion + correction-chain collapse on verifikationslista

Extend list_fiscal_period_entries_with_related with two opt-in params:
p_exclude_draft (keep drafts off the committed list — they get their own
surface) and p_collapse_corrections (render a correction group as the single
live correction, hiding the mechanical storno and the reversed original).
Both default false; nothing is deleted, every voucher keeps its number, and a
"show all" toggle exposes the full chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(reports): link multi-year SIE periods so resultatrapport shows the prior year

SIE import now sets fiscal_periods.previous_period_id in both directions when
creating a period, so multi-year files chain correctly regardless of #RAR order.
A backfill migration repairs periods imported before this (idempotent; only
touches NULL links on first-of-month periods). generateResultatrapport falls
back to the date-adjacent prior period when the chain is still null, so the
comparison column works for legacy data too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(articles): hide the VAT field for non-momsregistrerade companies

The article form reads company_settings.vat_registered and, when false, hides
the moms field and forces vat_rate to 0 on submit — mirroring the invoice
editor so a non-VAT-registered company never sets a rate it can't charge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(import): allow file-based imports in the sandbox

Bank-file, CSV/Excel and SIE imports run entirely on uploaded data with no
external service, so they're now reachable in the sandbox. Only the API-backed
options that need live third-party credentials (PSD2 bank connection, provider
migration) stay disabled. Updates the sandbox notice copy to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(bookkeeping): add edit draft functionality for journal entries

* feat(database): add default "Vår referens" column to company_settings for invoicing

* fix(tests): set SHOW_SWISH_ON_INVOICE to false in PDF template mocks

* @
fix(payments): use roundOre for Swish amount formatting

Replace naive Math.round(x*100)/100 with roundOre from @/lib/money to
satisfy the antipattern guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-06-18 11:49:33 +02:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2d6ddeafc5
commit 241959513b
54 changed files with 1634 additions and 120 deletions
+31 -3
View File
@@ -13,6 +13,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { ChevronDown, Loader2, Lock } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany } from '@/contexts/CompanyContext'
import { createClient } from '@/lib/supabase/client'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
import type { BASAccount, CreateArticleInput } from '@/types'
@@ -36,6 +38,8 @@ export default function ArticleForm({
initialData,
}: ArticleFormProps) {
const { canWrite } = useCanWrite()
const { company } = useCompany()
const supabase = createClient()
const t = useTranslations('form_article')
// Active class-3 (revenue) accounts for the combobox. The combobox accepts
// unknown 4-digit numbers optimistically — the API answers with
@@ -45,6 +49,9 @@ export default function ArticleForm({
// Inline account creation: what the user typed in the combobox when they hit
// "Skapa konto" — non-null opens AddAccountDialog prefilled with it.
const [createAccountPrefill, setCreateAccountPrefill] = useState<string | null>(null)
// Momsregistrerad? A non-VAT-registered company never charges moms, so the
// VAT field is hidden and the rate forced to 0 — mirrors the invoice editor.
const [vatRegistered, setVatRegistered] = useState(true)
async function fetchRevenueAccounts() {
try {
@@ -59,6 +66,25 @@ export default function ArticleForm({
useEffect(() => {
fetchRevenueAccounts()
}, [])
useEffect(() => {
if (!company?.id) return
let cancelled = false
supabase
.from('company_settings')
.select('vat_registered')
.eq('company_id', company.id)
.single()
.then(({ data }) => {
if (!cancelled && typeof data?.vat_registered === 'boolean') {
setVatRegistered(data.vat_registered)
}
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [company?.id])
// Open the advanced section by default when it already holds data, so an
// edit never hides a value the user previously set.
const [advancedOpen, setAdvancedOpen] = useState(
@@ -125,7 +151,7 @@ export default function ArticleForm({
type: data.type,
unit: data.unit,
price_excl_vat: data.price_excl_vat,
vat_rate: data.vat_rate,
vat_rate: vatRegistered ? data.vat_rate : 0,
revenue_account: data.revenue_account || null,
cost_price: data.cost_price ?? null,
ean: data.ean || null,
@@ -180,8 +206,8 @@ export default function ArticleForm({
<p className="text-xs text-muted-foreground">{t('name_en_hint')}</p>
</div>
{/* Unit + price + VAT */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* Unit + price + VAT (moms hidden for non-momsregistrerade) */}
<div className={`grid grid-cols-1 gap-4 ${vatRegistered ? 'sm:grid-cols-3' : 'sm:grid-cols-2'}`}>
<div className="space-y-2">
<Label>{t('unit_label')}</Label>
<Controller
@@ -215,6 +241,7 @@ export default function ArticleForm({
<p className="text-sm text-destructive">{errors.price_excl_vat.message}</p>
)}
</div>
{vatRegistered && (
<div className="space-y-2">
<Label>{t('vat_rate_label')}</Label>
<Controller
@@ -237,6 +264,7 @@ export default function ArticleForm({
)}
/>
</div>
)}
</div>
{/* Advanced (collapsible) */}
@@ -0,0 +1,66 @@
'use client'
import { useTranslations } from 'next-intl'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import JournalEntryForm, { type FormLine } from '@/components/bookkeeping/JournalEntryForm'
import type { JournalEntry, JournalEntryLine } from '@/types'
interface Props {
entry: JournalEntry
open: boolean
onOpenChange: (open: boolean) => void
/** Fired after the draft is successfully updated. */
onUpdated: () => void
}
/**
* Edit a DRAFT verifikat. Wraps JournalEntryForm in edit mode, pre-filled from
* the draft's header + lines; the form PATCHes the entry in place and it stays
* a draft (the user posts it separately). Only ever opened for status==='draft'
* entries — the engine + DB triggers reject edits on committed entries anyway.
*/
export default function EditDraftEntryDialog({ entry, open, onOpenChange, onUpdated }: Props) {
const t = useTranslations('bookkeeping')
const initialLines: FormLine[] = ((entry.lines || []) as JournalEntryLine[])
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((l) => ({
account_number: l.account_number,
debit_amount: Number(l.debit_amount) > 0 ? String(l.debit_amount) : '',
credit_amount: Number(l.credit_amount) > 0 ? String(l.credit_amount) : '',
line_description: l.line_description || '',
}))
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// Same guard as Ny verifikat: an accidental outside-click must not
// discard in-progress edits.
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{t('edit_draft_dialog_title')}</DialogTitle>
</DialogHeader>
<JournalEntryForm
key={entry.id}
bare
editEntryId={entry.id}
initialLines={initialLines}
initialDate={entry.entry_date}
initialDescription={entry.description}
initialNotes={entry.notes ?? undefined}
initialVoucherSeries={entry.voucher_series}
onUpdated={onUpdated}
/>
</DialogContent>
</Dialog>
)
}
+92 -32
View File
@@ -59,6 +59,7 @@ interface Props {
initialDate?: string
initialDescription?: string
initialNotes?: string
initialVoucherSeries?: string
sourceType?: JournalEntrySourceType
sourceId?: string
submitUrl?: string
@@ -66,6 +67,11 @@ interface Props {
/** Render without the Card chrome (e.g. inside a dialog) but keep the full
* non-embedded field set (series, notes, documents, voucher hint). */
bare?: boolean
/** Edit an existing DRAFT in place: the form PATCHes this entry instead of
* creating a new one. Only the draft's header + lines are updated. */
editEntryId?: string
/** Fired after a successful draft edit (editEntryId path). */
onUpdated?: () => void
}
const BLANK_LINE: FormLine = { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }
@@ -77,11 +83,14 @@ export default function JournalEntryForm({
initialDate,
initialDescription,
initialNotes,
initialVoucherSeries,
sourceType,
sourceId,
submitUrl,
embedded,
bare,
editEntryId,
onUpdated,
}: Props) {
const { canWrite } = useCanWrite()
const { toast } = useToast()
@@ -97,7 +106,7 @@ export default function JournalEntryForm({
const [lines, setLines] = useState<FormLine[]>(
initialLines ?? [{ ...BLANK_LINE }, { ...BLANK_LINE }]
)
const [voucherSeries, setVoucherSeries] = useState('A')
const [voucherSeries, setVoucherSeries] = useState(initialVoucherSeries ?? 'A')
const [nextVoucherNumber, setNextVoucherNumber] = useState<number | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
@@ -170,7 +179,9 @@ export default function JournalEntryForm({
// Fetch default voucher series from company settings — prefer the
// per-source-type mapping when present; fall back to the legacy
// default_voucher_series, then to 'A'.
if (!embedded) {
// In edit mode the draft's own series is pre-filled — never override it
// from the company defaults.
if (!embedded && !editEntryId) {
fetch('/api/settings').then(r => r.json()).then(({ data }) => {
if (!data) return
const effectiveSourceType = sourceType ?? 'manual'
@@ -182,7 +193,7 @@ export default function JournalEntryForm({
setVoucherSeries(perSource !== 'A' ? perSource : fallback)
}).catch(() => {/* keep 'A' */})
}
}, [embedded, sourceType])
}, [embedded, sourceType, editEntryId])
// Auto-select period when entry date changes
useEffect(() => {
@@ -539,9 +550,15 @@ export default function JournalEntryForm({
})
const baseUrl = submitUrl ?? '/api/bookkeeping/journal-entries'
const url = saveAsDraftRef.current ? `${baseUrl}?as_draft=true` : baseUrl
// Edit mode PATCHes the draft in place; create mode POSTs (with ?as_draft
// when saving a draft rather than posting).
const url = editEntryId
? `${baseUrl}/${editEntryId}`
: saveAsDraftRef.current
? `${baseUrl}?as_draft=true`
: baseUrl
const res = await fetch(url, {
method: 'POST',
method: editEntryId ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fiscal_period_id: selectedPeriod,
@@ -555,7 +572,7 @@ export default function JournalEntryForm({
}),
})
return (await throwOnStructuredError(res)) as { data?: { id?: string; voucher_series?: string; voucher_number?: number }; journal_entry_id?: string }
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes])
}, [lines, isForeign, rate, entryCurrency, computedForeignAmount, submitUrl, editEntryId, selectedPeriod, entryDate, description, sourceType, sourceId, voucherSeries, notes])
const { runSubmit, dialog: activationDialog, confirm: confirmActivation, cancel: cancelActivation } =
useSubmitWithAccountActivation(postJournalEntry)
@@ -678,6 +695,35 @@ export default function JournalEntryForm({
}
}
// Edit an existing draft: PATCH in place (postJournalEntry routes to the
// editEntryId URL) and keep it a draft. No field reset — the host dialog
// closes on success via onUpdated.
const handleSaveEdit = async () => {
if (!selectedPeriod || !description || !isBalanced || periodMismatch) return
setIsSavingDraft(true)
try {
await runSubmit()
toast({
title: t('toast_updated_title'),
description: t('toast_updated_description'),
})
onUpdated?.()
} catch (err) {
if (err instanceof Error && err.message === 'cancelled') {
// Activation dialog dismissed — silent
} else {
const anyErr = err as { body?: unknown; status?: number }
toast({
title: t('toast_update_failed'),
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
variant: 'destructive',
})
}
} finally {
setIsSavingDraft(false)
}
}
// Inline review for the modal (bare): swap the form body to a read-only
// summary instead of stacking a second dialog over the form dialog. The
// no-underlag caveat folds in here so there's a single confirm step.
@@ -1175,8 +1221,9 @@ export default function JournalEntryForm({
<p className="mt-1.5 text-xs text-muted-foreground">{t('fill_balance_hint')}</p>
</div>
{/* Document attachments */}
{!embedded && (
{/* Document attachments — hidden when editing a draft; underlag is
managed from the verifikat detail page (JournalEntryAttachments). */}
{!embedded && !editEntryId && (
<div>
<Label className="mb-2 block">{t('attachments_label')}</Label>
<DocumentUploadZone
@@ -1194,34 +1241,47 @@ export default function JournalEntryForm({
<div className="flex flex-col items-end gap-1">
<div className="flex gap-2">
{!embedded && (
{editEntryId ? (
<Button
variant="ghost"
onClick={() => setShowClearConfirm(true)}
disabled={!hasContent || isSubmitting || isSavingDraft}
title={t('clear_all_tooltip')}
onClick={handleSaveEdit}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
<Eraser className="mr-2 h-4 w-4" />
{t('clear_all')}
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save_edit')}
</Button>
) : (
<>
{!embedded && (
<Button
variant="ghost"
onClick={() => setShowClearConfirm(true)}
disabled={!hasContent || isSubmitting || isSavingDraft}
title={t('clear_all_tooltip')}
>
<Eraser className="mr-2 h-4 w-4" />
{t('clear_all')}
</Button>
)}
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save_draft')}
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
{t('review_and_create')}
</Button>
</>
)}
<Button
variant="outline"
onClick={handleSaveDraft}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : t('save_draft_tooltip')}
>
{!canWrite ? <Lock className="mr-2 h-4 w-4" /> : isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save_draft')}
</Button>
<Button
onClick={handleReview}
disabled={!isBalanced || !description || !selectedPeriod || !!periodMismatch || isSubmitting || isSavingDraft || isUploading || !canWrite}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4" />}
{t('review_and_create')}
</Button>
</div>
{(!description || !selectedPeriod || isUploading || periodMismatch || incompleteLineCount > 0 || (!isBalanced && submittableLines.length < 2)) && (
<div className="text-xs text-muted-foreground space-y-0.5 text-right">
+83 -6
View File
@@ -106,6 +106,13 @@ export default function JournalEntryList() {
const [seriesFilter, setSeriesFilter] = useState<string>('all')
const [searchInput, setSearchInput] = useState('')
const [search, setSearch] = useState('')
// Verifikat (committed) vs Utkast (drafts) view. Drafts are excluded from the
// committed list server-side and surfaced here behind a count badge.
const [listMode, setListMode] = useState<'committed' | 'drafts'>('committed')
// Collapse correction groups to the live correction (hide storno + reversed
// original). Toggled off via the filter dialog to reveal the full chain.
const [collapseCorrections, setCollapseCorrections] = useState(true)
const [draftCount, setDraftCount] = useState(0)
const [pageSizeChoice, setPageSizeChoice] = useState<PageSizeChoice>('20')
const [pageSizeHydrated, setPageSizeHydrated] = useState(false)
const showingAll = pageSizeChoice === 'all'
@@ -269,10 +276,18 @@ export default function JournalEntryList() {
offset: String(page * pageSize),
sort_by: sortBy,
})
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (seriesFilter !== 'all') params.set('series', seriesFilter)
if (listMode === 'drafts') {
// Drafts get their own view spanning all years — they're work-in-progress
// and shouldn't be hidden by the selected fiscal-year scope.
params.set('status', 'draft')
} else {
params.set('exclude_draft', 'true')
if (collapseCorrections) params.set('collapse_corrections', 'true')
if (periodId) params.set('period_id', periodId)
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (seriesFilter !== 'all') params.set('series', seriesFilter)
}
if (search) params.set('search', search)
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
@@ -289,12 +304,27 @@ export default function JournalEntryList() {
// Fetch attachment counts for the loaded entries
const ids = loadedEntries.map((e: JournalEntry) => e.id)
fetchAttachmentCounts(ids)
fetchDraftCount()
}
// Cheap count-only query for the "Utkast" badge — all years, so the badge
// surfaces drafts regardless of the selected fiscal-year scope.
async function fetchDraftCount() {
try {
const res = await fetch('/api/bookkeeping/journal-entries?status=draft&limit=1')
if (!res.ok) return
const { count: total } = await res.json()
setDraftCount(total || 0)
} catch {
// Non-fatal: the badge keeps its last value.
}
}
useEffect(() => {
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
fetchEntries()
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, sortHydrated, periodHydrated, pageSizeHydrated])
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
@@ -304,6 +334,14 @@ export default function JournalEntryList() {
setExpandedId(expandedId === id ? null : id)
}
function switchMode(mode: 'committed' | 'drafts') {
if (mode === listMode) return
setListMode(mode)
setPage(0)
setSelectedIds(new Set())
if (mode === 'drafts') setShowMissingOnly(false)
}
const handleCommit = async (entryId: string) => {
setCommittingId(entryId)
try {
@@ -714,6 +752,19 @@ export default function JournalEntryList() {
</Badge>
)}
</div>
{/* Reveal the storno + reversed-original rows the default view folds
into the surviving correction (3 rows → 1). */}
<div className="flex items-center gap-2">
<Switch
id="show-correction-chain"
checked={!collapseCorrections}
onCheckedChange={(on) => { setCollapseCorrections(!on); setPage(0) }}
/>
<Label htmlFor="show-correction-chain" className="text-sm cursor-pointer">
{t('show_correction_chain')}
</Label>
</div>
</div>
<DialogFooter className="sm:justify-between">
@@ -733,10 +784,36 @@ export default function JournalEntryList() {
</Dialog>
</div>
{/* Verifikat vs Utkast. Drafts live in their own view with a count badge so
they don't sink to the last page of the committed list. */}
<div className="flex items-center gap-2">
<div className="inline-flex rounded-md border border-border p-0.5">
<button
type="button"
onClick={() => switchMode('committed')}
className={`h-7 rounded px-3 text-xs font-medium transition-colors ${listMode === 'committed' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{t('mode_vouchers')}
</button>
<button
type="button"
onClick={() => switchMode('drafts')}
className={`inline-flex h-7 items-center gap-1.5 rounded px-3 text-xs font-medium transition-colors ${listMode === 'drafts' ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
{t('mode_drafts')}
{draftCount > 0 && (
<Badge variant="secondary" className="h-4 min-w-4 justify-center px-1 text-[10px] tabular-nums">
{draftCount}
</Badge>
)}
</button>
</div>
</div>
{/* Active fiscal-year scope — visible without opening the filter dialog so
the user always sees which räkenskapsår the ledger is scoped to (BFL
period-correctness). Clicking it opens the dialog to change the scope. */}
{periodHydrated && scopeLabel && (
{listMode === 'committed' && periodHydrated && scopeLabel && (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>{t('scope_label')}</span>
<button
@@ -45,7 +45,16 @@ export default function NewJournalEntryDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// A half-typed verifikat must survive an accidental click on the
// backdrop (easy to do across multiple windows/screens). Closing is
// explicit — the header X or Cancel. This also stops nested popovers
// (AccountCombobox, date pickers) and the form's own confirm dialogs
// from collapsing the parent when they portal outside it.
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{t('new_entry_dialog_title')}</DialogTitle>
</DialogHeader>
+79 -2
View File
@@ -54,9 +54,10 @@ import {
computeDeduction,
} from '@/lib/invoices/rot-rut-rules'
import AccrualPeriodControl from '@/components/bookkeeping/AccrualPeriodControl'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { DEFAULT_DEFERRED_REVENUE_ACCOUNT } from '@/lib/bookkeeping/accruals/account-suggestions'
import { countCalendarMonths } from '@/lib/bookkeeping/accruals/compute'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem } from '@/types'
import type { Customer, Currency, CreateInvoiceInput, CreateCustomerInput, InvoiceDocumentType, Article, Invoice, InvoiceItem, BASAccount } from '@/types'
const currencies: Currency[] = ['SEK', 'EUR', 'USD', 'GBP', 'NOK', 'DKK']
const units = ['st', 'tim', 'dag', 'månad', 'km', 'kg']
@@ -212,6 +213,10 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
// Artikelregister: active articles for the line picker + which line is mid quick-create.
const [articles, setArticles] = useState<ArticleOption[]>([])
const [savingArticleIndex, setSavingArticleIndex] = useState<number | null>(null)
// Class-3 (revenue) accounts for the optional per-line försäljningskonto
// override, plus which rows currently show that picker.
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
const [accountOverrideRows, setAccountOverrideRows] = useState<Set<number>>(new Set())
// True only when the user had zero invoices when this page loaded. The
// post-create flow uses this to offer a one-shot "upload a logo?" prompt
// — issue #520. Self-limits: once count > 0 it stays false.
@@ -349,6 +354,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
fetchCustomers()
fetchDefaultNotes()
fetchArticles()
fetchRevenueAccounts()
}, [company?.id])
async function fetchArticles() {
@@ -362,6 +368,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setArticles((data ?? []) as ArticleOption[])
}
async function fetchRevenueAccounts() {
if (!company?.id) return
try {
const res = await fetch('/api/bookkeeping/accounts?class=3')
const body = await res.json()
setRevenueAccounts((body?.data as BASAccount[]) || [])
} catch {
// Non-fatal: the override picker degrades to free 4-digit entry.
}
}
// Apply a chosen article's defaults onto a line. Selecting "none" detaches the
// article link (and its account override) but keeps the typed text/price so the
// row becomes an editable free-text line.
@@ -431,13 +448,18 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!company?.id) return
const { data } = await supabase
.from('company_settings')
.select('invoice_default_notes, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
.select('invoice_default_notes, default_our_reference, clearing_number, account_number, bankgiro, accounting_method, ore_rounding, logo_url, vat_registered')
.eq('company_id', company.id)
.single()
if (data?.invoice_default_notes) {
setDefaultNotes(data.invoice_default_notes)
setValue('notes', data.invoice_default_notes)
}
// Pre-fill "Vår referens" from the company default — only when creating a
// fresh invoice, so an edited draft's own reference is never overwritten.
if (!isEditMode && data?.default_our_reference) {
setValue('our_reference', data.default_our_reference)
}
setHasBankDetails(
!!(data?.clearing_number && data?.account_number) || !!data?.bankgiro
)
@@ -669,6 +691,22 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
}
}
// Open/close the optional per-line försäljningskonto override. Closing clears
// the value so the engine falls back to the VAT-rate-derived revenue account.
function toggleAccountOverride(index: number) {
const isOpen = accountOverrideRows.has(index) || !!watchItems[index]?.revenue_account
if (isOpen) {
setValue(`items.${index}.revenue_account`, null, { shouldDirty: true })
setAccountOverrideRows((prev) => {
const next = new Set(prev)
next.delete(index)
return next
})
} else {
setAccountOverrideRows((prev) => new Set(prev).add(index))
}
}
// Self-billing path: no review dialog, no PDF, no send — it arrives already
// booked. POST straight to the dedicated endpoint and open the verifikat.
async function handleSelfBilledSubmit(data: FormData) {
@@ -1283,6 +1321,17 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</DropdownMenuItem>
</>
)}
{watchItems[index]?.line_type !== 'text' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => toggleAccountOverride(index)} className="py-2">
<Landmark className="h-4 w-4" />
{(accountOverrideRows.has(index) || watchItems[index]?.revenue_account)
? t('row_menu_remove_account')
: t('row_menu_set_account')}
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="py-2 text-destructive focus:text-destructive"
@@ -1574,6 +1623,34 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
</div>
)}
{/* Optional försäljningskonto override (engångsartikel). When
unset the engine derives the revenue account from the VAT
rate; reverse-charge/export lines ignore the override. */}
{isInvoiceDoc && watchItems[index]?.line_type !== 'text' &&
(accountOverrideRows.has(index) || watchItems[index]?.revenue_account) && (
<div className="md:col-span-12 mt-2 md:mt-3">
<div className="flex flex-wrap items-end gap-2">
<div className="min-w-[220px] flex-1 space-y-1 md:space-y-2">
<Label className="text-xs text-muted-foreground md:text-sm md:text-foreground">
{t('revenue_account_label')}
</Label>
<Controller
name={`items.${index}.revenue_account`}
control={control}
render={({ field }) => (
<AccountCombobox
value={field.value ?? ''}
accounts={revenueAccounts}
onChange={(v) => field.onChange(v || null)}
/>
)}
/>
</div>
</div>
<p className="mt-1 text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
</div>
)}
{/* Mobile summary row */}
<div className="flex justify-between text-sm pt-1 border-t border-border/40 md:hidden">
<span className="text-muted-foreground">{t('row_label', { index: index + 1 })}</span>
+45 -1
View File
@@ -155,6 +155,7 @@ interface ApiKey {
name: string
scopes: string[] | null
rate_limit_rpm: number
mode?: 'live' | 'test'
last_used_at: string | null
revoked_at: string | null
created_at: string
@@ -252,6 +253,10 @@ export function ApiKeysPanel() {
const [showKeyDialog, setShowKeyDialog] = useState(false)
const [showApiKeyMethods, setShowApiKeyMethods] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
// 'live' by default: this is the general MCP-key surface and the dominant case
// is a key for the user's real company. 'test' is an explicit opt-in — a
// simulation-only key that forces dry-run on every write (nothing is saved).
const [newKeyMode, setNewKeyMode] = useState<'live' | 'test'>('live')
const [newKeyScopes, setNewKeyScopes] = useState<Set<Scope>>(new Set(ALL_SCOPES))
const [newKeyValue, setNewKeyValue] = useState('')
const [copied, setCopied] = useState(false)
@@ -305,13 +310,21 @@ export function ApiKeysPanel() {
body: JSON.stringify({
name: newKeyName || t('default_key_name'),
scopes: Array.from(newKeyScopes),
mode: newKeyMode,
...(hasSodConflict ? { acknowledge_sod: true } : {}),
}),
})
const json = await res.json()
if (!res.ok) {
toast({ title: json.error, variant: 'destructive' })
// The route returns the canonical { error: { code, message, message_en } }
// envelope — render the message string, never the object (a React child
// must be a string, not { code, message, ... }).
const message =
typeof json.error === 'string'
? json.error
: json.error?.message ?? t('toast_create_failed')
toast({ title: message, variant: 'destructive' })
return
}
@@ -319,6 +332,7 @@ export function ApiKeysPanel() {
setShowCreateDialog(false)
setShowKeyDialog(true)
setNewKeyName('')
setNewKeyMode('live')
setNewKeyScopes(new Set(ALL_SCOPES))
fetchKeys()
} catch {
@@ -413,6 +427,11 @@ export function ApiKeysPanel() {
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="text-sm font-medium truncate">{key.name}</p>
{key.mode === 'test' && (
<Badge variant="secondary" className="shrink-0 text-[10px] font-normal px-1.5 py-0">
{t('badge_test')}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{scopeCount === ALL_SCOPES.length
? t('all_permissions')
@@ -547,6 +566,31 @@ export function ApiKeysPanel() {
onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
/>
</div>
<div className="space-y-2">
<Label>{t('mode_label')}</Label>
<div className="inline-flex rounded-md border p-0.5" role="radiogroup" aria-label={t('mode_label')}>
{(['live', 'test'] as const).map((m) => (
<button
key={m}
type="button"
role="radio"
aria-checked={newKeyMode === m}
onClick={() => setNewKeyMode(m)}
className={cn(
'rounded-[5px] px-3 py-1.5 text-xs transition-colors',
newKeyMode === m
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{t(m === 'live' ? 'mode_live' : 'mode_test')}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
{newKeyMode === 'test' ? t('mode_test_help') : t('mode_live_help')}
</p>
</div>
<div className="space-y-3">
<div className="flex items-baseline justify-between gap-3">
<div className="space-y-1">
@@ -63,6 +63,19 @@ export function InvoiceSettingsForm({ settings }: InvoiceSettingsFormProps) {
{t('default_notes_help')}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="default_our_reference">{t('default_our_reference_label')}</Label>
<Input
id="default_our_reference"
name="default_our_reference"
placeholder={t('default_our_reference_placeholder')}
defaultValue={settings.default_our_reference || ''}
/>
<p className="text-xs text-muted-foreground">
{t('default_our_reference_help')}
</p>
</div>
</section>
)
}
+9 -6
View File
@@ -4,6 +4,7 @@ import { useTranslations } from 'next-intl'
import { useState, useCallback } from 'react'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { useToast } from '@/components/ui/use-toast'
import type { CompanySettings } from '@/types'
@@ -112,15 +113,17 @@ export function PdfPrintSettings({ settings, onUpdate }: PdfPrintSettingsProps)
/>
</div>
<div className="flex items-center justify-between">
{/* Swish on invoices is "coming soon" — the toggle is disabled until the
payment-QR flow ships (gated by SHOW_SWISH_ON_INVOICE in pdf-template). */}
<div className="flex items-center justify-between opacity-60">
<div>
<Label>{t('show_swish_label')}</Label>
<div className="flex items-center gap-2">
<Label>{t('show_swish_label')}</Label>
<Badge variant="secondary" className="text-[10px]">{t('coming_soon')}</Badge>
</div>
<p className="text-xs text-muted-foreground">{t('show_swish_help')}</p>
</div>
<Switch
checked={settings.invoice_show_swish ?? false}
onCheckedChange={(v) => saveToggle('invoice_show_swish', v)}
/>
<Switch checked={false} disabled aria-label={t('show_swish_label')} />
</div>
<div className="flex items-center justify-between">
@@ -42,6 +42,7 @@ export function InvoicingSettingsContent() {
next_invoice_number: parseInt(formData.get('next_invoice_number') as string) || 1,
invoice_default_days: parseInt(formData.get('invoice_default_days') as string) || 30,
invoice_default_notes: (formData.get('invoice_default_notes') as string) || null,
default_our_reference: (formData.get('default_our_reference') as string) || null,
}
return {
updates,