From bc5c673f7d902e778e45dc14ea1eebdea31165a4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg <149234542+jakobwennberg@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:33:36 +0200 Subject: [PATCH] fix(ui): reach touch-only actions, confirm before posting, drop a dead ring (#1280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 5 (1M context) --- .claude/rules/design.md | 3 +- app/(dashboard)/bookkeeping/[id]/page.tsx | 43 +++++++++++++++++- app/(dashboard)/invoices/[id]/page.tsx | 44 ++++++++++++++++++- app/(dashboard)/pending/page.tsx | 8 ++-- .../runs/[id]/employees/[employeeId]/page.tsx | 10 +++-- components/deadlines/DeadlineGroupCard.tsx | 7 +-- components/deadlines/DeadlineRow.tsx | 6 +-- components/ui/dry-table.tsx | 7 +++ messages/en.json | 6 +++ messages/sv.json | 6 +++ 10 files changed, 120 insertions(+), 20 deletions(-) diff --git a/.claude/rules/design.md b/.claude/rules/design.md index 2cd4676c..ce17ea3c 100644 --- a/.claude/rules/design.md +++ b/.claude/rules/design.md @@ -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 `

` + `

` 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 `` 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 ``, 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 `` 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` `` | 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 `
`. Preset variants exist (`EmptyInvoices`, `EmptyCustomers`, `EmptyTransactions`, etc.). | | Loading placeholder | `components/ui/skeleton.tsx` `` | Don't hand-roll `bg-muted rounded animate-pulse` divs. | diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 09a5a536..70cb4f6c 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -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(null) const [isLastInSeries, setIsLastInSeries] = useState(false) const [attachmentCount, setAttachmentCount] = useState(0) const [references, setReferences] = useState([]) @@ -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 )} @@ -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. */} + ) } diff --git a/app/(dashboard)/pending/page.tsx b/app/(dashboard)/pending/page.tsx index d4275c3e..beae9eb2 100644 --- a/app/(dashboard)/pending/page.tsx +++ b/app/(dashboard)/pending/page.tsx @@ -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, )} /> )} diff --git a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx index 7b75c18e..0efd4e62 100644 --- a/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx +++ b/app/(dashboard)/salary/runs/[id]/employees/[employeeId]/page.tsx @@ -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 ( -
+
{label} - {overridden && {t('adjusted_badge')}} + {/* 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 && {t('adjusted_badge')}}
{formatCurrency(value)}
diff --git a/components/deadlines/DeadlineGroupCard.tsx b/components/deadlines/DeadlineGroupCard.tsx index ab3ef864..e8a8ed0f 100644 --- a/components/deadlines/DeadlineGroupCard.tsx +++ b/components/deadlines/DeadlineGroupCard.tsx @@ -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')} diff --git a/components/deadlines/DeadlineRow.tsx b/components/deadlines/DeadlineRow.tsx index f38f2c2f..11ff7f08 100644 --- a/components/deadlines/DeadlineRow.tsx +++ b/components/deadlines/DeadlineRow.tsx @@ -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')} @@ -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')} diff --git a/components/ui/dry-table.tsx b/components/ui/dry-table.tsx index 2f37b57e..ba787220 100644 --- a/components/ui/dry-table.tsx +++ b/components/ui/dry-table.tsx @@ -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 }) { diff --git a/messages/en.json b/messages/en.json index 1befbc9b..d1989a9d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index 08d41445..7004c105 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",