Fix/usr fdbck ch (#1105)

* fix(privacy): mask voucher amounts in session replays

* fix: persist transaction source filter

* fix: clarify invoice filenames and booking previews

* fix: truncate long uploaded filenames

* feat: add invoice delivery history

* fix: harden invoice delivery history

* fix: include invoice deliveries in full archive
This commit is contained in:
Mattsson
2026-07-22 18:49:57 +02:00
committed by GitHub
parent 3e1ea29d02
commit 321e684523
58 changed files with 3742 additions and 285 deletions
+14 -2
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react'
import { Plus } from 'lucide-react'
import { Input } from '@/components/ui/input'
import { getAccountClassName } from '@/lib/bookkeeping/account-descriptions'
@@ -41,15 +41,19 @@ interface AccountComboboxProps {
// to the next konteringsrad's account on Enter: see JournalEntryForm.focusAccount).
inputRef?: React.RefCallback<HTMLInputElement>
disabled?: boolean
// Optional always-visible label for compact editors where the selected
// account name must remain readable after the dropdown closes.
selectedName?: string
}
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false }: AccountComboboxProps) {
export default function AccountCombobox({ value, accounts, onChange, onCommit, onCreateAccount, catalog, notActivatedLabel = 'Aktiveras vid bokföring', className, inputRef, disabled = false, selectedName }: AccountComboboxProps) {
const [search, setSearch] = useState(value)
const [isOpen, setIsOpen] = useState(false)
const [highlightedIndex, setHighlightedIndex] = useState(0)
const containerRef = useRef<HTMLDivElement>(null)
const internalInputRef = useRef<HTMLInputElement>(null)
const listRef = useRef<HTMLDivElement>(null)
const selectedNameId = useId()
// Whether the user has typed or arrow-navigated since the field was focused.
// Enter only selects the highlighted item after an actual interaction: a
// bare Enter on a freshly-focused field must not grab the first account in
@@ -236,6 +240,8 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
}, 150)
}
const showSelectedName = Boolean(selectedName && value && search === value)
return (
<div ref={containerRef} className="relative">
<Input
@@ -249,8 +255,14 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o
className={`font-mono ${className ?? ''}`.trim()}
autoComplete="off"
disabled={disabled}
aria-describedby={showSelectedName ? selectedNameId : undefined}
/>
{showSelectedName ? (
<p id={selectedNameId} className="mt-1 break-words px-1 text-sm leading-snug text-foreground">
{selectedName}
</p>
) : null}
{/* Dropdown */}
{isOpen && !disabled && flatList.length > 0 && (
+38 -3
View File
@@ -30,6 +30,41 @@ let uploadCounter = 0
const MAX_FILE_SIZE = 10 * 1024 * 1024 // 10 MB
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp']
const ACCEPTED_EXTENSIONS = '.pdf,.jpg,.jpeg,.png,.webp'
const FILE_NAME_TAIL_LENGTH = 16
function TruncatedFileName({ fileName }: { fileName: string }) {
const characters = Array.from(fileName)
if (characters.length <= FILE_NAME_TAIL_LENGTH * 2) {
return (
<span className="min-w-0 flex-1 truncate" title={fileName}>
{fileName}
</span>
)
}
const splitAt = characters.length - FILE_NAME_TAIL_LENGTH
const start = characters.slice(0, splitAt).join('')
const end = characters.slice(splitAt).join('')
return (
<span
className="flex min-w-0 flex-1"
title={fileName}
>
<span className="sr-only">{fileName}</span>
<span aria-hidden="true" className="min-w-0 flex-1 truncate">
{start}
</span>
<span
aria-hidden="true"
className="max-w-1/2 shrink overflow-hidden whitespace-nowrap text-right"
>
{end}
</span>
</span>
)
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`
@@ -208,7 +243,7 @@ export default function DocumentUploadZone({
const isUploading = files.some((f) => f.status === 'uploading')
return (
<div className="space-y-2">
<div className="min-w-0 space-y-2">
{/* Drop zone */}
<div
className={`
@@ -253,14 +288,14 @@ export default function DocumentUploadZone({
{files.map((file, index) => (
<div
key={file.uploadKey}
className="flex items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
className="flex min-w-0 items-center gap-2 text-sm py-1.5 px-2 rounded bg-muted/50"
>
{isImageType(file.file.type) ? (
<ImageIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<FileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate flex-1">{file.fileName}</span>
<TruncatedFileName fileName={file.fileName} />
<span className="text-xs text-muted-foreground shrink-0">
{formatFileSize(file.fileSize)}
</span>
+1 -1
View File
@@ -1233,7 +1233,7 @@ export default function JournalEntryList() {
<JournalEntryStatusBadge entry={entry} showStatus={entry.status === 'reversed' || entry.status === 'draft'} />
)}
<span className="flex-1 truncate">{entry.description}</span>
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium sensitive-field">
<span className="shrink-0 w-28 text-right tabular-nums text-sm font-medium rr-mask">
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
</span>
<div className="flex shrink-0 items-center gap-1">