fix(skattekonto): look-alike beslut rows, the list-to-voucher round trip, makulerad rendering, huvudbok discoverability (#1297)
Four fixes from the exit mail Anders Orback (Center Node AB) sent hours after churning. His five points were mostly one job: reconciling skattekontot against banken before årsredovisningen. Skattekonto look-alike rows. Skatteverket splits a retroactive omprövningsbeslut across every month it re-charges and sends one transaction per month, sharing date, text and amount; only ranteberakningsdatum separates them, and we stored it but rendered it nowhere. A real company posted 15 such vouchers (67 785 kr across Feb 2025-Apr 2026) unable to tell them from duplicates of the automatic hämtning. Surface the field when it carries information: its month differs from the Datum column, or another row in the same band is otherwise indistinguishable. The list-to-voucher round trip. The verifikat list collapsed to a skeleton on every refetch and sprang back, moving rows under the pointer; only the first load shows a skeleton now. Filter state is React-only, so leaving the list loses it: add a hover-revealed open-in-new-tab affordance on the voucher list and the skattekonto page, where the link had been behind a hand-rolled opacity-0 that coarse pointers never trigger. Makulerad rendering. A stornoed verifikat now reads as struck out, per data cell rather than on the row, because text-decoration propagates and a child cannot opt out. Vouchers-per-account discoverability. /reports/huvudbok?account=1930 already existed; the palette matcher requires every token and the entry never contained the word "verifikat". Add ReportDescriptor.searchTerms plus a report-library search box. Also fixes a false "Saknar underlag" compliance chip that flashed before attachment counts resolved, and a keyboard-access regression where HOVER_REVEAL_CLASS carried focus-visible only, hiding controls inside a non-focusable wrapper from keyboard users. No migration. No write paths, storno paths or posted entries touched. Follow-ups filed: #1300 #1301 #1302 #1303 #1304 #1305 #1306 #1307 #1308. Open decision: #1305 (Omförd vs Makulerad). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
144cc51458
commit
fa394e3759
@@ -30,6 +30,7 @@ import {
|
||||
} from '@/components/common/FiscalYearSelector'
|
||||
import { FyPicker } from '@/components/common/FyPicker'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { OpenInNewTab } from '@/components/ui/open-in-new-tab'
|
||||
import {
|
||||
TH_CLASS,
|
||||
TD_CLASS,
|
||||
@@ -99,10 +100,20 @@ export default function JournalEntryList() {
|
||||
const [commitTarget, setCommitTarget] = useState<JournalEntry | null>(null)
|
||||
const [commitVoucherPreview, setCommitVoucherPreview] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Only the very first load may replace the table with a skeleton. Every
|
||||
// later refetch (filter, sort, page, search) keeps the rows on screen and
|
||||
// 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)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [count, setCount] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [attachmentCounts, setAttachmentCounts] = useState<Record<string, number>>({})
|
||||
// Counts arrive in a second request, after the rows are already painted.
|
||||
// Until they land, every row looks like it has no underlag, so rendering the
|
||||
// chip eagerly flashes a false "Saknar underlag" compliance warning on every
|
||||
// load, sort, filter and page change. Render nothing until we actually know.
|
||||
const [attachmentCountsLoaded, setAttachmentCountsLoaded] = useState(false)
|
||||
// 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.
|
||||
@@ -188,8 +199,13 @@ export default function JournalEntryList() {
|
||||
const fetchAttachmentCounts = useCallback(async (entryIds: string[]) => {
|
||||
if (entryIds.length === 0) {
|
||||
setAttachmentCounts({})
|
||||
setAttachmentCountsLoaded(true)
|
||||
return
|
||||
}
|
||||
// Deliberately keeps the previous counts in place while refetching: they
|
||||
// are keyed by entry id, so a row that survives the refetch keeps its true
|
||||
// count and a new row is covered by the loaded flag below.
|
||||
setAttachmentCountsLoaded(false)
|
||||
// The counts route caps each request at 50 IDs, so a large page ("Alla", or
|
||||
// 100/page) must be split into chunks and merged. Without this the whole
|
||||
// request 400s and every document-requiring row falsely shows the
|
||||
@@ -213,6 +229,8 @@ export default function JournalEntryList() {
|
||||
setAttachmentCounts(Object.assign({}, ...results))
|
||||
} catch {
|
||||
// Non-critical: silently ignore
|
||||
} finally {
|
||||
setAttachmentCountsLoaded(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -373,6 +391,7 @@ export default function JournalEntryList() {
|
||||
|
||||
const res = await fetch(`/api/bookkeeping/journal-entries?${params}`)
|
||||
if (!res.ok) {
|
||||
setHasLoaded(true)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
@@ -390,6 +409,7 @@ export default function JournalEntryList() {
|
||||
} else {
|
||||
fetchDraftCount()
|
||||
}
|
||||
setHasLoaded(true)
|
||||
setLoading(false)
|
||||
|
||||
// Fetch attachment counts + rättelse markers for the loaded entries
|
||||
@@ -965,7 +985,7 @@ export default function JournalEntryList() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
{loading && !hasLoaded ? (
|
||||
<DataList className="stagger-enter">
|
||||
<DataListLoading />
|
||||
</DataList>
|
||||
@@ -973,8 +993,13 @@ export default function JournalEntryList() {
|
||||
// Empty placeholder, scoped to the situation: an empty drafts view, a
|
||||
// filtered committed view with no matches, or a committed view with no
|
||||
// posted entries yet (but drafts exist, hence we got here, not the
|
||||
// pristine early return above).
|
||||
<DataList className="stagger-enter">
|
||||
// pristine early return above). Carries the same busy treatment as the
|
||||
// table branch: widening a filter from an empty result would otherwise
|
||||
// look identical to "still nothing" for the whole request.
|
||||
<DataList
|
||||
className={cn('stagger-enter', loading && 'opacity-60')}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
<DataListEmpty
|
||||
icon={
|
||||
listMode === 'drafts' || !hasActiveFilters ? (
|
||||
@@ -1000,7 +1025,13 @@ export default function JournalEntryList() {
|
||||
/>
|
||||
</DataList>
|
||||
) : (
|
||||
<div>
|
||||
<div
|
||||
aria-busy={loading || undefined}
|
||||
className={cn(
|
||||
'transition-opacity duration-150',
|
||||
loading && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
{/* Bulkbar (concept): hidden until at least one verifikat is
|
||||
selected via the hover checkboxes, then it pops in with the
|
||||
count and the batch actions. Select-all and the filter-scoped
|
||||
@@ -1071,6 +1102,16 @@ export default function JournalEntryList() {
|
||||
// Voucher total = sum of the debit side (= credit side when balanced).
|
||||
const voucherTotal = lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
|
||||
const selectable = canWrite && isEligibleForExempt(entry)
|
||||
// A makulerad verifikat should read as struck out, the way
|
||||
// Grundbok already renders it. Applied per data cell rather
|
||||
// than on the row: text-decoration propagates to descendants
|
||||
// and a child cannot opt out, so striking the <tr> would draw
|
||||
// a line through the row's action controls too.
|
||||
//
|
||||
// No opacity on the row: the strike plus the Makulerad chip
|
||||
// already carry the state, and dimming muted-foreground text
|
||||
// pushes the date column under the AA contrast floor.
|
||||
const struckCell = entry.status === 'reversed' ? 'line-through' : undefined
|
||||
|
||||
return (
|
||||
<Fragment key={entry.id}>
|
||||
@@ -1111,20 +1152,30 @@ export default function JournalEntryList() {
|
||||
)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
|
||||
<Link
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className="font-mono text-[13px] tabular-nums hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatVoucher(entry)}
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Link
|
||||
href={`/bookkeeping/${entry.id}`}
|
||||
className={cn(
|
||||
'font-mono text-[13px] tabular-nums hover:underline',
|
||||
struckCell,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
// The row's Enter/Space handler calls
|
||||
// preventDefault(), so without this the voucher
|
||||
// link expands the row instead of opening it.
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{formatVoucher(entry)}
|
||||
</Link>
|
||||
<OpenInNewTab href={`/bookkeeping/${entry.id}`} />
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'hidden sm:table-cell whitespace-nowrap tabular-nums text-muted-foreground')}>
|
||||
<td className={cn(TD_CLASS, 'hidden sm:table-cell whitespace-nowrap tabular-nums text-muted-foreground', struckCell)}>
|
||||
{formatDate(entry.entry_date)}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'max-w-0 w-full')}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">{entry.description}</span>
|
||||
<span className={cn('truncate', struckCell)}>{entry.description}</span>
|
||||
{entry.out_of_period && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -1148,7 +1199,7 @@ export default function JournalEntryList() {
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask')}>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums rr-mask', struckCell)}>
|
||||
{formatCurrency(voucherTotal, 'SEK', { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right py-[9px]')}>
|
||||
@@ -1168,7 +1219,7 @@ export default function JournalEntryList() {
|
||||
<span className="text-xs tabular-nums">{attachmentCounts[entry.id]}</span>
|
||||
</button>
|
||||
) : (
|
||||
NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
|
||||
attachmentCountsLoaded && NEEDS_ATTACHMENT.has(entry.source_type) && entry.status === 'posted' && (
|
||||
noDocRequired.has(entry.id) ? (
|
||||
<span title={t('no_doc_required_indicator_tooltip')}>
|
||||
<CircleSlash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
|
||||
@@ -58,7 +58,14 @@ const PAGE_ENTRIES: Entry[] = [
|
||||
{ id: 'rapport-balansrapport', label: 'Visa rapport: Balansrapport', icon: BarChart3, href: '/reports/balansrapport', keywords: 'rapport balans tillgångar skulder saldo per konto' },
|
||||
{ id: 'rapport-saldobalans', label: 'Visa rapport: Saldobalans', icon: BarChart3, href: '/reports/trial-balance', keywords: 'rapport saldobalans trial balance saldo per konto' },
|
||||
{ id: 'rapport-moms', label: 'Visa rapport: Momsdeklaration', icon: BarChart3, href: '/reports/vat-declaration', keywords: 'rapport moms vat deklaration' },
|
||||
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions' },
|
||||
// "verifikat" is the word a bookkeeper reaches for ("verifikat per konto"),
|
||||
// and matches() requires every typed token, so leaving it out made the exact
|
||||
// phrase return nothing even though this report is precisely the answer.
|
||||
// Deliberately NOT carrying "stäm av"/"avstämning" here: the palette
|
||||
// auto-selects the first hit and huvudbok is listed above Bankavstämning, so
|
||||
// those words would hijack Enter from the reconciliation page. They live in
|
||||
// ReportDescriptor.searchTerms instead, where the library shows a list.
|
||||
{ id: 'rapport-huvudbok', label: 'Visa rapport: Huvudbok', icon: BookOpen, href: '/reports/huvudbok', keywords: 'rapport huvudbok ledger general konto saldo transaktioner per konto verifikat verifikationer verifikationer per konto kontoutdrag kontoanalys kontokort kontohistorik balance account statement transactions vouchers' },
|
||||
{ id: 'rapport-kundreskontra', label: 'Visa rapport: Kundreskontra', icon: Users, href: '/reports/kundreskontra', keywords: 'rapport kundreskontra ar kundfordringar' },
|
||||
{ id: 'rapport-bankavstamning', label: 'Bankavstämning', hint: 'Stäm av bank mot bokföring', icon: ArrowLeftRight, href: '/reports/bank-reconciliation', keywords: 'avstämning stäm av bank matcha banktransaktioner reconcile reconciliation 1930' },
|
||||
{ id: 'importera', label: 'Importera', icon: Upload, href: '/import' },
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Search, X } from 'lucide-react'
|
||||
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { getLibrarySections, type ReportDescriptor } from '@/lib/reports/catalog'
|
||||
import {
|
||||
getLibrarySections,
|
||||
reportMatchesQuery,
|
||||
type ReportDescriptor,
|
||||
} from '@/lib/reports/catalog'
|
||||
import type { EntityType } from '@/types'
|
||||
|
||||
/**
|
||||
@@ -28,7 +35,26 @@ export function ReportLibrary({
|
||||
onOpen: (slug: string) => void
|
||||
}) {
|
||||
const t = useTranslations('reports')
|
||||
const sections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
|
||||
const [query, setQuery] = useState('')
|
||||
const allSections = getLibrarySections(entityType, hasEmployees, dimensionsEnabled)
|
||||
|
||||
// Matched against the translated name and description plus the descriptor's
|
||||
// synonyms, so the vocabulary someone arrives with ("verifikat per konto")
|
||||
// reaches the report even when we named it something else ("Huvudbok").
|
||||
const sections = useMemo(() => {
|
||||
if (!query.trim()) return allSections
|
||||
return allSections
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) =>
|
||||
reportMatchesQuery(
|
||||
`${t(item.labelKey)} ${t(item.descKey)} ${item.searchTerms ?? ''}`,
|
||||
query,
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0)
|
||||
}, [allSections, query, t])
|
||||
|
||||
const lastOpenedLabel = (slug: string): string => {
|
||||
const at = openedAt[slug]
|
||||
@@ -40,27 +66,65 @@ export function ReportLibrary({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto" role="region" aria-label={t('title')}>
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_report')}</th>
|
||||
<th className={TH_CLASS}>{t('col_description')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[130px] text-right')}>{t('col_last_opened')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="stagger-enter">
|
||||
{sections.map((section) => (
|
||||
<SectionRows
|
||||
key={section.category}
|
||||
label={t(section.labelKey)}
|
||||
items={section.items}
|
||||
lastOpenedLabel={lastOpenedLabel}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-4">
|
||||
<div className="relative max-w-sm">
|
||||
<Search
|
||||
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
// Deliberately not type="search": WebKit adds its own cancel button,
|
||||
// which would sit next to the X below as a second clear affordance.
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t('search_placeholder')}
|
||||
aria-label={t('search_placeholder')}
|
||||
className="h-9 pl-9 pr-9 text-[13px]"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label={t('search_clear')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-muted-foreground transition-colors duration-150 hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t('search_no_results', { query: query.trim() })}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto" role="region" aria-label={t('title')}>
|
||||
<table className="w-full border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(TH_CLASS, 'w-[240px]')}>{t('col_report')}</th>
|
||||
<th className={TH_CLASS}>{t('col_description')}</th>
|
||||
<th className={cn(TH_CLASS, 'w-[130px] text-right')}>{t('col_last_opened')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{/* Stagger is the entry animation for the library as it loads. It
|
||||
must not re-run per keystroke while filtering, or every widening
|
||||
edit replays a 360ms cascade under the user's eyes. */}
|
||||
<tbody className={query.trim() ? undefined : 'stagger-enter'}>
|
||||
{sections.map((section) => (
|
||||
<SectionRows
|
||||
key={section.category}
|
||||
label={t(section.labelKey)}
|
||||
items={section.items}
|
||||
lastOpenedLabel={lastOpenedLabel}
|
||||
onOpen={onOpen}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,12 @@ export const QUIET_LINK_CLASS =
|
||||
// pointers never fire hover, so without pointer-coarse: the control would be
|
||||
// permanently invisible and the action unreachable on touch. Always use this
|
||||
// constant instead of hand-rolling `opacity-0 group-hover:opacity-100`.
|
||||
//
|
||||
// focus-within is what makes this safe on a WRAPPER: focus-visible only matches
|
||||
// the element itself, so a non-focusable <span> holding the buttons would stay
|
||||
// transparent while a keyboard user tabbed through the controls inside it.
|
||||
export const HOVER_REVEAL_CLASS =
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 pointer-coarse:opacity-100'
|
||||
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100'
|
||||
|
||||
// Animated row expansion (concept vwrap/vinner): grid-rows 0fr -> 1fr on
|
||||
// mount; the global reduced-motion rule collapses the transition.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink } from 'lucide-react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HOVER_REVEAL_CLASS } from '@/components/ui/dry-table'
|
||||
|
||||
/**
|
||||
* Escape hatch out of a list without losing it.
|
||||
*
|
||||
* Working a filtered list (kontoavstämning, granskning) means opening one
|
||||
* record, fixing it, and coming back to the same list. Navigating in place
|
||||
* throws away the filter, the page and the scroll position, so the only way
|
||||
* to keep them is to open the record in a second tab. Most of our row links
|
||||
* are plain anchors, so cmd-click already works, but nothing on screen ever
|
||||
* says so and a few call sites are buttons where it does not work at all.
|
||||
*
|
||||
* Sits next to the record's own link rather than replacing it: the primary
|
||||
* click keeps navigating in place, which is what people expect. Hover-revealed
|
||||
* per the row-control convention, and always visible on coarse pointers.
|
||||
*/
|
||||
export function OpenInNewTab({
|
||||
href,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
href: string
|
||||
/** Overrides the default "Öppna i ny flik" for a more specific target. */
|
||||
label?: string
|
||||
className?: string
|
||||
}) {
|
||||
const t = useTranslations('common')
|
||||
const text = label ?? t('open_in_new_tab')
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
// Named by the visually-hidden text below rather than aria-label, so the
|
||||
// title renders a tooltip for sighted users without screen readers
|
||||
// announcing the same string twice.
|
||||
title={text}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
// Rows that own this control are themselves interactive: JournalEntryList
|
||||
// gives its <tr> an Enter/Space handler that calls preventDefault() and
|
||||
// expands the row. Without this, Enter on a focused link would expand the
|
||||
// row instead of opening the voucher, so the control would be usable with
|
||||
// a mouse but not a keyboard.
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
HOVER_REVEAL_CLASS,
|
||||
'relative inline-flex shrink-0 items-center rounded p-1 text-muted-foreground',
|
||||
// The icon stays 14px so the row keeps its density, but the pointer
|
||||
// target is padded out to the 40px the design rules require.
|
||||
'before:absolute before:left-1/2 before:top-1/2 before:h-10 before:w-10',
|
||||
'before:-translate-x-1/2 before:-translate-y-1/2 before:content-[""]',
|
||||
'transition-colors duration-150 hover:text-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">{text}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user