fix(ui): reach touch-only actions, confirm before posting, drop a dead ring (#1280)

* fix(ui): reach touch-only actions, confirm before posting, drop a dead ring

Three defects from the UI craft audit where the interface is wrong, not
just inconsistent.

Unreachable on touch: 'Markera klar' on deadlines and the bulk-select
checkbox on /pending were 'opacity-0 group-hover:opacity-100'. Coarse
pointers never fire hover and DeadlineForm has no completion control, so
on a phone there was no way to mark a deadline done at all. Factored the
reveal into HOVER_REVEAL_CLASS, which adds pointer-coarse:opacity-100.

Unguarded ledger writes: 'Bokför' on the journal-entry detail page and on
the invoice detail page posted an immutable verifikat on one click, one
screen after the list confirmed the identical action. Both now open a
ConfirmDialog describing the outcome up front (convention 10), reusing
the list's indicative voucher-number prefetch.

Dead ring: SummaryCard emitted ring-1 ring-primary/40 and ring-1
ring-warning/40 on the same element, so the two set the same custom
property and one silently lost. The override is an exception, so it is a
Badge now (conventions 5 and 12).

Also corrects the design.md primitives table, which named ui/table.tsx as
the data-table primitive while 17 files use ui/dry-table.tsx; building a
list page from the documented row produced ~15% taller rows and a
different hover tint, which had already happened twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(ui): let the post-confirm dialog close after a successful commit

ConfirmDialog calls onOpenChange(false) immediately after `await onConfirm()`
resolves, but handleCommit/handleBook clear their in-flight flag in a
`finally` block, so the guard's closure still saw isCommitting/isUpdating as
true and swallowed the close: the dialog would sit open over a booking that
had already succeeded.

The guard was redundant as well as wrong. ConfirmDialog already blocks
Radix-initiated closes while pending, via `onOpenChange={(next) => !pending
&& onOpenChange(next)}` on the Dialog itself. Passing the setter directly
matches every other dialog on both pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-07-29 12:33:36 +02:00
committed by GitHub
parent 8571f9235b
commit bc5c673f7d
10 changed files with 120 additions and 20 deletions
+2 -1
View File
@@ -85,7 +85,8 @@ Compact metric cards (e.g. dashboard tiles, salary KPI row) use `p-4`. Detail ca
| Need | Component | Notes |
|---|---|---|
| Page title + action | `components/ui/page-header.tsx` `PageHeader` | Use this, not bespoke `<h1>` + `<p>` blocks. Drop the `description` prop when it just paraphrases the title. |
| Data table | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `<CardContent className="p-0">` when the table is a card's primary content. Add `tabular-nums` to numeric cells. |
| Data table, **page-level list** | `components/ui/dry-table.tsx` `TH_CLASS` / `TD_CLASS` on a plain `<table className="w-full border-collapse text-[13px]">`, rows `hover:bg-secondary/35` | The concept list table: borderless, straight on the panel, 13px rows, hairline heads. This is what every migrated list page uses. Add `tabular-nums` to numeric cells. Hover-revealed row controls use `HOVER_REVEAL_CLASS` from the same file, never a hand-rolled `opacity-0 group-hover:opacity-100` (coarse pointers never hover, so the control would be unreachable on touch). |
| Data table, **dialog or report view** | `components/ui/table.tsx` `Table / TableHeader / TableHead / TableRow / TableCell` | Header style is baked in: `text-[11px] font-medium uppercase tracking-wider text-muted-foreground`. Wrap in `<CardContent className="p-0">` when the table is a card's primary content. `TableCell` is `px-4 py-3` on `text-sm`, so a page-level list built from this primitive comes out ~15% taller with a different hover tint: use the dry-table row above instead. |
| Status indicator | `components/ui/badge.tsx` `<Badge variant>` | Chips mark exceptions only: normal states (Aktiv, Bokförd, Betald-i-tid) render as muted text (`text-muted-foreground text-xs`); Badge is reserved for rows that deviate (Utkast, Förfallen, Ej bokförd). A table where every row carries the same chip is wrong. Variants: `default / secondary / success / warning / destructive / outline`. **Never** use raw Tailwind colors (`bg-blue-100`, `bg-emerald-500/10`, etc.) for status. Map status → variant via a small `Record` per feature. |
| No-data state | `components/ui/empty-state.tsx` `EmptyState` | Don't hand-roll `<div className="flex flex-col items-center py-12">…</div>`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). |
| Loading placeholder | `components/ui/skeleton.tsx` `<Skeleton>` | Don't hand-roll `bg-muted rounded animate-pulse` divs. |
+41 -2
View File
@@ -17,7 +17,7 @@ import {
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate } from '@/lib/utils'
import { formatCurrency, formatDate } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import JournalEntryAttachments from '@/components/bookkeeping/JournalEntryAttachments'
import JournalEntryStatusBadge, { useSourceTypeLabels } from '@/components/bookkeeping/JournalEntryStatusBadge'
@@ -32,6 +32,7 @@ import CorrectionChain from '@/components/bookkeeping/CorrectionChain'
import RetagLineDialog, { type RetagLine } from '@/components/dimensions/RetagLineDialog'
import { useCompanySettings } from '@/components/settings/useSettings'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -85,6 +86,10 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const [isReversing, setIsReversing] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [isCommitting, setIsCommitting] = useState(false)
// Confirm-before-posting (convention 10). The list already gates this exact
// action; the detail page used to fire the commit straight from the button.
const [showCommitConfirm, setShowCommitConfirm] = useState(false)
const [commitVoucherPreview, setCommitVoucherPreview] = useState<string | null>(null)
const [isLastInSeries, setIsLastInSeries] = useState(false)
const [attachmentCount, setAttachmentCount] = useState(0)
const [references, setReferences] = useState<UnderlagReference[]>([])
@@ -183,6 +188,20 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
}
}, [id, toast, t])
// Open the confirm dialog and fetch the predicted voucher number. The
// prediction is indicative (numbers are assigned atomically at commit); the
// success toast always shows the real one. Mirrors JournalEntryList.
const openCommitConfirm = useCallback(() => {
setShowCommitConfirm(true)
setCommitVoucherPreview(null)
fetch('/api/bookkeeping/voucher-sequences/next')
.then((r) => r.json())
.then(({ data }) => {
if (data?.next != null) setCommitVoucherPreview(`${data.series}${data.next}`)
})
.catch(() => {})
}, [])
const handleCommit = useCallback(async () => {
setIsCommitting(true)
try {
@@ -443,7 +462,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
<Button
size="sm"
className="w-full sm:w-auto"
onClick={handleCommit}
onClick={openCommitConfirm}
disabled={!canWrite || isCommitting}
title={!canWrite ? t('read_only_tooltip') : undefined}
>
@@ -1133,6 +1152,26 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
/>
)}
{/* Confirm-before-posting for drafts (convention 10): describes the
outcome ("Bokförs som A-218 ...") before the commit runs, matching
the same action on the list. */}
<ConfirmDialog
open={showCommitConfirm}
onOpenChange={setShowCommitConfirm}
title={t('confirm_post_title')}
description={
commitVoucherPreview
? t('confirm_post_description', {
voucher: commitVoucherPreview,
description: entry?.description || '',
amount: formatCurrency(totalDebit),
})
: t('confirm_post_description_generic', { description: entry?.description || '' })
}
confirmLabel={t('post')}
onConfirm={handleCommit}
/>
{/* Delete confirmation dialog */}
<ConfirmationDialog
open={showDeleteConfirm}
+43 -1
View File
@@ -50,6 +50,7 @@ import {
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import {
@@ -165,6 +166,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [accountingMethod, setAccountingMethod] = useState<'accrual' | 'cash'>('accrual')
// #967: register/send without booking; ekonomi books in a separate step.
const [deferInvoiceBooking, setDeferInvoiceBooking] = useState(false)
const [showBookConfirm, setShowBookConfirm] = useState(false)
const [bookVoucherPreview, setBookVoucherPreview] = useState<string | null>(null)
const [reminderDays, setReminderDays] = useState<[number, number, number]>([15, 30, 45])
const statusLabel = (status: InvoiceStatus): string => t(`status_${status}`)
@@ -349,6 +352,20 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
}
// #967: deferred booking: create the revenue verifikat afterwards.
// Confirm-before-posting (convention 10): booking an invoice writes an
// immutable verifikat, so describe the outcome first. The predicted voucher
// number is indicative; the toast afterwards reports what actually landed.
function openBookConfirm() {
setShowBookConfirm(true)
setBookVoucherPreview(null)
fetch('/api/bookkeeping/voucher-sequences/next')
.then((r) => r.json())
.then(({ data }) => {
if (data?.next != null) setBookVoucherPreview(`${data.series}${data.next}`)
})
.catch(() => {})
}
async function handleBook() {
if (!invoice) return
setIsUpdating(true)
@@ -1294,7 +1311,7 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{t('not_booked_yet')}</span>
{canWrite && (
<Button size="sm" onClick={handleBook} disabled={isUpdating}>
<Button size="sm" onClick={openBookConfirm} disabled={isUpdating}>
{t('book_action')}
</Button>
)}
@@ -1815,6 +1832,31 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
onSuccess={() => fetchInvoice()}
/>
)}
{/* Confirm-before-posting (convention 10): booking writes an immutable
verifikat, so the outcome is described before the POST, not narrated
in a toast afterwards. */}
<ConfirmDialog
open={showBookConfirm}
onOpenChange={setShowBookConfirm}
title={t('confirm_book_title')}
description={
bookVoucherPreview
? t('confirm_book_description', {
voucher: bookVoucherPreview,
number: invoiceDisplayNumber(invoice as Invoice),
amount: formatCurrency(
getDisplayTotal(invoice, { ore_rounding: oreRounding }).displayed,
invoice.currency,
),
})
: t('confirm_book_description_generic', {
number: invoiceDisplayNumber(invoice as Invoice),
})
}
confirmLabel={t('book_action')}
onConfirm={handleBook}
/>
</div>
)
}
+3 -5
View File
@@ -7,7 +7,7 @@ import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { DataListEmpty, DataListLoading } from '@/components/ui/data-list'
import { ContextPicker } from '@/components/common/ContextPicker'
import { QUIET_LINK_CLASS, VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table'
import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS, VTH_CLASS, VTD_CLASS } from '@/components/ui/dry-table'
import {
SlideOver,
SlideOverContent,
@@ -1364,10 +1364,8 @@ export default function PendingOperationsPage() {
onCheckedChange={() => toggleSelected(op.id)}
aria-label={t('select_operation_aria')}
className={cn(
'transition-opacity duration-150',
isSelected
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 focus-visible:opacity-100',
'duration-150',
isSelected ? 'opacity-100' : HOVER_REVEAL_CLASS,
)}
/>
)}
@@ -5,10 +5,11 @@ import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
import type { SalaryRun, SalaryRunEmployee, SalaryLineItem, SalaryLineItemType, EmployeeMasked } from '@/types'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -319,10 +320,13 @@ export default function SalaryRunEmployeeDetailPage({
function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) {
const t = useTranslations('salary_run_employee')
return (
<div className={`rounded-md border bg-card p-3 ${accent ? 'ring-1 ring-primary/40' : ''} ${overridden ? 'ring-1 ring-warning/40' : ''}`}>
<div className={cn('rounded-md border bg-card p-3', accent && 'ring-1 ring-primary/40')}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{label}
{overridden && <span className="text-[10px] uppercase tracking-wider text-warning">{t('adjusted_badge')}</span>}
{/* The override is an exception, so it is a chip, not a second ring:
two ring-1 rules on one element set the same custom property and
one of them silently loses (design.md conventions 5 and 12). */}
{overridden && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</div>
<div className="mt-0.5 text-lg font-medium tabular-nums">{formatCurrency(value)}</div>
</div>
+2 -5
View File
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { deadlineDateLabel } from './DeadlineRow'
import { Pencil } from 'lucide-react'
@@ -92,10 +92,7 @@ export function DeadlineGroupCard({ deadlines, onEdit, onRequestToggle }: Deadli
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(
QUIET_LINK_CLASS,
'shrink-0 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100',
)}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS, 'shrink-0')}
>
{t('group_mark_done')}
</button>
+3 -3
View File
@@ -4,7 +4,7 @@ import { useTranslations } from 'next-intl'
import { Deadline } from '@/types'
import { cn } from '@/lib/utils'
import { isDeadlineOverdue, parseDate, startOfDay } from '@/lib/calendar/utils'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { HOVER_REVEAL_CLASS, QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import {
CalendarClock,
Check,
@@ -165,7 +165,7 @@ export function DeadlineRow({ deadline, onEdit, onRequestToggle }: DeadlineRowPr
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('mark_not_done')}
</button>
@@ -176,7 +176,7 @@ export function DeadlineRow({ deadline, onEdit, onRequestToggle }: DeadlineRowPr
e.stopPropagation()
onRequestToggle(deadline)
}}
className={cn(QUIET_LINK_CLASS, 'opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100')}
className={cn(QUIET_LINK_CLASS, HOVER_REVEAL_CLASS)}
>
{t('group_mark_done')}
</button>
+7
View File
@@ -16,6 +16,13 @@ export const VTD_CLASS = 'py-[7px] pr-4 border-b border-border/60 align-top'
export const QUIET_LINK_CLASS =
'text-[12.5px] text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground'
// Row controls that stay out of the way until the row is hovered. Coarse
// 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`.
export const HOVER_REVEAL_CLASS =
'opacity-0 transition-opacity group-hover:opacity-100 focus-visible: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.
export function RowFoldout({ children }: { children: React.ReactNode }) {
+6
View File
@@ -3081,6 +3081,9 @@
},
"invoice_detail": {
"back": "Back",
"confirm_book_title": "Book invoice",
"confirm_book_description": "Will be posted as voucher {voucher}: invoice {number}, {amount}. A posted voucher cannot be edited, only corrected or reversed.",
"confirm_book_description_generic": "Invoice {number} will be posted with the next available voucher number and can then only be corrected or reversed.",
"load_failed_title": "Could not load invoice",
"load_failed_description": "The invoice was not found.",
"status_draft": "Draft",
@@ -4116,6 +4119,9 @@
"error_not_found": "Journal entry not found",
"error_load_failed": "Could not fetch journal entry",
"post": "Post",
"confirm_post_title": "Post voucher",
"confirm_post_description": "Will be posted as voucher {voucher}: {description}, {amount}. A posted voucher cannot be edited, only corrected or reversed.",
"confirm_post_description_generic": "The voucher \"{description}\" will be posted with the next available voucher number and can then only be corrected or reversed.",
"delete_draft": "Delete draft",
"delete_entry": "Delete journal entry",
"create_correction": "Create correction entry",
+6
View File
@@ -3081,6 +3081,9 @@
},
"invoice_detail": {
"back": "Tillbaka",
"confirm_book_title": "Bokför faktura",
"confirm_book_description": "Bokförs som verifikat {voucher}: faktura {number}, {amount}. Verifikatet kan inte ändras i efterhand, bara rättas eller stornas.",
"confirm_book_description_generic": "Faktura {number} bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
"load_failed_title": "Kunde inte ladda faktura",
"load_failed_description": "Fakturan hittades inte.",
"status_draft": "Utkast",
@@ -4116,6 +4119,9 @@
"error_not_found": "Verifikation hittades inte",
"error_load_failed": "Kunde inte hämta verifikation",
"post": "Bokför",
"confirm_post_title": "Bokför verifikat",
"confirm_post_description": "Bokförs som verifikat {voucher}: {description}, {amount}. Verifikatet kan inte ändras i efterhand, bara rättas eller stornas.",
"confirm_post_description_generic": "Verifikatet \"{description}\" bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
"delete_draft": "Radera utkast",
"delete_entry": "Radera verifikat",
"create_correction": "Skapa ändringsverifikation",