Add/stripe connect transactions (#1139)
* fix(mcp-oauth): allow ChatGPT connector callbacks and resume OAuth after login Add chatgpt.com/connector/oauth/* (per-instance) and the legacy chatgpt.com/connector_platform_oauth_redirect to the built-in OAuth redirect allowlist so ChatGPT MCP connectors can register and authorize. Fix the login page dropping the ?next= destination: an OAuth-initiated visit that required login previously ended on the dashboard and the connection flow silently died. Login now resumes to the sanitized next path (hard navigation, since the consent page is route-handler HTML), carries it through the MFA step-up as returnTo, and /mfa/verify hard-navigates for /api/ destinations. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): dedup incoming feed rows against booked hand-entered twins Users who bookkeep via MCP/chat first and connect their bank afterwards got the same movement twice: the synced row's external_id lives in a different namespace, the free-form manual title never text-bridges the bank's raw string, and the cross-channel mirror deliberately excluded manual/mcp rows. Extend the mirror with a booked-hand-entered track: an incoming feed row is skipped when a BOOKED manual/mcp row shares its (date, ore) bucket count- symmetrically. Gates beyond the feed-vs-feed mirror: stored row must be booked (staged rows never consume an import), currencies must not contradict (bucket key is date+ore only), the cash-account guard applies to the count exactly as to consumption, and symmetry uses the Layer-1-unmatched incoming count so an already-stored row cannot inflate it. Consumption stamps the batch cash_account_id onto an account-unbound hand row, so one hand row can never consume feed rows on other accounts in later syncs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): inline verifikat rattelse (strike lines + text/date edit) Second sanctioned correction track under BFL 5 kap 5/9 pp, Fortnox-style: strike lines inside a posted verifikat with replacements in the same voucher, and correct description/entry_date without an andringsverifikat. Envelope: posted entries, open unlocked periods, company lock date, same-period date moves, structural/FX/doc-linked lines excluded, and a reconciliation guard preserving per-account net on bank/reskontra sides of externally linked entries. Every rattelse writes an immutable who/when row (journal_entry_rattelse_log, WORM, archived as rakenskapsinformation) and struck originals render struck-through in the verifikat; list rows and the detail header carry a Rattad marker. CLAUDE.md hard rule 1 and the swedish-accounting-compliance skill are amended to state the two-track rule. Staging carries the DDL; prod gets it on merge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat: live saldo in booking form, prior-year window comparison, hideable assistant FAB - Manual journal entry: saldo column now shows before -> after computed from the typed debit/credit amounts (direction feedback while booking) - Resultatrapport: a narrowed date range now compares against the same window shifted one year back (#862), merged across fiscal periods for brutet rakenskapsar; P&L rows report window activity instead of rolled-forward YTD closing - Assistant FAB: per-user hide toggle (user_preferences.hide_assistant_fab, settings > assistant), sidebar entry unaffected; collapsed sessions keep their reopen handle Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(stripe): sync balance transactions as a bank feed on 1686 Import the connected Stripe balance into the transactions inbox, opt-in per connection (transaction_sync_enabled on stripe_connections): - Balance transactions map to feed rows with the two-row gross+fee split and frozen external_id formats (stripe_{acct}_{txn} / _fee), dated on created, bound to a provisioned "Stripe-saldo" cash account on 1686 so booking settles against the clearing account by construction. - Double-booking protection: settled payment-link charges import pre-linked to their settlement entry; payout rows import pre-linked to the payout entry; processPayoutPaidEvent claims the payout's fee rows at booking time (linkPayoutFeedRows, idempotent from both directions). - Cursor last_balance_txn_synced_at with 24h overlap; first run backfills 90 days floored at the day after the company lock date. - Nightly cron /api/extensions/stripe/transactions/cron (03:30), transaction-sync toggle route, "Synka nu" covers both feeds, settings panel toggle with last-synced/backfill note, sv+en strings. - Migration 20260723200000 (applied to staging). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(transactions): offer match-to-voucher on unbooked history rows Unbooked transactions with is_business already set (e.g. left behind when a voucher was removed without a full uncategorize) land in the history list instead of the inbox, where the match-against-existing-voucher action did not exist, leaving them with no path back to voucher matching. Add the same menu item to the history list for unbooked rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(transactions): enhance ownership checks and error handling in journal entry routes --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
be9d630347
commit
d840257c0c
@@ -26,12 +26,18 @@ import { CAPABILITY } from '@/lib/entitlements/keys'
|
||||
// row-level "Fråga [namn]" button in TransactionInboxCard, and the matching
|
||||
// "Fråga assistenten" in Dokumentinkorgen: both passing a transaction_id the
|
||||
// pathname-only FAB can't know.)
|
||||
export default function AgentTrigger() {
|
||||
export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
const { openAgentSheet, expandAgentSheet, isOpen, collapsed, identity } = useAgentSheet()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const hasAi = useCapability(CAPABILITY.ai)
|
||||
|
||||
// User opt-out (Inställningar → Assistenten): the sidebar entry stays, the
|
||||
// floating button goes. A collapsed session keeps its reopen handle even
|
||||
// when hidden: it's the only way back to a minimized conversation, and its
|
||||
// existence implies the user is actively using the assistant right now.
|
||||
if (hidden && !collapsed) return null
|
||||
|
||||
// Sheet open AND visible → hide the FAB so the icon doesn't double up. When
|
||||
// the session is merely collapsed we KEEP the FAB: it's the handle that
|
||||
// brings the minimized conversation back.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import type { JournalEntry } from '@/types'
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCorrected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata rättelse (BFL 5 kap 9 §): correct the verifikationstext and/or
|
||||
* the date (within the same fiscal period) of a posted verifikat without an
|
||||
* ändringsverifikation. Who/when is recorded in the immutable rättelse log
|
||||
* and shown in the verifikat's history. Stays Swedish (verifikat surface,
|
||||
* .claude/rules/i18n.md).
|
||||
*/
|
||||
export default function CorrectMetadataDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const [description, setDescription] = useState('')
|
||||
const [entryDate, setEntryDate] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setDescription(entry.description || '')
|
||||
setEntryDate(entry.entry_date?.slice(0, 10) || '')
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const descriptionChanged = description.trim() !== (entry.description || '')
|
||||
const dateChanged = entryDate !== (entry.entry_date?.slice(0, 10) || '')
|
||||
const hasChange = (descriptionChanged && description.trim().length > 0) || (dateChanged && entryDate.length > 0)
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!hasChange) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload: { description?: string; entry_date?: string } = {}
|
||||
if (descriptionChanged && description.trim().length > 0) payload.description = description.trim()
|
||||
if (dateChanged && entryDate.length > 0) payload.entry_date = entryDate
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/correct-metadata`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
const error = new Error('Failed to correct metadata') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
toast({
|
||||
title: 'Verifikationen rättad',
|
||||
description: 'Ändringen har loggats i verifikatets rättelsehistorik.',
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte rätta verifikationen',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Ändra text eller datum</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Verifikationstexten och datumet kan rättas utan ändringsverifikation. Rättelsen loggas
|
||||
med vem och när, och det gamla värdet förblir synligt i rättelsehistoriken. Datumet kan
|
||||
bara flyttas inom samma bokföringsperiod: använd "Flytta till annat datum" för att
|
||||
byta period. Om månaden redan är momsdeklarerad kan en datumflytt påverka den inlämnade
|
||||
deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rattelse-description">Verifikationstext</Label>
|
||||
<Input
|
||||
id="rattelse-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={500}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="rattelse-date">Datum</Label>
|
||||
<Input
|
||||
id="rattelse-date"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
disabled={['storno', 'opening_balance', 'year_end'].includes(entry.source_type)}
|
||||
/>
|
||||
{['storno', 'opening_balance', 'year_end'].includes(entry.source_type) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Datumet på den här verifikationstypen kan inte ändras.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!hasChange || isSubmitting}>
|
||||
{isSubmitting ? 'Rättar...' : 'Spara rättelse'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from '@/lib/hooks/use-submit-with-account-activation'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { formatVoucher, resolveDefaultSeriesForSource } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
|
||||
import { useCompany } from '@/contexts/CompanyContext'
|
||||
@@ -357,8 +358,9 @@ export default function JournalEntryForm({
|
||||
)
|
||||
|
||||
// Fetch per-account saldo as of entryDate for the accounts currently on the
|
||||
// form. Balances are reference-only ("saldo before this entry"): they ignore
|
||||
// the draft lines the user is typing, by design.
|
||||
// form. The fetched value is always "saldo before this entry"; the render
|
||||
// layer adds the typed draft amounts on top (draftDeltas) so the column
|
||||
// shows where the account is heading.
|
||||
useEffect(() => {
|
||||
if (!accountsKey) {
|
||||
setAccountBalances({})
|
||||
@@ -414,6 +416,22 @@ export default function JournalEntryForm({
|
||||
}
|
||||
}, [accountsKey, entryDate])
|
||||
|
||||
// What the typed-but-unposted rows would do to each account's saldo. The
|
||||
// /account-balances convention is debit-positive for every class, so
|
||||
// delta = debit - credit encodes direction without needing the account type:
|
||||
// rendering "before -> after" gives instant feedback on whether the chosen
|
||||
// side increases or decreases the account.
|
||||
const draftDeltas = useMemo(() => {
|
||||
const deltas: Record<string, number> = {}
|
||||
for (const l of lines) {
|
||||
if (!/^\d{4}$/.test(l.account_number)) continue
|
||||
const delta = (parseFloat(l.debit_amount) || 0) - (parseFloat(l.credit_amount) || 0)
|
||||
if (delta === 0) continue
|
||||
deltas[l.account_number] = roundOre((deltas[l.account_number] ?? 0) + delta)
|
||||
}
|
||||
return deltas
|
||||
}, [lines])
|
||||
|
||||
// New rows inherit the current header default (a row without a per-row
|
||||
// override follows the header (see setHeaderDimension).
|
||||
const makeBlankLine = useCallback(
|
||||
@@ -1446,9 +1464,24 @@ export default function JournalEntryForm({
|
||||
{accountBalances[line.account_number] === null || accountBalances[line.account_number] === undefined ? (
|
||||
<Skeleton className="h-3 w-20" />
|
||||
) : (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(accountBalances[line.account_number] as number)}
|
||||
</span>
|
||||
(() => {
|
||||
const bal = accountBalances[line.account_number] as number
|
||||
const delta = draftDeltas[line.account_number]
|
||||
if (!delta) {
|
||||
return (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(bal)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const after = roundOre(bal + delta)
|
||||
return (
|
||||
<span>
|
||||
{t('saldo_label')} {formatCurrency(bal)}{' '}
|
||||
<span className="text-foreground">→ {formatCurrency(after)}</span>
|
||||
</span>
|
||||
)
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1579,7 +1612,15 @@ export default function JournalEntryForm({
|
||||
if (bal === null || bal === undefined) {
|
||||
return <Skeleton className="h-4 w-20 ml-auto" />
|
||||
}
|
||||
return formatCurrency(bal)
|
||||
const delta = draftDeltas[line.account_number]
|
||||
if (!delta) return formatCurrency(bal)
|
||||
const after = roundOre(bal + delta)
|
||||
return (
|
||||
<span className="inline-flex flex-col items-end leading-tight">
|
||||
<span className="text-[11px]">{formatCurrency(bal)}</span>
|
||||
<span className="text-foreground">→ {formatCurrency(after)}</span>
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5">
|
||||
|
||||
@@ -103,6 +103,10 @@ export default function JournalEntryList() {
|
||||
const [count, setCount] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||
// Entries with inline rättelser (journal_entry_rattelse_log rows): drives
|
||||
// the "Rättad" marker so a rättelse is discoverable from the list
|
||||
// (BFL 5 kap 5 §), not only on the detail page.
|
||||
const [rattelseFlags, setRattelseFlags] = useState<Set<string>>(new Set())
|
||||
const [noDocRequired, setNoDocRequired] = useState<Map<string, string | null>>(new Map())
|
||||
const [showMissingOnly, setShowMissingOnly] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
@@ -212,6 +216,23 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchRattelseFlags = useCallback(async (entryIds: string[]) => {
|
||||
if (entryIds.length === 0) {
|
||||
setRattelseFlags(new Set())
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/bookkeeping/journal-entries/rattelse-flags?ids=${entryIds.join(',')}`
|
||||
)
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
setRattelseFlags(new Set((data || []) as string[]))
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchNoDocRequired = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/bookkeeping/no-doc-required')
|
||||
@@ -371,9 +392,10 @@ export default function JournalEntryList() {
|
||||
}
|
||||
setLoading(false)
|
||||
|
||||
// Fetch attachment counts for the loaded entries
|
||||
// Fetch attachment counts + rättelse markers for the loaded entries
|
||||
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
||||
fetchAttachmentCounts(ids)
|
||||
fetchRattelseFlags(ids)
|
||||
}
|
||||
|
||||
// Cheap count-only query for the "Utkast" badge, all years, so the badge
|
||||
@@ -1103,6 +1125,15 @@ export default function JournalEntryList() {
|
||||
{(entry.status === 'reversed' || entry.status === 'draft' || entry.source_type === 'storno' || entry.source_type === 'correction') && (
|
||||
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
|
||||
)}
|
||||
{rattelseFlags.has(entry.id) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs font-normal shrink-0"
|
||||
title={t('rattelse_badge_tooltip')}
|
||||
>
|
||||
{t('rattelse_badge')}
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { AccountNumber } from '@/components/ui/account-number'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { changeCorrectionLineAccount, getSelectableCorrectionCatalog } from '@/lib/bookkeeping/correction-line-account'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
import { Loader2, Plus, Trash2 } from 'lucide-react'
|
||||
import type { JournalEntry, JournalEntryLine, BASAccount } from '@/types'
|
||||
|
||||
interface NewLine {
|
||||
account_number: string
|
||||
debit_amount: string
|
||||
credit_amount: string
|
||||
line_description: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
entry: JournalEntry
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onCorrected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline line rättelse (BFL 5 kap 5 §): strike lines in a posted verifikat
|
||||
* and add replacement lines in the SAME verifikat, without an
|
||||
* ändringsverifikation. The struck originals stay visible (strikethrough)
|
||||
* in the verifikat via the immutable rättelse log. Stays Swedish
|
||||
* (verifikat surface, .claude/rules/i18n.md).
|
||||
*/
|
||||
export default function StrikeLinesDialog({ entry, open, onOpenChange, onCorrected }: Props) {
|
||||
const { toast } = useToast()
|
||||
const t = useTranslations('journal_detail')
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [accountsStatus, setAccountsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [strikeIds, setStrikeIds] = useState<Set<string>>(new Set())
|
||||
const [newLines, setNewLines] = useState<NewLine[]>([])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const activeAccounts = useMemo(
|
||||
() => accounts.filter((account) => account.is_active),
|
||||
[accounts],
|
||||
)
|
||||
const selectableCatalog = useMemo(
|
||||
() => getSelectableCorrectionCatalog(accounts, catalog),
|
||||
[accounts, catalog],
|
||||
)
|
||||
|
||||
const originalLines = ((entry.lines || []) as JournalEntryLine[])
|
||||
.slice()
|
||||
.sort((a, b) => a.sort_order - b.sort_order)
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStrikeIds(new Set())
|
||||
setNewLines([])
|
||||
void fetchAccounts()
|
||||
}
|
||||
}, [open, entry.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function fetchAccounts() {
|
||||
setAccountsStatus('loading')
|
||||
try {
|
||||
const [res, basCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts?active=false'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!res.ok) throw new Error(`accounts ${res.status}`)
|
||||
const { data } = await res.json()
|
||||
setAccounts(data || [])
|
||||
setCatalog(basCatalog)
|
||||
setAccountsStatus('ready')
|
||||
} catch {
|
||||
setAccounts([])
|
||||
setCatalog([])
|
||||
setAccountsStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStrike = (lineId: string) => {
|
||||
setStrikeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(lineId)) next.delete(lineId)
|
||||
else next.add(lineId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const updateNewLine = (index: number, field: keyof NewLine, value: string) => {
|
||||
setNewLines((prev) => prev.map((l, i) => (i === index ? { ...l, [field]: value } : l)))
|
||||
}
|
||||
|
||||
const updateNewLineAccount = (index: number, accountNumber: string) => {
|
||||
setNewLines((prev) => prev.map((line, lineIndex) => (
|
||||
lineIndex === index
|
||||
? changeCorrectionLineAccount(line, accountNumber, [...accounts, ...catalog])
|
||||
: line
|
||||
)))
|
||||
}
|
||||
|
||||
const addNewLine = () => {
|
||||
setNewLines((prev) => [...prev, { account_number: '', debit_amount: '', credit_amount: '', line_description: '' }])
|
||||
}
|
||||
|
||||
const removeNewLine = (index: number) => {
|
||||
setNewLines((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
// Effective verifikat after the rättelse: remaining original lines + new lines.
|
||||
const remaining = originalLines.filter((l) => !strikeIds.has(l.id))
|
||||
const remainingDebit = remaining.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
||||
const remainingCredit = remaining.reduce((sum, l) => sum + (Number(l.credit_amount) || 0), 0)
|
||||
const newDebit = newLines.reduce((sum, l) => sum + (parseFloat(l.debit_amount) || 0), 0)
|
||||
const newCredit = newLines.reduce((sum, l) => sum + (parseFloat(l.credit_amount) || 0), 0)
|
||||
const totalDebit = Math.round((remainingDebit + newDebit) * 100) / 100
|
||||
const totalCredit = Math.round((remainingCredit + newCredit) * 100) / 100
|
||||
const isBalanced = totalDebit === totalCredit && totalDebit > 0
|
||||
|
||||
const newLinesValid = newLines.every((l) => {
|
||||
const debit = parseFloat(l.debit_amount) || 0
|
||||
const credit = parseFloat(l.credit_amount) || 0
|
||||
return l.account_number.length === 4 && debit >= 0 && credit >= 0 && (debit > 0) !== (credit > 0)
|
||||
})
|
||||
const hasChange = strikeIds.size > 0 || newLines.length > 0
|
||||
const effectiveCount = remaining.length + newLines.length
|
||||
const canSubmit = hasChange && newLinesValid && isBalanced && effectiveCount >= 2
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries/${entry.id}/strike-lines`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
strike_line_ids: [...strikeIds],
|
||||
lines: newLines.map((l) => ({
|
||||
account_number: l.account_number,
|
||||
debit_amount: parseFloat(l.debit_amount) || 0,
|
||||
credit_amount: parseFloat(l.credit_amount) || 0,
|
||||
line_description: l.line_description || undefined,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!res.ok) {
|
||||
const error = new Error('Failed to strike lines') as Error & { body?: unknown; status?: number }
|
||||
error.body = result
|
||||
error.status = res.status
|
||||
throw error
|
||||
}
|
||||
toast({
|
||||
title: 'Verifikationen rättad',
|
||||
description: 'De strukna raderna visas överstrukna i verifikatet.',
|
||||
})
|
||||
onOpenChange(false)
|
||||
onCorrected()
|
||||
} catch (err) {
|
||||
const anyErr = err as { body?: unknown; status?: number }
|
||||
toast({
|
||||
title: 'Kunde inte rätta verifikationen',
|
||||
description: getErrorMessage(anyErr.body ?? err, { context: 'journal_entry', statusCode: anyErr.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Stryk rader i verifikatet</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="rounded-lg bg-muted/50 border p-3 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground mb-1">Rättelse i samma verifikat</p>
|
||||
<p>
|
||||
Felaktiga rader stryks och ersätts direkt i verifikatet, utan ändringsverifikation.
|
||||
De strukna raderna förblir synliga (överstrukna) och rättelsen loggas med vem och när,
|
||||
enligt bokföringslagen. Fungerar bara i öppna, olåsta perioder. Om månaden redan är
|
||||
momsdeklarerad kan en ändring av momskonton påverka den inlämnade deklarationen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Original lines with strike checkboxes */}
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Markera rader som ska strykas</p>
|
||||
<div className="rounded-lg border divide-y">
|
||||
{originalLines.map((line) => {
|
||||
const struck = strikeIds.has(line.id)
|
||||
// FX lines carry conversion data replacements cannot reproduce;
|
||||
// the RPC rejects striking them, so the checkbox is disabled.
|
||||
const isForeign = !!line.currency && line.currency !== 'SEK'
|
||||
return (
|
||||
<label
|
||||
key={line.id}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm transition-colors ${isForeign ? 'opacity-60' : 'cursor-pointer hover:bg-secondary/60'}`}
|
||||
title={isForeign ? 'Rader i utländsk valuta rättas med ändringsverifikat' : undefined}
|
||||
>
|
||||
<Checkbox
|
||||
checked={struck}
|
||||
disabled={isForeign}
|
||||
onCheckedChange={() => toggleStrike(line.id)}
|
||||
/>
|
||||
<span className={`flex-1 min-w-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
|
||||
<AccountNumber number={line.account_number} showName />
|
||||
{line.line_description && (
|
||||
<span className="text-muted-foreground ml-2">{line.line_description}</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`tabular-nums shrink-0 ${struck ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{Number(line.debit_amount) > 0
|
||||
? `${Number(line.debit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} D`
|
||||
: `${Number(line.credit_amount).toLocaleString('sv-SE', { minimumFractionDigits: 2 })} K`}
|
||||
</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replacement lines */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Ersättningsrader</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Lägg till de rader som ska gälla i stället. Verifikationen måste balansera efter rättelsen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{accountsStatus !== 'ready' && (
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-2">
|
||||
{accountsStatus === 'loading' && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{accountsStatus === 'loading' ? t('accounts_loading') : t('accounts_load_failed')}
|
||||
</span>
|
||||
{accountsStatus === 'error' && (
|
||||
<Button variant="outline" size="sm" onClick={() => void fetchAccounts()}>
|
||||
{t('accounts_retry')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{newLines.map((line, index) => (
|
||||
<div key={index} className="space-y-2 sm:space-y-0 sm:grid sm:grid-cols-[1fr_1fr_120px_120px_auto] sm:gap-2 sm:items-start border-b sm:border-0 pb-3 sm:pb-0 last:border-0">
|
||||
<div className="grid grid-cols-[1fr_auto] sm:contents gap-2">
|
||||
<AccountCombobox
|
||||
value={line.account_number}
|
||||
accounts={activeAccounts}
|
||||
catalog={selectableCatalog}
|
||||
onChange={(v) => updateNewLineAccount(index, v)}
|
||||
disabled={accountsStatus !== 'ready'}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 min-h-[44px] min-w-[44px] sm:order-last"
|
||||
onClick={() => removeNewLine(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={line.line_description}
|
||||
onChange={(e) => updateNewLine(index, 'line_description', e.target.value)}
|
||||
placeholder="Beskrivning"
|
||||
className="h-8"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-2 sm:contents">
|
||||
<Input
|
||||
type="number"
|
||||
value={line.debit_amount}
|
||||
onChange={(e) => updateNewLine(index, 'debit_amount', e.target.value)}
|
||||
placeholder="Debet"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={line.credit_amount}
|
||||
onChange={(e) => updateNewLine(index, 'credit_amount', e.target.value)}
|
||||
placeholder="Kredit"
|
||||
className="h-8 text-right"
|
||||
min={0}
|
||||
step="0.01"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={addNewLine}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Lägg till rad
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Effective balance after the rättelse */}
|
||||
<div className="flex justify-end gap-6 text-sm pt-2 border-t">
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Debet efter rättelse:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{totalDebit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mr-2">Kredit efter rättelse:</span>
|
||||
<span className={!isBalanced ? 'text-destructive font-medium' : 'font-medium'}>
|
||||
{totalCredit.toLocaleString('sv-SE', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChange && !isBalanced && (
|
||||
<p className="text-sm text-destructive">
|
||||
Debet och kredit måste vara lika och större än 0 efter rättelsen.
|
||||
</p>
|
||||
)}
|
||||
{hasChange && isBalanced && effectiveCount < 2 && (
|
||||
<p className="text-sm text-destructive">
|
||||
Verifikationen måste ha minst två rader efter rättelsen. Använd Återför (storno) för att
|
||||
makulera hela verifikatet.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
Avbryt
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? 'Rättar...' : 'Rätta verifikatet'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -781,8 +781,18 @@ export function ResultatrapportView({ periodId, dateRange, dimensionFilter = nul
|
||||
<tr className="border-b text-[11px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="text-left font-medium px-4 py-2 w-20">Konto</th>
|
||||
<th className="text-left font-medium px-4 py-2">Kontonamn</th>
|
||||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Innevarande</th>
|
||||
<th className="text-right font-medium px-4 py-2 w-32 tabular-nums">Föregående</th>
|
||||
<th
|
||||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||||
title={`${data.period.start} till ${data.period.end}`}
|
||||
>
|
||||
Innevarande
|
||||
</th>
|
||||
<th
|
||||
className="text-right font-medium px-4 py-2 w-32 tabular-nums"
|
||||
title={hasPrior ? `${data.prior_period!.start} till ${data.prior_period!.end}` : undefined}
|
||||
>
|
||||
Föregående
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { AgentMemoryPanel } from '@/components/settings/AgentMemoryPanel'
|
||||
import { AgentSkillsPanel } from '@/components/settings/AgentSkillsPanel'
|
||||
import { AgentKnowledgePanel } from '@/components/agent-knowledge/AgentKnowledgePanel'
|
||||
@@ -31,24 +35,92 @@ export function AssistantSettingsContent() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="space-y-8">
|
||||
<Tabs value={view} onValueChange={setView} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="knowledge">Kunskap</TabsTrigger>
|
||||
<TabsTrigger value="memory">Minne</TabsTrigger>
|
||||
<TabsTrigger value="skills">Kompetens</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{/* Radix unmounts the inactive panel, so each panel's data is fetched
|
||||
lazily the first time its tab is opened. */}
|
||||
<TabsContent value="knowledge">
|
||||
<AgentKnowledgePanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="memory">
|
||||
<AgentMemoryPanel />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<AgentSkillsPanel />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<FabVisibilityCard />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Per-user toggle for the floating assistant button bottom-right. The value
|
||||
// lives on user_preferences (server-rendered into the dashboard layout), so
|
||||
// a successful save triggers router.refresh() to make the button react
|
||||
// immediately instead of on next navigation.
|
||||
function FabVisibilityCard() {
|
||||
const t = useTranslations('settings_assistant')
|
||||
const router = useRouter()
|
||||
// null = not yet loaded (switch disabled meanwhile)
|
||||
const [hideFab, setHideFab] = useState<boolean | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
fetch('/api/user/preferences')
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
if (!cancelled) setHideFab(Boolean(body?.data?.hide_assistant_fab))
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHideFab(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
async function handleToggle(showFab: boolean) {
|
||||
const nextHide = !showFab
|
||||
const previous = hideFab
|
||||
setHideFab(nextHide)
|
||||
setSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/user/preferences', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hide_assistant_fab: nextHide }),
|
||||
})
|
||||
if (!res.ok) throw new Error('save failed')
|
||||
router.refresh()
|
||||
} catch {
|
||||
setHideFab(previous)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6 flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">{t('fab_title')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('fab_description')}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={hideFab === null ? true : !hideFab}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={hideFab === null || saving}
|
||||
aria-label={t('fab_title')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
FileText,
|
||||
Landmark,
|
||||
Link2,
|
||||
FileSearch,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Paperclip,
|
||||
@@ -55,6 +56,11 @@ interface TransactionHistoryListProps {
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the attach-underlag dialog (pin an inbox doc / fresh upload). */
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
/** Open the match-against-existing-voucher dialog. Unbooked rows can end up
|
||||
* here (not in the inbox) when is_business is already set, e.g. after a
|
||||
* voucher was removed without a full uncategorize; without this item such
|
||||
* rows have no path back to voucher matching. */
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
onSkvBokfor?: (row: StoredSkattekontoTransaction) => void
|
||||
onSkvMatch?: (row: StoredSkattekontoTransaction) => void
|
||||
@@ -78,6 +84,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
onSkvBokfor,
|
||||
onSkvMatch,
|
||||
@@ -203,6 +210,7 @@ export default function TransactionHistoryList({
|
||||
onOpenMatchDialog={onOpenMatchDialog}
|
||||
onOpenCategoryDialog={onOpenCategoryDialog}
|
||||
onOpenAttachDocument={onOpenAttachDocument}
|
||||
onOpenMatchVoucher={onOpenMatchVoucher}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
) : (
|
||||
@@ -243,6 +251,7 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
onOpenAttachDocument,
|
||||
onOpenMatchVoucher,
|
||||
onDelete,
|
||||
}: {
|
||||
transaction: TransactionWithInvoice
|
||||
@@ -250,6 +259,7 @@ function BankHistoryRow({
|
||||
onOpenMatchDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenCategoryDialog: (transaction: TransactionWithInvoice) => void
|
||||
onOpenAttachDocument?: (transaction: TransactionWithInvoice) => void
|
||||
onOpenMatchVoucher?: (transaction: TransactionWithInvoice) => void
|
||||
onDelete?: (id: string) => void
|
||||
}) {
|
||||
const t = useTranslations('tx_history')
|
||||
@@ -275,8 +285,11 @@ function BankHistoryRow({
|
||||
const hasJeDoc = jeStatus === 'has'
|
||||
const missingUnderlag = isBooked && !transaction.document_id && jeStatus === 'missing'
|
||||
const showAttachItem = canWrite && !!onOpenAttachDocument
|
||||
// Same affordance as the inbox card: an unbooked row may need to be linked
|
||||
// to an already-booked voucher (e.g. the other leg of a transfer).
|
||||
const showMatchVoucherItem = canWrite && !isBooked && !!onOpenMatchVoucher
|
||||
const showOverflowMenu =
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem
|
||||
hasInvoiceMatch || (canDelete && !!onDelete) || (isBooked && canWrite) || showAttachItem || showMatchVoucherItem
|
||||
|
||||
const isPrivate = transaction.is_business === false
|
||||
const categoryLabel =
|
||||
@@ -394,6 +407,12 @@ function BankHistoryRow({
|
||||
})}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{showMatchVoucherItem && (
|
||||
<DropdownMenuItem onSelect={() => onOpenMatchVoucher!(transaction)}>
|
||||
<FileSearch className="h-3.5 w-3.5" />
|
||||
{t('match_voucher')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{/* Attach underlag: available on both booked rows (the route
|
||||
propagates the doc onto the verifikation) and unbooked. */}
|
||||
{showAttachItem && (
|
||||
@@ -413,7 +432,7 @@ function BankHistoryRow({
|
||||
)}
|
||||
{canDelete && onDelete && (
|
||||
<>
|
||||
{(hasInvoiceMatch || showAttachItem) && <DropdownMenuSeparator />}
|
||||
{(hasInvoiceMatch || showAttachItem || showMatchVoucherItem) && <DropdownMenuSeparator />}
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete(transaction.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
|
||||
Reference in New Issue
Block a user