Add/pdf and templates (#705)
* fix(invoices): apply configured voucher series to payments + preview next voucher The booking engine resolves the series from default_voucher_series_per_source_type, but the global "Standardserie" dropdown wrote a separate field the engine ignored, and cash-method invoice payments (invoice_cash_payment) weren't exposed in settings — so configured series were silently dropped to "A". - Expose cash/private payment source types in the per-source-type form - Write the global default through to the map on save, keeping overrides - Resolve voucher-sequences/next by source_type (+date) to match the engine - Show the upcoming voucher (V2) in the payment dialog title - Share resolveInvoicePaymentSourceType so preview and booking can't drift Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(salary): keep AGI panel in sync with Skatteverket signing state The AGI panel mixed run-scoped generation state (agi_generated_at, agi_declarations) with period-scoped submission state (extension_data agi_submission_{period}), so the two could drift and present contradictory UI. Reconcile them: - Auto-detect a Mina Sidor BankID signature: while awaiting_signing, poll /agi/kvittenser on mount and on tab refocus so the panel flips to "signed" (hiding the signing actions) without a manual "Hamta kvittens" click. - Warn instead of offering to sign when the locked granskningsunderlag predates the run's latest AGI generation (draftIsStale) — avoids filing superseded figures. - Self-heal a stale "AGI-XML saknas" error once the run's AGI is (re)generated out-of-band (MCP/API/other tab). - Refetch the salary run on tab focus so agi_generated_at reflects out-of-band generation without a hard reload. - /agi/lasUpp now clears the cached agi_submission_{period} record, so unlocking drops the panel back to the pre-submission state instead of stranding it on a released "redo att signeras" draft. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat: Implement VAT registration handling and invoice item line types - Added VAT registration check in commitCreateInvoice to set VAT rate to 0% for non-VAT registered companies. - Updated invoice creation logic to reflect 'exempt' VAT treatment and adjusted related fields accordingly. - Introduced support for free-text and blank spacer rows in invoice items by adding a new line_type field. - Enhanced invoice and credit note handling to accommodate new line types. - Added new localized messages for text rows in English and Swedish. - Created tests for salary run approval logic, ensuring bank details are validated correctly. - Implemented effective net payout calculation for salary runs, considering tax overrides. - Added SQL migrations to support new invoice item line types and accounting method awareness for linking invoices to vouchers. * feat(articles): artikelregister with revenue account + VAT rate per article Article register (non-inventory) with per-article VAT rate and optional BAS class-3 revenue-account override. Includes API routes, UI pages, MCP tools, pending-operation staging, and the activate-or-create account flow (ACCOUNTS_NOT_IN_CHART -> ActivateAccountsDialog, unknown numbers -> AddAccountDialog) reusing the journal entry UX. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): no-doc-required batch + bulk-missing endpoints Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(payments): supplier payment lines + cash-method invoice matching Shared payment-line proposal for supplier invoices, improved match-invoice/match-supplier-invoice flows (kontantmetoden-aware), and voucher-link support without requiring a 151x clearing entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): new journal entry dialog, SIE import tweaks, misc New journal entry dialog component, journal list/page updates, invoice editor updates, SIE import adjustments, transaction ingest and api-key tweaks, pr-agent workflow update. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(invoices): implement tax reduction features and localization updates * feat(tests): add VAT registration gate to pending operations commit tests --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
21cfcbe180
commit
f9ea9c0082
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm, Controller } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -13,7 +13,9 @@ 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 type { CreateArticleInput } from '@/types'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog'
|
||||
import type { BASAccount, CreateArticleInput } from '@/types'
|
||||
|
||||
// Unit list mirrors the invoice line editor (app/(dashboard)/invoices/new/page.tsx).
|
||||
const UNITS = ['st', 'tim', 'dag', 'månad', 'km', 'kg'] as const
|
||||
@@ -35,6 +37,28 @@ export default function ArticleForm({
|
||||
}: ArticleFormProps) {
|
||||
const { canWrite } = useCanWrite()
|
||||
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
|
||||
// ACCOUNTS_NOT_IN_CHART for activatable BAS accounts, and the host page's
|
||||
// ActivateAccountsDialog flow takes over (same UX as the journal entry form).
|
||||
const [revenueAccounts, setRevenueAccounts] = useState<BASAccount[]>([])
|
||||
// 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)
|
||||
|
||||
async function fetchRevenueAccounts() {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/accounts?class=3')
|
||||
const body = await res.json()
|
||||
setRevenueAccounts((body?.data as BASAccount[]) || [])
|
||||
} catch {
|
||||
// Non-fatal: the combobox degrades to free 4-digit entry.
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchRevenueAccounts()
|
||||
}, [])
|
||||
// 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(
|
||||
@@ -73,6 +97,7 @@ export default function ArticleForm({
|
||||
handleSubmit,
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
formState: { errors },
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -232,13 +257,18 @@ export default function ArticleForm({
|
||||
<div className="space-y-4 pt-4">
|
||||
{/* Revenue account */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="revenue_account">{t('revenue_account_label')}</Label>
|
||||
<Input
|
||||
id="revenue_account"
|
||||
inputMode="numeric"
|
||||
placeholder={t('revenue_account_placeholder')}
|
||||
className="tabular-nums"
|
||||
{...register('revenue_account')}
|
||||
<Label>{t('revenue_account_label')}</Label>
|
||||
<Controller
|
||||
name="revenue_account"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<AccountCombobox
|
||||
value={field.value || ''}
|
||||
accounts={revenueAccounts}
|
||||
onChange={field.onChange}
|
||||
onCreateAccount={(prefill) => setCreateAccountPrefill(prefill)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('revenue_account_hint')}</p>
|
||||
</div>
|
||||
@@ -332,6 +362,31 @@ export default function ArticleForm({
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Inline custom-account creation (renders in a portal, outside the form).
|
||||
After create: refresh the chart and select the new number as the
|
||||
article's revenue account — mirrors the journal entry form. */}
|
||||
<AddAccountDialog
|
||||
open={createAccountPrefill != null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setCreateAccountPrefill(null)
|
||||
}}
|
||||
initialAccountNumber={
|
||||
createAccountPrefill && /^\d{1,4}$/.test(createAccountPrefill)
|
||||
? createAccountPrefill
|
||||
: undefined
|
||||
}
|
||||
initialAccountName={
|
||||
createAccountPrefill && !/^\d{1,4}$/.test(createAccountPrefill)
|
||||
? createAccountPrefill
|
||||
: undefined
|
||||
}
|
||||
onCreated={async (account) => {
|
||||
await fetchRevenueAccounts()
|
||||
setValue('revenue_account', account.account_number, { shouldDirty: true })
|
||||
setCreateAccountPrefill(null)
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface ActivateAccountsDialogProps {
|
||||
// for a number that isn't in the BAS catalogue. The host should close this
|
||||
// dialog and open AddAccountDialog prefilled with the number.
|
||||
onCreateUnknown?: (accountNumber: string) => void
|
||||
// Confirm button label. Defaults to the bookkeeping wording; non-booking
|
||||
// hosts (e.g. the article register) pass their own.
|
||||
confirmLabel?: string
|
||||
}
|
||||
|
||||
interface BasLookupRow {
|
||||
@@ -35,6 +38,7 @@ export function ActivateAccountsDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onCreateUnknown,
|
||||
confirmLabel,
|
||||
}: ActivateAccountsDialogProps) {
|
||||
const [rows, setRows] = useState<BasLookupRow[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -149,7 +153,7 @@ export function ActivateAccountsDialog({
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Aktivera och bokför
|
||||
{confirmLabel ?? 'Aktivera och bokför'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -13,6 +13,8 @@ interface ReviewItem {
|
||||
unit: string
|
||||
unit_price: number
|
||||
vat_rate?: number
|
||||
/** 'text' rows are free-text/blank lines — description only, no amounts. */
|
||||
line_type?: 'product' | 'text'
|
||||
}
|
||||
|
||||
interface InvoiceReviewContentProps {
|
||||
@@ -62,9 +64,10 @@ export function InvoiceReviewContent({
|
||||
non_eu_business: t('customer_type_non_eu_business'),
|
||||
}
|
||||
|
||||
// Calculate per-rate VAT breakdown
|
||||
// Calculate per-rate VAT breakdown (free-text rows carry no amounts).
|
||||
const vatByRate = new Map<number, number>()
|
||||
for (const item of items) {
|
||||
if (item.line_type === 'text') continue
|
||||
const rate = item.vat_rate ?? 0
|
||||
const lineTotal = item.quantity * item.unit_price
|
||||
const lineVat = Math.round(lineTotal * rate / 100 * 100) / 100
|
||||
@@ -118,36 +121,48 @@ export function InvoiceReviewContent({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.map((item, index) =>
|
||||
item.line_type === 'text' ? (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="py-2 text-muted-foreground" colSpan={showVatColumn ? 6 : 5}>
|
||||
{item.description || ' '}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="py-2">{item.description}</td>
|
||||
<td className="py-2 text-right">{item.quantity}</td>
|
||||
<td className="py-2 text-center">{item.unit}</td>
|
||||
<td className="py-2 text-right">{formatCurrency(item.unit_price, currency)}</td>
|
||||
{showVatColumn && (
|
||||
<td className="py-2 text-right">{item.vat_rate ?? 0}%</td>
|
||||
)}
|
||||
<td className="py-2 text-right">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="sm:hidden space-y-2">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
|
||||
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
|
||||
{items.map((item, index) =>
|
||||
item.line_type === 'text' ? (
|
||||
<p key={index} className="text-sm text-muted-foreground px-1">{item.description || ' '}</p>
|
||||
) : (
|
||||
<div key={index} className="border rounded-lg p-3 text-sm space-y-1.5">
|
||||
<p className="font-medium">{item.description}</p>
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, currency)}</span>
|
||||
{showVatColumn && <span className="text-xs">{t('mobile_vat_suffix', { rate: item.vat_rate ?? 0 })}</span>}
|
||||
</div>
|
||||
<p className="text-right font-medium">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-right font-medium">
|
||||
{formatCurrency(item.quantity * item.unit_price, currency)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Totals */}
|
||||
|
||||
@@ -43,6 +43,13 @@ interface LinkVoucherPickerProps {
|
||||
onCancel: () => void
|
||||
/** Defaults to 'customer_invoice' for back-compat with existing call sites. */
|
||||
mode?: VoucherPickerMode
|
||||
/**
|
||||
* Company accounting method. On 'cash' (kontantmetoden) the matcher searches
|
||||
* bank/cash debits (19xx) instead of AR credits (1510), so the intro + empty
|
||||
* copy switch to describe that. Defaults to 'accrual'. Only affects the
|
||||
* customer-invoice mode's wording — the data path is decided server-side.
|
||||
*/
|
||||
accountingMethod?: 'accrual' | 'cash'
|
||||
}
|
||||
|
||||
function candidateAmount(c: VoucherCandidate): number {
|
||||
@@ -72,10 +79,17 @@ export default function LinkVoucherPicker({
|
||||
onLinked,
|
||||
onCancel,
|
||||
mode = 'customer_invoice',
|
||||
accountingMethod = 'accrual',
|
||||
}: LinkVoucherPickerProps) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('invoice_link_voucher')
|
||||
|
||||
// Kontantmetoden links against a bank/cash debit (19xx), not an AR credit —
|
||||
// describe that. Only the customer-invoice copy varies by method.
|
||||
const isCash = mode === 'customer_invoice' && accountingMethod === 'cash'
|
||||
const introKey = isCash ? 'intro_cash' : 'intro'
|
||||
const emptyDescriptionKey = isCash ? 'empty_description_cash' : 'empty_description'
|
||||
|
||||
const apiBase =
|
||||
mode === 'supplier_invoice'
|
||||
? `/api/supplier-invoices/${invoiceId}`
|
||||
@@ -173,7 +187,7 @@ export default function LinkVoucherPicker({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t('intro')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t(introKey)}</p>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -194,7 +208,7 @@ export default function LinkVoucherPicker({
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm font-medium">{t('empty_title')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t('empty_description')}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t(emptyDescriptionKey)}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2 max-h-[320px] overflow-y-auto">
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
|
||||
import { proposePaymentLines } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { proposePaymentLines, resolveInvoicePaymentSourceType } from '@/lib/bookkeeping/propose-payment-lines'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -44,6 +44,9 @@ interface DuplicateCandidate {
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
items: InvoiceItem[]
|
||||
// Present once an issuance verifikat has been booked (faktureringsmetoden);
|
||||
// absent on kontantmetoden invoices that recognise revenue at payment.
|
||||
journal_entry_id?: string | null
|
||||
}
|
||||
|
||||
interface PaymentBookingDialogProps {
|
||||
@@ -80,6 +83,14 @@ export default function PaymentBookingDialog({
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [duplicateCandidates, setDuplicateCandidates] = useState<DuplicateCandidate[] | null>(null)
|
||||
const [tab, setTab] = useState<'new' | 'existing'>('new')
|
||||
// Drives the "Befintlig verifikation" picker copy: cash links against a 19xx
|
||||
// debit, accrual against a 1510 credit.
|
||||
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
|
||||
// source_type the booking will use — drives the voucher-series preview so the
|
||||
// number shown matches what mark-paid will actually create.
|
||||
const [sourceType, setSourceType] =
|
||||
useState<'invoice_cash_payment' | 'invoice_paid' | null>(null)
|
||||
const [nextVoucher, setNextVoucher] = useState<{ series: string; next: number | null } | null>(null)
|
||||
|
||||
// Load accounts and settings when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -87,6 +98,8 @@ export default function PaymentBookingDialog({
|
||||
setIsInitialized(false)
|
||||
setDuplicateCandidates(null)
|
||||
setTab('new')
|
||||
setSourceType(null)
|
||||
setNextVoucher(null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -117,6 +130,15 @@ export default function PaymentBookingDialog({
|
||||
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
|
||||
setAccountingMethod(accountingMethod)
|
||||
|
||||
setSourceType(
|
||||
resolveInvoicePaymentSourceType({
|
||||
invoiceAlreadyBooked: !!invoice.journal_entry_id,
|
||||
accountingMethod,
|
||||
}),
|
||||
)
|
||||
|
||||
const proposed = proposePaymentLines({
|
||||
invoice: {
|
||||
invoice_number: invoice.invoice_number,
|
||||
@@ -153,6 +175,25 @@ export default function PaymentBookingDialog({
|
||||
return () => { cancelled = true }
|
||||
}, [open, invoice.id, company?.id])
|
||||
|
||||
// Voucher-series preview: resolve the upcoming serie + nummer the same way the
|
||||
// booking engine will, so a misconfigured series is visible before confirming.
|
||||
// Re-runs when the payment date changes (vouchers are numbered per period).
|
||||
useEffect(() => {
|
||||
if (!open || !sourceType) return
|
||||
let cancelled = false
|
||||
const qs = new URLSearchParams({ source_type: sourceType, date: paymentDate })
|
||||
fetch(`/api/bookkeeping/voucher-sequences/next?${qs}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => {
|
||||
if (cancelled || !json?.data) return
|
||||
setNextVoucher({ series: json.data.series, next: json.data.next })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setNextVoucher(null)
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [open, sourceType, paymentDate])
|
||||
|
||||
// Balance computation
|
||||
const { totalDebit, totalCredit, isBalanced } = useMemo(() => {
|
||||
let totalDebit = 0
|
||||
@@ -258,7 +299,14 @@ export default function PaymentBookingDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[680px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}</DialogTitle>
|
||||
<DialogTitle>
|
||||
{t('title')}{invoice.invoice_number ? t('title_suffix', { number: invoice.invoice_number }) : ''}
|
||||
{nextVoucher && (
|
||||
<span className="ml-1 text-muted-foreground tabular-nums">
|
||||
({nextVoucher.series}{nextVoucher.next})
|
||||
</span>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
{invoice.currency !== 'SEK' && invoice.total_sek && (
|
||||
@@ -328,6 +376,7 @@ export default function PaymentBookingDialog({
|
||||
<LinkVoucherPicker
|
||||
invoiceId={invoice.id}
|
||||
invoiceCurrency={invoice.currency}
|
||||
accountingMethod={accountingMethod}
|
||||
onLinked={() => {
|
||||
onOpenChange(false)
|
||||
onSuccess()
|
||||
@@ -440,7 +489,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateLine(index, 'debit_amount', e.target.value)}
|
||||
className="font-mono text-right h-8"
|
||||
className="font-mono text-right"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
@@ -449,7 +498,7 @@ export default function PaymentBookingDialog({
|
||||
placeholder="0,00"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateLine(index, 'credit_amount', e.target.value)}
|
||||
className="font-mono text-right h-8"
|
||||
className="font-mono text-right"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+130
-30
@@ -73,6 +73,8 @@ interface SubmissionState {
|
||||
inlamningId?: number
|
||||
tillstand?: string
|
||||
meddelande?: string
|
||||
/** ISO timestamp the submission record was last written by the extension. */
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
/** Subset of SkatteverketAGIKontrollresultat we use in the panel. */
|
||||
@@ -196,6 +198,19 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
return () => window.removeEventListener('message', handleMessage)
|
||||
}, [fetchStatus])
|
||||
|
||||
// Drop a stale "AGI-XML saknas" error once the run's AGI is (re)generated.
|
||||
// That error is set when "Skicka in underlag" runs before the XML exists; if
|
||||
// the file is then generated out-of-band (MCP, the download button, another
|
||||
// tab) the parent refreshes `agiGeneratedAt` and this clears the now-wrong
|
||||
// message without forcing a full reload — mirroring the session-expired
|
||||
// self-heal in fetchStatus above.
|
||||
useEffect(() => {
|
||||
if (!agiGeneratedAt) return
|
||||
setError(prev =>
|
||||
prev && /agi-xml saknas|inte genererats/i.test(prev) ? null : prev,
|
||||
)
|
||||
}, [agiGeneratedAt])
|
||||
|
||||
// Background kvittens-polling timers (see scheduleKvittensPolls below).
|
||||
// Held in a ref so the unmount-cleanup effect can cancel them if the
|
||||
// user leaves the page mid-signing.
|
||||
@@ -208,47 +223,95 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
|
||||
* receives a signing link. The kvittenser handler in the extension stamps
|
||||
* salary_runs.agi_submitted_at when it observes a uuidKvittens, so this
|
||||
* gives us a high-probability confirmation without depending on the user
|
||||
* returning to the panel and clicking "Hämta kvittens" — which is critical
|
||||
* for the audit trail (BFL 5 kap / BFNAR 2013:2): a NULL agi_submitted_at
|
||||
* after a real filing would misrepresent the behandlingshistorik.
|
||||
* Silently ask Skatteverket whether this period's granskningsunderlag has
|
||||
* been signed. The kvittenser handler stamps salary_runs.agi_submitted_at
|
||||
* and flips the local submission state to 'signed' the instant it sees a
|
||||
* uuidKvittens — so a positive result transitions the panel out of
|
||||
* awaiting_signing on its own (the action buttons then disappear via the
|
||||
* isSigned gate). Returns true iff a signed kvittens was observed. No-ops
|
||||
* (returns false) until we have the arbetsgivare id.
|
||||
*
|
||||
* Each poll silently refreshes local submission state on success and
|
||||
* stops scheduling further polls once a kvittens is observed.
|
||||
* Shared by the post-link background timers (scheduleKvittensPolls) and the
|
||||
* auto-detect effect that runs on mount / tab refocus.
|
||||
*/
|
||||
const checkKvittens = useCallback(async (): Promise<boolean> => {
|
||||
if (!arbetsgivare) return false
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
||||
)
|
||||
if (!res.ok) return false
|
||||
const json = await res.json()
|
||||
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
|
||||
await fetchSubmission()
|
||||
if (signed) {
|
||||
// Replace any lingering "Granskningsunderlag klart…" / stale error
|
||||
// with an unambiguous confirmation. Mirrors handleCheckSubmitted.
|
||||
setError(null)
|
||||
setSuccess('AGI har signerats och lämnats in.')
|
||||
onChange?.()
|
||||
}
|
||||
return signed
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, [arbetsgivare, period, fetchSubmission, onChange])
|
||||
|
||||
/**
|
||||
* Background-poll /agi/kvittenser at 30s, 2 min, and 5 min after the user
|
||||
* receives a signing link — a timer-based fallback to the focus-driven
|
||||
* auto-detect below. The kvittenser handler stamps salary_runs.agi_submitted_at
|
||||
* when it observes a uuidKvittens, critical for the audit trail (BFL 5 kap /
|
||||
* BFNAR 2013:2): a NULL agi_submitted_at after a real filing would
|
||||
* misrepresent the behandlingshistorik. Stops scheduling once observed.
|
||||
*/
|
||||
const scheduleKvittensPolls = useCallback(() => {
|
||||
for (const t of kvittensTimers.current) clearTimeout(t)
|
||||
kvittensTimers.current = []
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/extensions/ext/skatteverket/agi/kvittenser?arbetsgivare=${encodeURIComponent(arbetsgivare)}&period=${period}`,
|
||||
)
|
||||
if (!res.ok) return
|
||||
const json = await res.json()
|
||||
const signed = !!json.data?.kvittenser?.[0]?.uuidKvittens
|
||||
await fetchSubmission()
|
||||
if (signed) {
|
||||
// Cancel any remaining timers — the kvittens has been recorded
|
||||
// server-side and further polls are wasted requests.
|
||||
for (const t of kvittensTimers.current) clearTimeout(t)
|
||||
kvittensTimers.current = []
|
||||
onChange?.()
|
||||
}
|
||||
} catch {
|
||||
// Silent: this is a background helper. The "Hämta kvittens" button
|
||||
// remains the explicit recovery path.
|
||||
// checkKvittens is silent on failure — the "Hämta kvittens" button
|
||||
// remains the explicit recovery path.
|
||||
const signed = await checkKvittens()
|
||||
if (signed) {
|
||||
// Cancel any remaining timers — the kvittens has been recorded
|
||||
// server-side and further polls are wasted requests.
|
||||
for (const t of kvittensTimers.current) clearTimeout(t)
|
||||
kvittensTimers.current = []
|
||||
}
|
||||
}
|
||||
|
||||
kvittensTimers.current.push(setTimeout(poll, 30_000))
|
||||
kvittensTimers.current.push(setTimeout(poll, 120_000))
|
||||
kvittensTimers.current.push(setTimeout(poll, 300_000))
|
||||
}, [arbetsgivare, period, fetchSubmission, onChange])
|
||||
}, [checkKvittens])
|
||||
|
||||
// Auto-detect a Mina Sidor BankID signature so the panel reflects "signed"
|
||||
// without the user having to click "Hämta kvittens". While we sit in
|
||||
// awaiting_signing the user has typically opened the signing link (which
|
||||
// opens a new tab), signed on Skatteverket's site, and come back. We re-check
|
||||
// the kvittens (a) once on entering awaiting_signing — covering a reload
|
||||
// after signing — and (b) whenever the tab regains focus — covering the
|
||||
// sign-in-the-other-tab-then-return flow. A found kvittens flips the local
|
||||
// state to 'signed', hiding the signing actions. The ref makes the on-enter
|
||||
// check fire once per episode even if checkKvittens's identity churns (its
|
||||
// onChange dep is an unmemoized parent callback).
|
||||
const signCheckedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (submission?.status !== 'awaiting_signing') {
|
||||
signCheckedRef.current = false
|
||||
return
|
||||
}
|
||||
if (!signCheckedRef.current) {
|
||||
signCheckedRef.current = true
|
||||
checkKvittens()
|
||||
}
|
||||
function onVisible() {
|
||||
if (document.visibilityState === 'visible') checkKvittens()
|
||||
}
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [submission?.status, checkKvittens])
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
setActionLoading('disconnect')
|
||||
@@ -568,6 +631,18 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
const underlagSubmitted = subState === 'underlag_submitted'
|
||||
const underlagRejected = subState === 'underlag_rejected'
|
||||
const isSigned = subState === 'signed' || !!agiSubmittedAt
|
||||
// The submission state is keyed by PERIOD; AGI generation is keyed by RUN.
|
||||
// If the run's AGI was (re)generated AFTER this signing draft was created,
|
||||
// the locked underlag at Skatteverket reflects superseded figures and must
|
||||
// not be signed — surface a warning and steer the user to unlock + resubmit
|
||||
// rather than presenting it as ready to sign (avoids filing stale amounts).
|
||||
const draftUpdatedAt = submission?.updatedAt ? new Date(submission.updatedAt) : null
|
||||
const draftIsStale =
|
||||
awaitingSigning &&
|
||||
!!agiGeneratedAt &&
|
||||
!!draftUpdatedAt &&
|
||||
!Number.isNaN(draftUpdatedAt.getTime()) &&
|
||||
new Date(agiGeneratedAt).getTime() > draftUpdatedAt.getTime()
|
||||
// Tokens issued before the agd scope was added to DEFAULT_SCOPES will
|
||||
// 403 with invalid_scope at submission time — surface that proactively
|
||||
// so the user reconnects before hitting the deadline rather than at it.
|
||||
@@ -663,7 +738,9 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
}
|
||||
pendingText={
|
||||
awaitingSigning
|
||||
? 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
|
||||
? draftIsStale
|
||||
? 'Ett signeringsutkast finns hos Skatteverket men är inaktuellt — lås upp och skicka in underlaget på nytt.'
|
||||
: 'Granskningsunderlag klart — väntar på BankID-signatur i Mina Sidor.'
|
||||
: underlagSubmitted
|
||||
? 'Underlag inläst hos Skatteverket. Skapa granskningsunderlag för att gå vidare till signering.'
|
||||
: 'Inte skickad till Skatteverket ännu. Deadline: 12:e i månaden efter utbetalning (17:e i januari/augusti för arbetsgivare vars sammanlagda lönesumma understiger 40 MSEK per år).'
|
||||
@@ -676,7 +753,7 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
below to surface a felrapport URL, which deserves a distinct
|
||||
treatment so the user understands they must fix errors before
|
||||
BankID signing is even possible. */}
|
||||
{submission?.signeringslank && awaitingSigning && (
|
||||
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<p className="text-sm font-medium">Utkastet är låst och redo att signeras</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
@@ -693,6 +770,29 @@ export function AGIPanel(props: AGIPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stale-draft guard — the signing draft at Skatteverket predates the
|
||||
current run's AGI generation, so it carries superseded figures.
|
||||
We deliberately do NOT surface "Öppna signeringslänk" here: signing
|
||||
it would file the old amounts. The "Lås upp" button below releases
|
||||
the SKV lock; the user then re-submits the freshly generated XML. */}
|
||||
{awaitingSigning && draftIsStale && (
|
||||
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-900/20">
|
||||
<p className="text-sm font-medium">Signeringsutkastet är inaktuellt</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
AGI:n genererades om{' '}
|
||||
{agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : ''}{' '}
|
||||
efter att det här signeringsutkastet skapades
|
||||
{submission?.updatedAt
|
||||
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
|
||||
: ''}
|
||||
. Utkastet hos Skatteverket innehåller äldre siffror. Klicka{' '}
|
||||
<span className="font-medium">Lås upp</span> och därefter{' '}
|
||||
<span className="font-medium">Skicka in underlag</span> för att
|
||||
signera rätt belopp.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INCORRECT_DATA branch — skapaGranskningsunderlag returned 409 with
|
||||
a felrapport link. The user must open the link in Mina Sidor to
|
||||
see what's wrong, fix it, and then re-submit. Without this UI the
|
||||
|
||||
@@ -26,8 +26,11 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
|
||||
{ key: 'manual', labelKey: 'manual' },
|
||||
{ key: 'invoice_created', labelKey: 'invoice_created' },
|
||||
{ key: 'invoice_paid', labelKey: 'invoice_paid' },
|
||||
{ key: 'invoice_cash_payment', labelKey: 'invoice_cash_payment' },
|
||||
{ key: 'supplier_invoice_registered', labelKey: 'supplier_invoice_registered' },
|
||||
{ key: 'supplier_invoice_paid', labelKey: 'supplier_invoice_paid' },
|
||||
{ key: 'supplier_invoice_cash_payment', labelKey: 'supplier_invoice_cash_payment' },
|
||||
{ key: 'supplier_invoice_privately_paid', labelKey: 'supplier_invoice_privately_paid' },
|
||||
{ key: 'salary_payment', labelKey: 'salary_payment' },
|
||||
{ key: 'bank_transaction', labelKey: 'bank_transaction' },
|
||||
{ key: 'reminder_fee', labelKey: 'reminder_fee' },
|
||||
@@ -41,9 +44,12 @@ const VISIBLE_SOURCE_TYPES: Array<{ key: JournalEntrySourceType; labelKey: strin
|
||||
const SV_LABELS: Record<string, string> = {
|
||||
manual: 'Manuella verifikat',
|
||||
invoice_created: 'Kundfakturor (skapande)',
|
||||
invoice_paid: 'Kundfakturor (betalning)',
|
||||
invoice_paid: 'Kundfakturor (betalning, fakturametod)',
|
||||
invoice_cash_payment: 'Kundfakturor (betalning, kontantmetod)',
|
||||
supplier_invoice_registered: 'Leverantörsfakturor (registrering)',
|
||||
supplier_invoice_paid: 'Leverantörsfakturor (betalning)',
|
||||
supplier_invoice_paid: 'Leverantörsfakturor (betalning, fakturametod)',
|
||||
supplier_invoice_cash_payment: 'Leverantörsfakturor (betalning, kontantmetod)',
|
||||
supplier_invoice_privately_paid: 'Leverantörsfakturor (privat utlägg)',
|
||||
salary_payment: 'Lön',
|
||||
bank_transaction: 'Banktransaktioner',
|
||||
reminder_fee: 'Påminnelseavgifter',
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SettingsLoadingSkeleton } from '@/components/settings/SettingsLoadingSk
|
||||
import { PeriodLockingSettings } from '@/components/settings/PeriodLockingSettings'
|
||||
import { VoucherSeriesManager } from '@/components/settings/VoucherSeriesManager'
|
||||
import { VoucherSeriesPerSourceTypeForm } from '@/components/settings/VoucherSeriesPerSourceTypeForm'
|
||||
import { applyDefaultSeriesToMap } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { PeriodiseringAutoDetectToggle } from '@/components/settings/PeriodiseringAutoDetectToggle'
|
||||
import { AccountingFrameworkForm } from '@/components/settings/AccountingFrameworkForm'
|
||||
import { useSettings } from '@/components/settings/useSettings'
|
||||
@@ -46,6 +47,25 @@ export function BookkeepingSettingsContent() {
|
||||
accounting_method: accountingMethod,
|
||||
default_voucher_series: defaultVoucherSeries,
|
||||
}
|
||||
|
||||
// Write-through: the booking engine resolves the series from the
|
||||
// per-source-type map, NOT from default_voucher_series. So when the user
|
||||
// changes the global default, propagate it across the map — but only for
|
||||
// types that were still following the previous default, leaving explicit
|
||||
// per-type overrides (set via VoucherSeriesPerSourceTypeForm) untouched.
|
||||
// Without this the "Standardserie" dropdown is a no-op for bookkeeping.
|
||||
// Only runs when the series actually changed, so saving the form for an
|
||||
// unrelated reason (e.g. the lock date) never rewrites the map.
|
||||
const prevDefault = settings?.default_voucher_series || 'A'
|
||||
const currentMap = settings?.default_voucher_series_per_source_type
|
||||
if (currentMap && defaultVoucherSeries !== prevDefault) {
|
||||
updates.default_voucher_series_per_source_type = applyDefaultSeriesToMap(
|
||||
currentMap,
|
||||
prevDefault,
|
||||
defaultVoucherSeries,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
updates,
|
||||
onSuccess: (data: Record<string, unknown>) => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { Reorder, useDragControls, useReducedMotion } from 'framer-motion'
|
||||
import { GripVertical } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SortableRowProps<T> {
|
||||
/** Identity used by Reorder to track this item across reorders. */
|
||||
value: T
|
||||
/** The row's existing markup — rendered untouched beside the drag handle. */
|
||||
children: ReactNode
|
||||
/** Localized aria-label for the drag handle. */
|
||||
handleLabel: string
|
||||
/** Disable dragging (e.g. a single-row list). */
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag-to-reorder row built on framer-motion's Reorder, with the grip handle
|
||||
* on the LEFT edge. The handle owns the drag (dragListener=false +
|
||||
* dragControls) so text inputs inside the row stay selectable. The handle is
|
||||
* vertically centered against the row so it reads correctly for both compact
|
||||
* text rows and tall product rows. Motion collapses to instant when the user
|
||||
* prefers reduced motion.
|
||||
*
|
||||
* Wrap the list in `<Reorder.Group as="div" axis="y" values={...} onReorder={...}>`
|
||||
* and render one SortableRow per item; the row's own markup goes in `children`,
|
||||
* so callers don't have to restructure existing JSX.
|
||||
*/
|
||||
export function SortableRow<T>({
|
||||
value,
|
||||
children,
|
||||
handleLabel,
|
||||
disabled = false,
|
||||
className,
|
||||
}: SortableRowProps<T>) {
|
||||
const controls = useDragControls()
|
||||
const reduceMotion = useReducedMotion()
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={value}
|
||||
as="div"
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
transition={reduceMotion ? { duration: 0 } : undefined}
|
||||
className={className}
|
||||
>
|
||||
<div className="flex items-stretch gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={handleLabel}
|
||||
disabled={disabled}
|
||||
onPointerDown={(e) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
controls.start(e)
|
||||
}}
|
||||
className="flex shrink-0 touch-none cursor-grab items-center px-1 text-muted-foreground transition-colors duration-150 hover:text-foreground active:cursor-grabbing disabled:cursor-default disabled:opacity-30"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</Reorder.Item>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user