fix(invoices): ROT/RUT credit notes; verifikat amount sort, HTML underlag, source chip (#1523)
* feat(invoice-inbox): store HTML mails as underlag, expandable field editor Body-only mails and .html attachments (including forwarded .eml bodies) no longer dead-end as "Fel vid bearbetning": the mail body is wrapped into a self-contained text/html document, stored through the normal upload/extract pipeline, and extracted via a new HTML-to-text Bedrock path, so the mail itself can serve as bookable underlag. Empty mails keep the error row, unsupported types are still rejected, and webhook retries dedupe on resend_email_id. Mail HTML is attacker-controlled, so rendering is fully sandboxed: iframe sandbox in the workspace preview and a CSP sandbox header on /api/documents/:id/inline for text/html. The type is accepted only from the email pipeline (EMAIL_ALLOWED_MIME_TYPES), never from manual upload. The "Extraherade falt" rail gains an expand button opening a centered dialog with the same autosaving field editor at a readable size (two columns), which also gives every failed or skipped extraction a manual fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): sortable verifikat list headers with amount sort - clickable sort toggles on the verifikat list headers (asc -> desc -> default) - total_amount computed column + sort_by total/description on the list route - failed list loads render an error card with retry, never the empty-ledger state Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(import): decode bank CSV as Windows-1252 fallback in column mapping The client read the uploaded file with file.text(), which is UTF-8-only, so Windows-1252 exports (e.g. Handelsbanken) rendered and re-parsed with U+FFFD in place of Swedish characters. Decode from bytes with the shared decodeFileContent() helper, matching what the server parse route does. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(bookkeeping): stackable sort keys on verifikat list headers - shift-click adds a column as secondary/tertiary sort key (max 3), plain click keeps the single-key tri-state cycle - sort_by accepts a comma-separated priority list; single tokens stay valid - voucher tiebreak follows the last key's direction (#972 parity) - priority numbers on stacked headers; hint text in the filter dialog Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): keep ROT/RUT deduction fields positive on credit notes Crediting an invoice with a ROT/RUT deduction failed 100% of the time: the credit-note path negated deduction_total (and per-item deduction_amount) like the other amounts, but both columns carry CHECK (>= 0), so Postgres rejected the insert and the user only saw 'Kunde inte skapa kreditfaktura'. Store the deduction fields as positive magnitudes, matching the convention everywhere else. The stored sign is inert on credit notes: the reversing verifikat recomputes the ROT/RUT split from the items, and the PDF and amount-to-pay logic skip deductions on credit notes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(transactions): share the source chip across inbox and history modes Move SourceFilter to transaction-types.ts (widened with 'bank:other' and 'acct:<id>'), render the one toolbar ContextPicker in both view modes, and drop the narrower duplicate chip inside TransactionHistoryList. The history list now applies the acct:/bank:other narrowing itself and hides skattekonto rows under any bank-side selection. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(deps): bump js-yaml to 4.3.1 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(schema): recognize PostgREST computed columns in the migration parser The verifikat amount sort orders by total_amount, a PostgREST computed column (a function on the journal_entries row type, migration 20260811100000). The schema guard only modeled real columns, so no-phantom-columns flagged the order as a phantom. Teach the parser that a function whose only argument is a table's row type joins that table's column set, with DROP FUNCTION retraction when the signature names the row type. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: resolve PR #1523 review findings - journal-entries route: append the globally unique id tiebreak to every direct-query sort; voucher series+number repeat across fiscal years, so the all-years scope could duplicate or drop rows at page boundaries. Existing order assertions updated, new all-years tiebreak test. - documents inline route: CSP source policy on HTML previews; sandbox alone still loads remote resources, letting a tracking pixel notify the sender on open. New route test asserts the full header. - JournalEntryList: catch rejected list requests so loading cannot stick forever, and gate every post-await state write behind a request generation so a slow earlier request cannot overwrite the current sort. - TransactionHistoryList: pagination follows the selected source scope (reachable with zero matches on the current page, hidden for the skattekonto scope it cannot affect). - transactions page: bank:other picker availability derives from history rows too, not only the pending inbox dataset. - DECISIONS.md: mark the superseded single-sort decision; record the credit-note deduction positive-magnitude invariant and its verified reader inventory (Swedish review flag). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: guard metadata refetches behind the list request generation fetchAttachmentCounts and fetchRattelseFlags write state after their own awaits; a stale list request's late completion could overwrite attachment counts and rattelse flags for rows a newer request just rendered, showing false missing-underlag warnings. Both helpers now take the caller's generation guard and discard stale completions, including the attachment-counts loaded flag. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f2d9e98af3
commit
9dbaebcc50
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Fragment, useState, useEffect, useCallback } from 'react'
|
||||
import { Fragment, useState, useEffect, useCallback, useRef } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
QUIET_LINK_CLASS,
|
||||
RowFoldout,
|
||||
} from '@/components/ui/dry-table'
|
||||
import { ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronRight, ChevronLeft, ChevronsLeft, ChevronsRight, Copy, Paperclip, CircleSlash, Loader2, BookOpen, X, Lock, Search, SlidersHorizontal, RotateCcw } from 'lucide-react'
|
||||
import { cn, formatDate, formatCurrency } from '@/lib/utils'
|
||||
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
|
||||
import { resolveCurrentPeriodId } from '@/lib/bookkeeping/suggest-fiscal-period'
|
||||
@@ -67,12 +67,111 @@ const NEEDS_ATTACHMENT = new Set([
|
||||
'import',
|
||||
])
|
||||
|
||||
type SortBy = 'date_desc' | 'date_asc' | 'voucher_asc' | 'voucher_desc'
|
||||
// Column-header sorting (support feedback: "filtrera/sortera alla rubriker").
|
||||
// The sort order is a priority-ordered STACK of keys (max 3): the second key
|
||||
// breaks ties in the first, and so on. Plain click sets a single key;
|
||||
// shift-click stacks.
|
||||
type SortColumn = 'voucher' | 'date' | 'description' | 'total'
|
||||
type SortDirection = 'asc' | 'desc'
|
||||
interface SortKey {
|
||||
column: SortColumn
|
||||
direction: SortDirection
|
||||
}
|
||||
|
||||
// Per-company persistence of the sort dropdown. Mirrors the localStorage
|
||||
// Per-company persistence of the sort order. Mirrors the localStorage
|
||||
// convention used by FiscalYearSelector ('Accounted:fiscal-year:<companyId>').
|
||||
// The stored value is the serialized stack (see serializeSortStack).
|
||||
const SORT_STORAGE_KEY_PREFIX = 'Accounted:journal-sort:'
|
||||
const SORT_VALUES = new Set<SortBy>(['date_desc', 'date_asc', 'voucher_asc', 'voucher_desc'])
|
||||
const MAX_SORT_KEYS = 3
|
||||
const SORT_TOKEN_RE = /^(voucher|date|description|total)_(asc|desc)$/
|
||||
|
||||
// The list's resting order, and the exit of the header toggle cycle.
|
||||
const DEFAULT_SORT_STACK: SortKey[] = [{ column: 'date', direction: 'desc' }]
|
||||
|
||||
// 'total_desc,description_asc' <-> [{total desc}, {description asc}]. The
|
||||
// single-token form is the pre-stack format, so persisted values from before
|
||||
// stacking (and the dialog's single-choice select) parse with the same code.
|
||||
// Any invalid token rejects the whole value: a corrupt stored string falls
|
||||
// back to the default rather than silently sorting by half a stack.
|
||||
function parseSortStack(raw: string | null): SortKey[] | null {
|
||||
if (!raw) return null
|
||||
const keys: SortKey[] = []
|
||||
for (const token of raw.split(',')) {
|
||||
const m = SORT_TOKEN_RE.exec(token.trim())
|
||||
if (!m) return null
|
||||
const column = m[1] as SortColumn
|
||||
if (keys.some((k) => k.column === column)) continue
|
||||
keys.push({ column, direction: m[2] as SortDirection })
|
||||
if (keys.length === MAX_SORT_KEYS) break
|
||||
}
|
||||
return keys.length > 0 ? keys : null
|
||||
}
|
||||
|
||||
const serializeSortStack = (stack: SortKey[]): string =>
|
||||
stack.map((k) => `${k.column}_${k.direction}`).join(',')
|
||||
|
||||
const sortStacksEqual = (a: SortKey[], b: SortKey[]): boolean =>
|
||||
serializeSortStack(a) === serializeSortStack(b)
|
||||
|
||||
// Same shape as the invoices list header (app/(dashboard)/invoices/page.tsx):
|
||||
// click cycles the sort, inactive columns show a dimmed two-way arrow. Two
|
||||
// deliberate differences: the plain-click cycle has a third step back to
|
||||
// DEFAULT_SORT_STACK so a sort is always escapable from the header itself
|
||||
// (the invoices list starts unsorted and has nothing to return to), and
|
||||
// shift-click stacks the column as a secondary/tertiary key, shown with a
|
||||
// priority number next to the arrow.
|
||||
interface SortableHeaderProps {
|
||||
label: string
|
||||
sortLabel: string
|
||||
column: SortColumn
|
||||
stack: SortKey[]
|
||||
onSort: (column: SortColumn, additive: boolean) => void
|
||||
className?: string
|
||||
align?: 'left' | 'right'
|
||||
}
|
||||
|
||||
function SortableHeader({
|
||||
label,
|
||||
sortLabel,
|
||||
column,
|
||||
stack,
|
||||
onSort,
|
||||
className,
|
||||
align = 'left',
|
||||
}: SortableHeaderProps) {
|
||||
const index = stack.findIndex((k) => k.column === column)
|
||||
const active = index !== -1
|
||||
const direction = active ? stack[index].direction : null
|
||||
const SortIcon = !active ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown
|
||||
|
||||
return (
|
||||
<th
|
||||
className={cn(TH_CLASS, className)}
|
||||
aria-sort={active ? (direction === 'asc' ? 'ascending' : 'descending') : 'none'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'-mx-2 inline-flex min-h-10 items-center gap-1 rounded-sm px-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
align === 'right' && 'ml-auto justify-end',
|
||||
)}
|
||||
aria-label={sortLabel}
|
||||
onClick={(e) => onSort(column, e.shiftKey)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<SortIcon
|
||||
aria-hidden="true"
|
||||
className={cn('h-3.5 w-3.5 shrink-0', !active && 'text-muted-foreground/60')}
|
||||
/>
|
||||
{active && stack.length > 1 && (
|
||||
<span className="text-[10px] font-medium tabular-nums text-muted-foreground" aria-hidden="true">
|
||||
{index + 1}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
// Compact row density (support feedback: "kompakt visning av verifikat").
|
||||
// Persisted per company, mirroring the sort key convention.
|
||||
@@ -105,6 +204,11 @@ export default function JournalEntryList() {
|
||||
// dims them, so the list never collapses to a spinner and springs back to
|
||||
// full height under the pointer. Growing lists were causing real mis-clicks.
|
||||
const [hasLoaded, setHasLoaded] = useState(false)
|
||||
// A failed list fetch must NEVER render as an empty ledger: the sort order
|
||||
// is persisted per company, so a sort the backend rejects (e.g. a computed
|
||||
// column missing on this environment) would otherwise masquerade as "all
|
||||
// entries gone" on every reload, with no toolbar to escape through.
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [count, setCount] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
@@ -131,7 +235,7 @@ export default function JournalEntryList() {
|
||||
const [reverseEntryTarget, setReverseEntryTarget] = useState<JournalEntry | null>(null)
|
||||
const [isReversing, setIsReversing] = useState(false)
|
||||
const [previewEntryId, setPreviewEntryId] = useState<string | null>(null)
|
||||
const [sortBy, setSortBy] = useState<SortBy>('date_desc')
|
||||
const [sortStack, setSortStack] = useState<SortKey[]>(DEFAULT_SORT_STACK)
|
||||
const [sortHydrated, setSortHydrated] = useState(false)
|
||||
const [periodId, setPeriodId] = useState<string | null>(null)
|
||||
const [periodHydrated, setPeriodHydrated] = useState(false)
|
||||
@@ -196,7 +300,12 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
|
||||
// isCurrent: the caller's request-generation guard (see fetchGenRef). The
|
||||
// metadata writes land after their own awaits, so a stale list request's
|
||||
// late completion must not overwrite counts/flags for the rows a newer
|
||||
// request just rendered: wrong counts here flip the missing-underlag
|
||||
// warning and bulk-exemption eligibility.
|
||||
const fetchAttachmentCounts = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => {
|
||||
if (entryIds.length === 0) {
|
||||
setAttachmentCounts({})
|
||||
setAttachmentCountsLoaded(true)
|
||||
@@ -226,15 +335,18 @@ export default function JournalEntryList() {
|
||||
return (data || {}) as Record<string, number>
|
||||
})
|
||||
)
|
||||
if (!isCurrent()) return
|
||||
setAttachmentCounts(Object.assign({}, ...results))
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
} finally {
|
||||
setAttachmentCountsLoaded(true)
|
||||
// A stale run must not flip the loaded flag either: the newer run set
|
||||
// it false on entry and owns setting it true when ITS counts land.
|
||||
if (isCurrent()) setAttachmentCountsLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchRattelseFlags = useCallback(async (entryIds: string[]) => {
|
||||
const fetchRattelseFlags = useCallback(async (entryIds: string[], isCurrent: () => boolean = () => true) => {
|
||||
if (entryIds.length === 0) {
|
||||
setRattelseFlags(new Set())
|
||||
return
|
||||
@@ -245,6 +357,7 @@ export default function JournalEntryList() {
|
||||
)
|
||||
if (!res.ok) return
|
||||
const { data } = await res.json()
|
||||
if (!isCurrent()) return
|
||||
setRattelseFlags(new Set((data || []) as string[]))
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
@@ -276,8 +389,10 @@ export default function JournalEntryList() {
|
||||
// the saved order, no flash of the default sort.
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'))
|
||||
if (stored && SORT_VALUES.has(stored as SortBy)) setSortBy(stored as SortBy)
|
||||
const stored = parseSortStack(
|
||||
window.localStorage.getItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default')),
|
||||
)
|
||||
if (stored) setSortStack(stored)
|
||||
}
|
||||
setSortHydrated(true)
|
||||
}, [company?.id])
|
||||
@@ -367,13 +482,20 @@ export default function JournalEntryList() {
|
||||
return () => clearTimeout(handle)
|
||||
}, [searchInput])
|
||||
|
||||
// Sort/filter changes fire fetchEntries while an earlier request may still
|
||||
// be in flight; only the newest request may write state, or a slow earlier
|
||||
// response would overwrite the current sort's rows after they rendered.
|
||||
const fetchGenRef = useRef(0)
|
||||
|
||||
async function fetchEntries() {
|
||||
const gen = ++fetchGenRef.current
|
||||
const isCurrent = () => fetchGenRef.current === gen
|
||||
setLoading(true)
|
||||
setSelectedIds(new Set()) // selection is page-scoped, reset on reload
|
||||
const params = new URLSearchParams({
|
||||
limit: String(pageSize),
|
||||
offset: String(page * pageSize),
|
||||
sort_by: sortBy,
|
||||
sort_by: serializeSortStack(sortStack),
|
||||
})
|
||||
if (listMode === 'drafts') {
|
||||
// Drafts get their own view spanning all years: they're work-in-progress
|
||||
@@ -389,33 +511,52 @@ export default function JournalEntryList() {
|
||||
}
|
||||
if (search) params.set('search', search)
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
||||
if (!res.ok) {
|
||||
try {
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
||||
if (!isCurrent()) return
|
||||
if (!res.ok) {
|
||||
// Surface the failure: stale rows (if any) stay on screen, the empty
|
||||
// case renders the error state below, and the toast covers refetches.
|
||||
setLoadFailed(true)
|
||||
toast({ title: t('load_failed_title'), variant: 'destructive' })
|
||||
setHasLoaded(true)
|
||||
return
|
||||
}
|
||||
setLoadFailed(false)
|
||||
const { data, count: total } = await res.json()
|
||||
if (!isCurrent()) return
|
||||
const loadedEntries = data || []
|
||||
setEntries(loadedEntries)
|
||||
setCount(total || 0)
|
||||
|
||||
// The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges
|
||||
// on draftCount. When the committed list comes back empty, resolve the draft
|
||||
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
||||
// a stale count of 0. Every other case refreshes the badge in the background.
|
||||
if (loadedEntries.length === 0 && listMode === 'committed') {
|
||||
await fetchDraftCount()
|
||||
} else {
|
||||
fetchDraftCount()
|
||||
}
|
||||
if (!isCurrent()) return
|
||||
setHasLoaded(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const { data, count: total } = await res.json()
|
||||
const loadedEntries = data || []
|
||||
setEntries(loadedEntries)
|
||||
setCount(total || 0)
|
||||
|
||||
// The pristine empty card vs. the (toggle-bearing) "drafts exist" state hinges
|
||||
// on draftCount. When the committed list comes back empty, resolve the draft
|
||||
// count BEFORE clearing loading so the toggle doesn't flash out for a frame on
|
||||
// a stale count of 0. Every other case refreshes the badge in the background.
|
||||
if (loadedEntries.length === 0 && listMode === 'committed') {
|
||||
await fetchDraftCount()
|
||||
} else {
|
||||
fetchDraftCount()
|
||||
// Fetch attachment counts + rättelse markers for the loaded entries,
|
||||
// carrying the generation guard so their late completions can't
|
||||
// overwrite metadata a newer request just rendered.
|
||||
const ids = loadedEntries.map((e: JournalEntry) => e.id)
|
||||
fetchAttachmentCounts(ids, isCurrent)
|
||||
fetchRattelseFlags(ids, isCurrent)
|
||||
} catch {
|
||||
// Network-level rejection (offline, aborted response body): same
|
||||
// surfacing as a non-OK response, or the list stays dimmed forever.
|
||||
if (!isCurrent()) return
|
||||
setLoadFailed(true)
|
||||
setHasLoaded(true)
|
||||
toast({ title: t('load_failed_title'), variant: 'destructive' })
|
||||
} finally {
|
||||
if (isCurrent()) setLoading(false)
|
||||
}
|
||||
setHasLoaded(true)
|
||||
setLoading(false)
|
||||
|
||||
// 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
|
||||
@@ -431,10 +572,14 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// The stack serializes to a string for the dependency array: a stable
|
||||
// primitive, so refetches fire on real sort changes, not array identity.
|
||||
const sortParam = serializeSortStack(sortStack)
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortHydrated || !periodHydrated || !pageSizeHydrated) return
|
||||
fetchEntries()
|
||||
}, [periodId, page, pageSize, sortBy, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
|
||||
}, [periodId, page, pageSize, sortParam, dateFrom, dateTo, seriesFilter, search, listMode, collapseCorrections, sortHydrated, periodHydrated, pageSizeHydrated])
|
||||
|
||||
const handleAttachmentCountChange = useCallback((entryId: string, count: number) => {
|
||||
setAttachmentCounts((prev) => ({ ...prev, [entryId]: count }))
|
||||
@@ -691,6 +836,53 @@ export default function JournalEntryList() {
|
||||
}
|
||||
}
|
||||
|
||||
// Apply a sort stack, whether it came from a column header or the dialog
|
||||
// select. Resets to the first page and persists the choice per company.
|
||||
const applySort = (next: SortKey[]) => {
|
||||
setSortStack(next)
|
||||
setPage(0)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(
|
||||
SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'),
|
||||
serializeSortStack(next),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Plain click: single-key tri-state, replacing any stack: ascending ->
|
||||
// descending -> back to the default order. On the DATUM column descending
|
||||
// IS the default, so that column degenerates to a plain asc/desc toggle
|
||||
// instead of wasting the third click on a no-op.
|
||||
// Shift-click stacks (max 3 keys): adds the column as the next priority
|
||||
// (ascending), a second shift-click flips it, a third removes it again.
|
||||
const handleHeaderSort = (column: SortColumn, additive: boolean) => {
|
||||
if (additive) {
|
||||
const index = sortStack.findIndex((k) => k.column === column)
|
||||
if (index === -1) {
|
||||
if (sortStack.length >= MAX_SORT_KEYS) return
|
||||
applySort([...sortStack, { column, direction: 'asc' }])
|
||||
} else if (sortStack[index].direction === 'asc') {
|
||||
applySort(
|
||||
sortStack.map((k, i) => (i === index ? { column, direction: 'desc' as const } : k)),
|
||||
)
|
||||
} else {
|
||||
const next = sortStack.filter((_, i) => i !== index)
|
||||
applySort(next.length > 0 ? next : DEFAULT_SORT_STACK)
|
||||
}
|
||||
return
|
||||
}
|
||||
const solo = sortStack.length === 1 && sortStack[0].column === column ? sortStack[0] : null
|
||||
if (!solo) {
|
||||
applySort([{ column, direction: 'asc' }])
|
||||
} else if (solo.direction === 'asc') {
|
||||
applySort([{ column, direction: 'desc' }])
|
||||
} else if (sortStacksEqual(sortStack, DEFAULT_SORT_STACK)) {
|
||||
applySort([{ column, direction: 'asc' }])
|
||||
} else {
|
||||
applySort(DEFAULT_SORT_STACK)
|
||||
}
|
||||
}
|
||||
|
||||
const clearAllFilters = () => {
|
||||
setPeriodId(null)
|
||||
// Mirror the selector's "Alla räkenskapsår" write so the cleared scope
|
||||
@@ -728,7 +920,7 @@ export default function JournalEntryList() {
|
||||
// whole component: every other empty state (a draft exists, or we're in the
|
||||
// drafts view) must fall through to the main render below so the
|
||||
// Verifikat/Utkast toggle stays reachable.
|
||||
if (!loading && entries.length === 0 && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
if (!loading && entries.length === 0 && !loadFailed && !hasActiveFilters && listMode === 'committed' && draftCount === 0) {
|
||||
return (
|
||||
<DataList className="stagger-enter">
|
||||
<DataListEmpty
|
||||
@@ -839,15 +1031,14 @@ export default function JournalEntryList() {
|
||||
{/* Sortering */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">{t('filter_section_sort')}</Label>
|
||||
{/* The select models a SINGLE sort key: it shows the primary
|
||||
key of the stack, and picking an option resets the whole
|
||||
stack to that one key. Stacking lives on the headers. */}
|
||||
<Select
|
||||
value={sortBy}
|
||||
value={`${sortStack[0].column}_${sortStack[0].direction}`}
|
||||
onValueChange={(v) => {
|
||||
const next = v as SortBy
|
||||
setSortBy(next)
|
||||
setPage(0)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(SORT_STORAGE_KEY_PREFIX + (company?.id ?? 'default'), next)
|
||||
}
|
||||
const parsed = parseSortStack(v)
|
||||
if (parsed) applySort(parsed)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-9 w-full text-sm">
|
||||
@@ -858,8 +1049,13 @@ export default function JournalEntryList() {
|
||||
<SelectItem value="date_asc">{t('sort_date_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_asc">{t('sort_voucher_asc')}</SelectItem>
|
||||
<SelectItem value="voucher_desc">{t('sort_voucher_desc')}</SelectItem>
|
||||
<SelectItem value="total_desc">{t('sort_total_desc')}</SelectItem>
|
||||
<SelectItem value="total_asc">{t('sort_total_asc')}</SelectItem>
|
||||
<SelectItem value="description_asc">{t('sort_description_asc')}</SelectItem>
|
||||
<SelectItem value="description_desc">{t('sort_description_desc')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{t('sort_stack_hint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Verifikationsserie */}
|
||||
@@ -989,6 +1185,22 @@ export default function JournalEntryList() {
|
||||
<DataList className="stagger-enter">
|
||||
<DataListLoading />
|
||||
</DataList>
|
||||
) : loadFailed && filteredEntries.length === 0 ? (
|
||||
// Failed load with nothing on screen: an explicit error state with a
|
||||
// retry, never the "empty ledger" card. The toolbar above stays
|
||||
// mounted so the sort/filter that caused the failure can be changed.
|
||||
<DataList className={cn('stagger-enter', loading && 'opacity-60')} aria-busy={loading || undefined}>
|
||||
<DataListEmpty
|
||||
icon={<CircleSlash className="h-6 w-6" />}
|
||||
title={t('load_failed_title')}
|
||||
description={t('load_failed_description')}
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={() => fetchEntries()}>
|
||||
{t('load_failed_retry')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</DataList>
|
||||
) : filteredEntries.length === 0 ? (
|
||||
// Empty placeholder, scoped to the situation: an empty drafts view, a
|
||||
// filtered committed view with no matches, or a committed view with no
|
||||
@@ -1088,10 +1300,38 @@ export default function JournalEntryList() {
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[26px] !pl-1')} aria-hidden="true"></th>
|
||||
<th className={TH_CLASS}>{t('th_voucher')}</th>
|
||||
<th className={cn(TH_CLASS, 'hidden sm:table-cell')}>{t('th_date')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-full')}>{t('th_description')}</th>
|
||||
<th className={cn(TH_CLASS, 'text-right')}>{t('th_amount')}</th>
|
||||
<SortableHeader
|
||||
label={t('th_voucher')}
|
||||
sortLabel={t('sort_by', { column: t('th_voucher') })}
|
||||
column="voucher"
|
||||
stack={sortStack}
|
||||
onSort={handleHeaderSort}
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_date')}
|
||||
sortLabel={t('sort_by', { column: t('th_date') })}
|
||||
column="date"
|
||||
stack={sortStack}
|
||||
onSort={handleHeaderSort}
|
||||
className="hidden sm:table-cell"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_description')}
|
||||
sortLabel={t('sort_by', { column: t('th_description') })}
|
||||
column="description"
|
||||
stack={sortStack}
|
||||
onSort={handleHeaderSort}
|
||||
className="w-full"
|
||||
/>
|
||||
<SortableHeader
|
||||
label={t('th_amount')}
|
||||
sortLabel={t('sort_by', { column: t('th_amount') })}
|
||||
column="total"
|
||||
stack={sortStack}
|
||||
onSort={handleHeaderSort}
|
||||
className="text-right"
|
||||
align="right"
|
||||
/>
|
||||
<th className={TH_CLASS} aria-hidden="true"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Inbox,
|
||||
@@ -38,6 +44,7 @@ import {
|
||||
ChevronDown,
|
||||
Sparkles,
|
||||
MessageCircle,
|
||||
Maximize2,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { cn, formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -600,13 +607,18 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { data } = await res.json()
|
||||
if (docRequestRef.current !== itemId) return
|
||||
const url: string | null = data?.download_url ?? null
|
||||
// HTML mail underlag renders via the same-origin inline proxy: it
|
||||
// serves text/html with a CSP sandbox header and guaranteed inline
|
||||
// disposition. Other types keep the signed storage URL.
|
||||
const effectiveUrl =
|
||||
data?.mime_type === 'text/html' ? `/api/documents/${documentId}/inline` : url
|
||||
if (!url) {
|
||||
// The document row exists but no signed URL came back: still a load
|
||||
// failure, not an absent underlag.
|
||||
setDocState('error')
|
||||
return
|
||||
}
|
||||
setDocUrl(url)
|
||||
setDocUrl(effectiveUrl)
|
||||
setDocMime(data?.mime_type ?? null)
|
||||
setDocState('ready')
|
||||
} catch {
|
||||
@@ -1669,6 +1681,19 @@ export function DocumentPreview({
|
||||
className="block max-h-[calc(100vh-9rem)] max-w-full w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : docMime === 'text/html' ? (
|
||||
// HTML mail underlag: arbitrary sender-controlled markup. sandbox
|
||||
// with no tokens = opaque origin, no scripts, no forms, no popups.
|
||||
// bg-white because mail HTML assumes a white canvas and would render
|
||||
// transparent (unreadable in dark mode) otherwise.
|
||||
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
|
||||
<iframe
|
||||
src={docUrl}
|
||||
sandbox=""
|
||||
className="w-full h-full border-0 bg-white"
|
||||
title="Underlag"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// PDF: iframe needs explicit height, frame fills the available pane.
|
||||
<div className="h-full w-full max-w-3xl bg-background rounded-md border overflow-hidden">
|
||||
@@ -2223,6 +2248,14 @@ function FieldsRail({
|
||||
const isResolved = isProcessed || isBookedDirectly
|
||||
const [isUnmatchingTx, setIsUnmatchingTx] = useState(false)
|
||||
const [isRetrying, setIsRetrying] = useState(false)
|
||||
// Larger edit surface for the extracted fields (the rail is deliberately
|
||||
// compact and users found it hard to read/fill: dialog reuses the same
|
||||
// autosaving list at a comfortable size). Closes on item switch so it never
|
||||
// shows fields for a row other than the selected one.
|
||||
const [fieldsExpanded, setFieldsExpanded] = useState(false)
|
||||
useEffect(() => {
|
||||
setFieldsExpanded(false)
|
||||
}, [item.id])
|
||||
const t = useTranslations('inbox_workspace')
|
||||
|
||||
// WhatsApp chat context: verified human answers captured by the intake bot
|
||||
@@ -2455,9 +2488,23 @@ function FieldsRail({
|
||||
|
||||
{/* Extracted fields */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium mb-3">
|
||||
Extraherade fält
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs uppercase tracking-wide text-muted-foreground font-medium">
|
||||
Extraherade fält
|
||||
</h3>
|
||||
{!item.isPlaceholder && (hasAnyExtractedField(data) || hasAi) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 -my-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setFieldsExpanded(true)}
|
||||
aria-label={t('expand_fields')}
|
||||
title={t('expand_fields')}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{item.isPlaceholder ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-foreground italic flex items-center gap-2 mb-2">
|
||||
@@ -2661,6 +2708,23 @@ function FieldsRail({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expanded fields editor: same autosaving list as the rail, at a
|
||||
readable size. Convention 13: centered modal. */}
|
||||
<Dialog open={fieldsExpanded} onOpenChange={setFieldsExpanded}>
|
||||
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Extraherade fält</DialogTitle>
|
||||
</DialogHeader>
|
||||
<EditableFieldsList
|
||||
itemId={item.id}
|
||||
data={data ?? emptyExtraction()}
|
||||
disabled={isResolved}
|
||||
onUpdated={onFieldsUpdated}
|
||||
variant="expanded"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2748,11 +2812,16 @@ export function EditableFieldsList({
|
||||
data,
|
||||
disabled,
|
||||
onUpdated,
|
||||
variant = 'rail',
|
||||
}: {
|
||||
itemId: string
|
||||
data: InvoiceExtractionResult
|
||||
disabled: boolean
|
||||
onUpdated: (data: InvoiceExtractionResult) => void
|
||||
/** 'rail' = the compact inline list in the side rail; 'expanded' = the
|
||||
* larger two-column layout inside the fields dialog. Same behavior,
|
||||
* different density. */
|
||||
variant?: 'rail' | 'expanded'
|
||||
}) {
|
||||
const { toast } = useToast()
|
||||
const [drafts, setDrafts] = useState<Record<FieldKey, string>>(() =>
|
||||
@@ -2911,13 +2980,24 @@ export function EditableFieldsList({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className={
|
||||
variant === 'expanded'
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3'
|
||||
: 'space-y-2'
|
||||
}
|
||||
>
|
||||
{shownFields.map((f) => (
|
||||
<div key={f.key} className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label
|
||||
htmlFor={`field-${f.key}`}
|
||||
className="text-[10px] uppercase tracking-wide text-muted-foreground/80"
|
||||
// The rail and the expanded dialog can be mounted at the same
|
||||
// time: the variant keeps the input ids unique between them.
|
||||
htmlFor={`field-${variant}-${f.key}`}
|
||||
className={cn(
|
||||
'uppercase tracking-wide text-muted-foreground/80',
|
||||
variant === 'expanded' ? 'text-xs' : 'text-[10px]'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</label>
|
||||
@@ -2927,7 +3007,7 @@ export function EditableFieldsList({
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
id={`field-${f.key}`}
|
||||
id={`field-${variant}-${f.key}`}
|
||||
type={f.type}
|
||||
inputMode={f.inputMode}
|
||||
value={drafts[f.key]}
|
||||
@@ -2936,7 +3016,9 @@ export function EditableFieldsList({
|
||||
disabled={disabled}
|
||||
placeholder="-"
|
||||
className={cn(
|
||||
'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
|
||||
variant === 'expanded'
|
||||
? 'h-9 text-sm border-border bg-transparent px-3 focus-visible:border-ring'
|
||||
: 'h-8 text-sm border-transparent bg-transparent px-2 -mx-2 hover:border-border focus-visible:border-ring',
|
||||
drafts[f.key] === '' && 'text-muted-foreground/50 italic'
|
||||
)}
|
||||
/>
|
||||
@@ -2946,13 +3028,16 @@ export function EditableFieldsList({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAllFields(true)}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground hover:underline pt-1"
|
||||
className={cn(
|
||||
'text-[11px] text-muted-foreground hover:text-foreground hover:underline pt-1 text-left',
|
||||
variant === 'expanded' && 'sm:col-span-2'
|
||||
)}
|
||||
>
|
||||
Visa fakturafält ({hiddenCount})
|
||||
</button>
|
||||
)}
|
||||
{vatRows.length > 0 && (
|
||||
<div className="pt-2 border-t mt-3">
|
||||
<div className={cn('pt-2 border-t mt-3', variant === 'expanded' && 'sm:col-span-2 mt-1')}>
|
||||
<p className="text-[10px] uppercase tracking-wide text-muted-foreground/80 mb-1.5">
|
||||
Momsfördelning
|
||||
</p>
|
||||
@@ -2970,7 +3055,12 @@ export function EditableFieldsList({
|
||||
</div>
|
||||
)}
|
||||
{disabled && (
|
||||
<p className="text-[10px] text-muted-foreground/70 pt-2">
|
||||
<p
|
||||
className={cn(
|
||||
'text-[10px] text-muted-foreground/70 pt-2',
|
||||
variant === 'expanded' && 'sm:col-span-2 text-xs'
|
||||
)}
|
||||
>
|
||||
Posten är kopplad till en leverantörsfaktura: fälten kan inte ändras.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataListEmpty } from '@/components/ui/data-list'
|
||||
import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
|
||||
import { ContextPicker } from '@/components/common/ContextPicker'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
@@ -47,8 +46,9 @@ interface TransactionHistoryListProps {
|
||||
transactions: TransactionWithInvoice[]
|
||||
skvRows?: SkattekontoTransactionWithSuggestion[]
|
||||
searchTerm?: string
|
||||
/** Page-level source chip selection (the toolbar ContextPicker on
|
||||
* /transactions, shared with the inbox mode). */
|
||||
sourceFilter: SourceFilter
|
||||
onSourceFilterChange: (sourceFilter: SourceFilter) => void
|
||||
/** Underlag status per journal_entry_id (computeJeUnderlagStatus): drives
|
||||
* the per-row "Underlag"/"Underlag saknas" badges on booked rows. */
|
||||
jeUnderlagStatus?: Record<string, JeUnderlagStatus>
|
||||
@@ -79,7 +79,6 @@ export default function TransactionHistoryList({
|
||||
skvRows = [],
|
||||
searchTerm = '',
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
jeUnderlagStatus,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
@@ -99,6 +98,13 @@ export default function TransactionHistoryList({
|
||||
// is_business flag. So when the filter is 'business' or 'private' we
|
||||
// implicitly hide SKV.
|
||||
const bankFiltered = transactions.filter((tx) => {
|
||||
if (
|
||||
sourceFilter.startsWith('acct:') &&
|
||||
tx.cash_account_id !== sourceFilter.slice('acct:'.length)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (sourceFilter === 'bank:other' && tx.cash_account_id != null) return false
|
||||
const matchesSearch = tx.description.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
const matchesFilter =
|
||||
filter === 'all' ||
|
||||
@@ -118,7 +124,9 @@ export default function TransactionHistoryList({
|
||||
merged.push({ source: 'bank', date: tx.date, data: tx })
|
||||
}
|
||||
}
|
||||
if (sourceFilter !== 'bank') {
|
||||
// Skattekonto rows belong to no cash account: any bank-side narrowing
|
||||
// ('bank', 'bank:other', 'acct:<id>') hides them.
|
||||
if (sourceFilter === 'all' || sourceFilter === 'skatteverket') {
|
||||
for (const r of skvFiltered) {
|
||||
merged.push({ source: 'skatteverket', date: r.transaktionsdatum, data: r })
|
||||
}
|
||||
@@ -128,7 +136,6 @@ export default function TransactionHistoryList({
|
||||
return a.source === 'bank' ? -1 : 1
|
||||
})
|
||||
|
||||
const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0)
|
||||
const filtered = merged
|
||||
|
||||
const FILTERS: Array<{ key: HistoryFilter; labelKey: string }> = [
|
||||
@@ -139,7 +146,7 @@ export default function TransactionHistoryList({
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Business/private seg + source chip, mirroring the inbox toolbar. */}
|
||||
{/* Business/private seg; the source chip lives in the page toolbar. */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="inline-flex shrink-0 gap-0.5 rounded-lg bg-muted/70 p-[3px]" role="tablist">
|
||||
{FILTERS.map(({ key, labelKey }) => (
|
||||
@@ -160,26 +167,6 @@ export default function TransactionHistoryList({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{showSourceFilter && (
|
||||
<div className="ml-auto">
|
||||
<ContextPicker
|
||||
value={sourceFilter}
|
||||
onChange={(id) => onSourceFilterChange(id as SourceFilter)}
|
||||
triggerLabel={
|
||||
sourceFilter === 'all'
|
||||
? t('source_all')
|
||||
: sourceFilter === 'bank'
|
||||
? t('source_bank')
|
||||
: t('source_skatteverket')
|
||||
}
|
||||
items={[
|
||||
{ id: 'all', label: t('source_all') },
|
||||
{ id: 'bank', label: t('source_bank') },
|
||||
{ id: 'skatteverket', label: t('source_skatteverket') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
@@ -229,7 +216,12 @@ export default function TransactionHistoryList({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMore && onLoadMore && !searchTerm && filtered.length > 0 && (
|
||||
{/* Pagination pages BANK rows: keep it reachable whenever more exist,
|
||||
even when the current page has no rows matching an acct:/bank:other
|
||||
scope (filtered.length would hide the only way to older matches),
|
||||
and drop it for the skattekonto scope where it cannot change the
|
||||
visible list. */}
|
||||
{hasMore && onLoadMore && !searchTerm && sourceFilter !== 'skatteverket' && (
|
||||
<div className="flex justify-center">
|
||||
<Button variant="outline" onClick={onLoadMore} disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (
|
||||
|
||||
@@ -9,7 +9,11 @@ export interface TransactionWithInvoice extends Transaction {
|
||||
// Page view modes
|
||||
export type ViewMode = 'inbox' | 'history'
|
||||
export type HistoryFilter = 'all' | 'business' | 'private'
|
||||
export type SourceFilter = 'all' | 'bank' | 'skatteverket'
|
||||
// Source filter (concept scene 10 account chooser), shared by both view modes:
|
||||
// everything, one cash account ('acct:<id>'), bank rows not yet tied to a
|
||||
// registered cash account ('bank:other'), all bank rows ('bank': the fallback
|
||||
// split when no cash accounts are registered), or the skattekonto side.
|
||||
export type SourceFilter = 'all' | 'bank' | 'bank:other' | 'skatteverket' | `acct:${string}`
|
||||
|
||||
// Handler types
|
||||
// Returns the journal_entry_id on success, null on failure
|
||||
|
||||
Reference in New Issue
Block a user