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:
co-authored by
Claude Opus 4.8
parent
2d6ddeafc5
commit
241959513b
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user