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:
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import { useFormatter, useTranslations } from 'next-intl'
|
||||
import { ExternalLink, Mail, Send } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import type { InvoiceDelivery } from '@/types'
|
||||
|
||||
export type InvoiceDeliveryView = Pick<
|
||||
InvoiceDelivery,
|
||||
| 'id'
|
||||
| 'channel'
|
||||
| 'to_addresses'
|
||||
| 'cc_addresses'
|
||||
| 'provider'
|
||||
| 'error_code'
|
||||
| 'document_attachment_id'
|
||||
| 'sent_at'
|
||||
| 'failed_at'
|
||||
| 'created_at'
|
||||
> & {
|
||||
status: 'pending' | 'sent' | 'failed' | 'marked_sent'
|
||||
}
|
||||
|
||||
interface InvoiceDeliveryHistoryProps {
|
||||
deliveries: InvoiceDeliveryView[]
|
||||
showLegacyEmptyState: boolean
|
||||
}
|
||||
|
||||
const statusVariant = {
|
||||
pending: 'secondary',
|
||||
sent: 'success',
|
||||
failed: 'destructive',
|
||||
marked_sent: 'outline',
|
||||
} as const
|
||||
|
||||
export function InvoiceDeliveryHistory({
|
||||
deliveries,
|
||||
showLegacyEmptyState,
|
||||
}: InvoiceDeliveryHistoryProps) {
|
||||
const t = useTranslations('invoice_detail')
|
||||
const format = useFormatter()
|
||||
|
||||
if (deliveries.length === 0 && !showLegacyEmptyState) return null
|
||||
|
||||
const formatTimestamp = (value: string) =>
|
||||
format.dateTime(new Date(value), {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
return (
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
{t('delivery_history_title')}
|
||||
</CardTitle>
|
||||
<CardDescription>{t('delivery_history_description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{deliveries.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
|
||||
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{deliveries.map((delivery) => {
|
||||
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
|
||||
const isManual = delivery.channel === 'manual'
|
||||
|
||||
return (
|
||||
<details key={delivery.id} className="group rounded-lg border bg-card">
|
||||
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
|
||||
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium">
|
||||
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground tabular-nums">
|
||||
{formatTimestamp(occurredAt)}
|
||||
</span>
|
||||
</span>
|
||||
<Badge variant={statusVariant[delivery.status]}>
|
||||
{t(`delivery_status_${delivery.status}`)}
|
||||
</Badge>
|
||||
</summary>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<Separator className="mb-4" />
|
||||
{isManual ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('delivery_manual_unknown_details')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 text-sm">
|
||||
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
|
||||
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
|
||||
{delivery.cc_addresses.length > 0 && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
|
||||
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{delivery.document_attachment_id && (
|
||||
<Button asChild variant="outline" size="sm" className="max-w-full">
|
||||
<a
|
||||
href={`/api/documents/${delivery.document_attachment_id}/inline`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
<span className="truncate">
|
||||
{t('delivery_open_pdf', { filename: t('delivery_pdf_fallback') })}
|
||||
</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(delivery.provider || delivery.error_code) && (
|
||||
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
|
||||
{delivery.provider && (
|
||||
<>
|
||||
<dt>{t('delivery_provider_label')}</dt>
|
||||
<dd>{delivery.provider}</dd>
|
||||
</>
|
||||
)}
|
||||
{delivery.error_code && (
|
||||
<>
|
||||
<dt>{t('delivery_error_label')}</dt>
|
||||
<dd>{delivery.error_code}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import { useCompany } from '@/contexts/CompanyContext'
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, BASAccount, EntityType } from '@/types'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
type DuplicateMatchReason = 'ocr_exact' | 'name_amount_fuzzy' | 'amount_only'
|
||||
|
||||
@@ -77,7 +78,13 @@ export default function PaymentBookingDialog({
|
||||
}
|
||||
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [lines, setLines] = useState<FormLine[]>([])
|
||||
const accountNameByNumber = useMemo(() => {
|
||||
const names = new Map(catalog.map((account) => [account.account_number, account.account_name]))
|
||||
for (const account of accounts) names.set(account.account_number, account.account_name)
|
||||
return names
|
||||
}, [accounts, catalog])
|
||||
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@@ -108,7 +115,10 @@ export default function PaymentBookingDialog({
|
||||
async function init() {
|
||||
try {
|
||||
// Fetch accounts
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
const [accountsRes, fetchedCatalog] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
|
||||
const accountsData = await accountsRes.json()
|
||||
const fetchedAccounts: BASAccount[] = accountsData.data || []
|
||||
@@ -126,6 +136,7 @@ export default function PaymentBookingDialog({
|
||||
if (cancelled) return
|
||||
|
||||
setAccounts(fetchedAccounts)
|
||||
setCatalog(fetchedCatalog)
|
||||
|
||||
const accountingMethod = (settings?.accounting_method || 'accrual') as 'accrual' | 'cash'
|
||||
const entityType = (settings?.entity_type as EntityType) || 'enskild_firma'
|
||||
@@ -424,6 +435,7 @@ export default function PaymentBookingDialog({
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(val) => updateLine(index, 'account_number', val)}
|
||||
selectedName={accountNameByNumber.get(line.account_number)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -490,6 +502,7 @@ export default function PaymentBookingDialog({
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(val) => updateLine(index, 'account_number', val)}
|
||||
selectedName={accountNameByNumber.get(line.account_number)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Loader2, Mail, Plus, Send, Trash2 } from 'lucide-react'
|
||||
import type { FormLine } from '@/components/bookkeeping/JournalEntryForm'
|
||||
import type { Invoice, InvoiceItem, Customer, EntityType, BASAccount } from '@/types'
|
||||
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { loadBasCatalog, type CatalogAccount } from '@/lib/bookkeeping/bas-catalog-client'
|
||||
|
||||
interface InvoiceWithRelations extends Invoice {
|
||||
customer: Customer
|
||||
@@ -68,8 +69,14 @@ export default function SendInvoiceDialog({
|
||||
const [shouldBookOnIssue, setShouldBookOnIssue] = useState(true)
|
||||
const [deferBooking, setDeferBooking] = useState(false)
|
||||
const [accounts, setAccounts] = useState<BASAccount[]>([])
|
||||
const [catalog, setCatalog] = useState<CatalogAccount[]>([])
|
||||
const [editLines, setEditLines] = useState<FormLine[]>([])
|
||||
const [hasEdited, setHasEdited] = useState(false)
|
||||
const accountNameByNumber = useMemo(() => {
|
||||
const names = new Map(catalog.map((account) => [account.account_number, account.account_name]))
|
||||
for (const account of accounts) names.set(account.account_number, account.account_name)
|
||||
return names
|
||||
}, [accounts, catalog])
|
||||
|
||||
// The accrual book-at-issue path (both email send and manual mark-sent)
|
||||
// lets the user adjust the proposed lines before booking (same editor as
|
||||
@@ -133,16 +140,22 @@ export default function SendInvoiceDialog({
|
||||
// Line editing needs the chart of accounts; only the accrual
|
||||
// book-at-issue path renders the editor, so skip the fetch elsewhere.
|
||||
let fetchedAccounts: BASAccount[] = []
|
||||
let fetchedCatalog: CatalogAccount[] = []
|
||||
if (!invoice.credited_invoice_id && bookOnIssue && !hasAccrualItems) {
|
||||
const accountsRes = await fetch('/api/bookkeeping/accounts')
|
||||
const [accountsRes, catalogResult] = await Promise.all([
|
||||
fetch('/api/bookkeeping/accounts'),
|
||||
loadBasCatalog(),
|
||||
])
|
||||
if (!accountsRes.ok) throw new Error(t('load_chart_failed'))
|
||||
const accountsData = await accountsRes.json()
|
||||
fetchedAccounts = accountsData.data || []
|
||||
fetchedCatalog = catalogResult
|
||||
}
|
||||
|
||||
if (cancelled) return
|
||||
|
||||
setAccounts(fetchedAccounts)
|
||||
setCatalog(fetchedCatalog)
|
||||
setEntityType((settingsResult.data?.entity_type as EntityType) || 'enskild_firma')
|
||||
setPeriodName(periodResult.data?.name || '')
|
||||
setDeferBooking(!!settingsResult.data?.defer_invoice_booking)
|
||||
@@ -427,6 +440,7 @@ export default function SendInvoiceDialog({
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(val) => updateLine(index, 'account_number', val)}
|
||||
selectedName={accountNameByNumber.get(line.account_number)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@@ -504,6 +518,7 @@ export default function SendInvoiceDialog({
|
||||
value={line.account_number}
|
||||
accounts={accounts}
|
||||
onChange={(val) => updateLine(index, 'account_number', val)}
|
||||
selectedName={accountNameByNumber.get(line.account_number)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -45,14 +45,12 @@ import { TransactionAttachmentIndicator } from './TransactionAttachmentIndicator
|
||||
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
|
||||
import { useCanWrite } from '@/lib/hooks/use-can-write'
|
||||
import type { JeUnderlagStatus } from '@/lib/transactions/underlag-status'
|
||||
import type { TransactionWithInvoice, HistoryFilter } from './transaction-types'
|
||||
import type { TransactionWithInvoice, HistoryFilter, SourceFilter } from './transaction-types'
|
||||
import type {
|
||||
SkattekontoTransactionWithSuggestion,
|
||||
StoredSkattekontoTransaction,
|
||||
} from '@/types/skatteverket'
|
||||
|
||||
type SourceFilter = 'all' | 'bank' | 'skatteverket'
|
||||
|
||||
type HistoryRow =
|
||||
| { source: 'bank'; date: string; data: TransactionWithInvoice }
|
||||
| { source: 'skatteverket'; date: string; data: SkattekontoTransactionWithSuggestion }
|
||||
@@ -61,6 +59,8 @@ interface TransactionHistoryListProps {
|
||||
transactions: TransactionWithInvoice[]
|
||||
skvRows?: SkattekontoTransactionWithSuggestion[]
|
||||
searchTerm?: string
|
||||
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>
|
||||
@@ -80,6 +80,8 @@ export default function TransactionHistoryList({
|
||||
transactions,
|
||||
skvRows = [],
|
||||
searchTerm = '',
|
||||
sourceFilter,
|
||||
onSourceFilterChange,
|
||||
jeUnderlagStatus,
|
||||
onOpenMatchDialog,
|
||||
onOpenCategoryDialog,
|
||||
@@ -93,7 +95,6 @@ export default function TransactionHistoryList({
|
||||
}: TransactionHistoryListProps) {
|
||||
const t = useTranslations('tx_history')
|
||||
const [filter, setFilter] = useState<HistoryFilter>('all')
|
||||
const [sourceFilter, setSourceFilter] = useState<SourceFilter>('all')
|
||||
|
||||
// The bank/private filter doesn't apply to SKV rows: they have no
|
||||
// is_business flag. So when the filter is 'business' or 'private' we
|
||||
@@ -128,7 +129,7 @@ export default function TransactionHistoryList({
|
||||
return a.source === 'bank' ? -1 : 1
|
||||
})
|
||||
|
||||
const showSourceFilter = skvRows.length > 0 && transactions.length > 0
|
||||
const showSourceFilter = sourceFilter !== 'all' || (skvRows.length > 0 && transactions.length > 0)
|
||||
const filtered = merged
|
||||
const showHeader = showSourceFilter
|
||||
|
||||
@@ -163,7 +164,7 @@ export default function TransactionHistoryList({
|
||||
<DropdownMenuContent align="start" className="min-w-[12rem]">
|
||||
<DropdownMenuRadioGroup
|
||||
value={sourceFilter}
|
||||
onValueChange={(v) => setSourceFilter(v as SourceFilter)}
|
||||
onValueChange={(v) => onSourceFilterChange(v as SourceFilter)}
|
||||
>
|
||||
<DropdownMenuRadioItem value="all">{t('source_all')}</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem value="bank">{t('source_bank')}</DropdownMenuRadioItem>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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'
|
||||
|
||||
// Handler types
|
||||
// Returns the journal_entry_id on success, null on failure
|
||||
|
||||
Reference in New Issue
Block a user